Compare commits

...
Author SHA1 Message Date
enricobuehler 8508f8f3c3 Merge pull request 'The gamescope bind is armed on every gamescope box, takes a user namespace that breaks Xwayland, and leaves a drop-in that bricks the next boot' (#151) from worktree-bind-userns into main
ci / web (push) Successful in 1m9s
apple / swift (push) Successful in 1m31s
ci / rust-arm64 (push) Failing after 1m49s
apple / screenshots (push) Canceled after 43s
ci / bun-nix (push) Successful in 42s
android / android (push) Canceled after 2m38s
ci / rust (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 1m27s
deb / build-publish-host (push) Canceled after 9s
deb / build-publish (push) Canceled after 8s
deb / build-publish-client-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / deploy-docs (push) Successful in 26s
arch / build-publish (push) Successful in 10m41s
windows-host / package (push) Successful in 12m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m38s
Reviewed-on: #151
2026-08-09 22:41:40 +00:00
enricobuehler 6695300b67 fix(pf-vdisplay): the gamescope bind took a user namespace that broke Xwayland, and left a drop-in that bricked the next boot
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Canceled after 4m4s
ci / rust (pull_request) Canceled after 4m13s
Field-diagnosed on Nobara (fc44, canary g13179011), where Game Mode became unstartable and the box
was handed to plasma. #144's bind works — the patched build genuinely reaches a session script that
hardcodes /usr/bin/gamescope — but a mount namespace in a systemd USER unit is also a USER namespace,
and only this uid is mapped in it. Measured on the box:

    on disk / in a unit without the bind :  drwxrwxrwt 2 0 0          /tmp/.X11-unix
    in a unit WITH the bind              :  drwxrwxrwt 2 65534 65534  /tmp/.X11-unix
    uid_map inside                       :  1000 1000 1

wlroots checks that /tmp/.X11-unix is "owned by root or us", sees nobody, and refuses:

    wlserver: [xwayland/sockets.c:100] /tmp/.X11-unix not owned by root or us
    wlserver: [xwayland/sockets.c:217] No display available in the first 33
    -> SIGSEGV in run_pipewire

Three ~10 s failures then feed chimeraos' short-session tracker, session-plus stops even trying, and
steamos-session-select rewrites the user's session to plasma. So the symptom an operator reports is
"thrown onto KDE and I can't get back" — two removes from the cause.

Two further bugs found while fixing it, each worse than the one reported:

  * THE BIND WAS ARMED EVERYWHERE. The condition was only `gamescope_bin() != /usr/bin/gamescope`,
    so every box with punktfunk-gamescope installed took a namespace it has no use for — Bazzite,
    SteamOS-likes, the Deck. The blast radius was every gamescope box, not just the hardcoded-path
    ones the mechanism exists for. Now the host READS the session script and arms only where it
    never mentions GAMESCOPE_BIN and names /usr/bin/gamescope outright; everything else is
    bit-for-bit pre-#144, no namespace at all. An unreadable script does not arm.

  * THE DROP-IN OUTLIVED ITS SOURCES. It was written to ~/.config/systemd/user/ on the TEMPLATE, so
    it also applied to the box's OWN autologin unit at every boot — while both paths it binds live in
    tmpfs. After a reboot the drop-in survives and its sources do not, and BindReadOnlyPaths= with a
    missing source fails the unit outright. THAT is why the field symptom survived a reboot. It now
    lives in $XDG_RUNTIME_DIR (dies with the login session), removal covers both the runtime and the
    legacy $HOME path, and restore_takeover_on_startup does that removal unconditionally at host
    start — which is the upgrade path for every box already running canary g13179011. Without it,
    updating the host would not un-brick them.

  * A bind was armed even when gamescope_bin() fell back to the bare name "gamescope". The wrapper
    execs `gamescope` through PATH inside the unit — onto the path we just bound the wrapper over.
    Fork bomb. Refused ahead of even the operator's force.

Where the bind IS armed it now carries its own compensation: a user-owned $XDG_RUNTIME_DIR/punktfunk-x11
bound read-WRITE over /tmp/.X11-unix (Xwayland creates the socket there), so the ownership check sees
"us". Skipped when that directory is already ours or absent — neither is the hazard. Stale sockets are
pruned by connect-test so a SIGKILLed session cannot walk the 33 display slots away.

And rather than trust that reasoning, the host now ASKS THE BOX before arming: it runs the field
reproduction with the real property set — `systemd-run --user --wait --collect --property=<the same
args> -- stat -c %u /tmp/.X11-unix` — and arms only if the answer is our uid. Anything else (65534, a
rejected property, no user manager, a blown 10 s budget) means no bind, and the session runs stock
gamescope: no HDR, no in-node cursor, but it STARTS. A runtime backstop disarms and relaunches if a
session launched with the bind armed produces no node in its window, latching one-way per process.

The XFixes-cursor concern that argued against relocating the socket does not hold: the only host-side
X client is spawned under `plan.gamescope_cursor`, which is `gamescope && !gamescope_composites_cursor()`,
and our shipped +pfhdr4 build is patch level 4 — so on the very route where the bind arms, that reader
is never constructed.
2026-08-10 00:39:18 +02:00
enricobuehler 73d435b967 Merge pull request 'The driver clippy gate has been red on main since the Xbox pad landed' (#150) from worktree-drivers-clippy into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m25s
ci / bun-nix (push) Successful in 28s
ci / docs-site (push) Successful in 1m12s
ci / rust (push) Successful in 9m34s
windows-drivers / probe-and-proto (push) Successful in 21s
windows-drivers / driver-build (push) Successful in 1m39s
windows-host / package (push) Successful in 12m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 58s
docker / deploy-docs (push) Successful in 42s
Reviewed-on: #150
2026-08-09 21:55:42 +00:00
enricobuehler c7df7b45af fix(drivers/pf-gamepad): the three Xbox identities as a range — the driver clippy gate is red on main
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 37s
ci / rust-arm64 (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 11m37s
windows-drivers / probe-and-proto (pull_request) Successful in 23s
windows-drivers / driver-build (pull_request) Successful in 1m43s
`cargo clippy --all-targets -- -D warnings` over the shipped drivers (the step that
enforces the unsafe-audit gates) fails on main since #149 landed: clippy 1.96's
`manual_range_patterns` fires on all five `4 | 5 | 6` device-type arms, and `-D warnings`
turns each into an error, so `pf-gamepad` fails to compile as both lib and lib-test and
the whole step never reaches the other five crates.

Device types 4/5/6 are the Xbox Wireless / One S / Elite Series 2 identities added by
#149 — contiguous by construction, so `4..=6` is the same set. Purely a lint fix: no arm
gains or loses a device type, and the comments that already record *why* the three share
one report shape, one descriptor and one vendor string are untouched.
2026-08-09 23:46:43 +02:00
enricobuehler 5d7091bf87 Merge pull request 'The Windows Xbox pad: make games actually see it' (#149) from worktree-xbox-pad-wgi-visibility into main
apple / swift (push) Successful in 1m35s
windows-drivers / probe-and-proto (push) Successful in 26s
windows-drivers / driver-build (push) Failing after 1m40s
android / android (push) Successful in 6m12s
ci / rust-arm64 (push) Successful in 5m5s
ci / bun-nix (push) Successful in 21s
ci / web (push) Successful in 5m31s
arch / build-publish (push) Successful in 8m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / docs-site (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
release / apple (push) Successful in 10m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 45s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m4s
docker / deploy-docs (push) Skipped
ci / rust (push) Successful in 12m22s
deb / build-publish-client-arm64 (push) Successful in 4m27s
deb / build-publish-host (push) Successful in 7m16s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish (push) Successful in 9m58s
apple / screenshots (push) Successful in 6m11s
windows-host / package (push) Canceled after 12m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 9m46s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m31s
nix / flake (push) Failing after 21m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m26s
Reviewed-on: #149
2026-08-09 21:36:29 +00:00
enricobuehler e19f11bb0d feat(abi/apple): carry the trigger motors to non-Rust clients — ABI 18, next_rumble_cmd2
windows-drivers / probe-and-proto (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m37s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 2m57s
ci / bun-nix (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m36s
android / android (pull_request) Successful in 4m0s
ci / rust-arm64 (pull_request) Successful in 4m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 4m36s
ci / rust (pull_request) Failing after 9m15s
nix / flake (pull_request) Successful in 15m3s
The `0xCA` wire already carries the two Xbox impulse-trigger motors (v3), and the Rust decode path
already parses them; `datagram_task.rs` dropped them on the floor with a comment naming exactly this
work as what remained. The blocker was the C ABI: every non-Rust client pulls rumble through
`punktfunk_connection_next_rumble_cmd`, whose out-params cannot carry two more channels.

    PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
        PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
        uint16_t *left_trigger, uint16_t *right_trigger,
        uint32_t *backstop_ms, uint32_t timeout_ms);

⚠️ ADDED, not widened. `_cmd` keeps its signature and its values bit-identical for handle-only
traffic — out-of-tree embedders depend on it and `docs/embedding-the-c-abi.md` documents it, so
silently changing an exported symbol would break every consumer at once. `nm` on the staticlib shows
all four rumble entry points still exported. `ABI_VERSION` 17 → 18; every other site reads it
dynamically, so there are no hardcoded mirrors to drift.

⚠️ ONE HONEST BEHAVIOURAL DELTA, documented in `abi.rs` and pinned by a test: against a
trigger-driving host a `_cmd` caller now receives commands with `low == high == 0` where the demux
previously dropped the update entirely. They are idempotent handle stops, and the redundant-stop
suppression cannot fold them because the command as a whole is not silent. Zero cost today —
nothing sources non-zero triggers.

The dedupe-jitter proof was RE-DERIVED rather than widened, which is the kind of thing that quietly
rots when a tuple grows: the nudge touches only `low` by ±1 LSB and `emit` is only reached with a
non-silent level, so the nudged tuple can collide with the four-field stop sentinel only at
`(1,0,0,0)`. A test pins both directions — refuse at `(1,0,0,0)`, flip freely at `(1,0,lt,0)`.

Apple renders them: `RumbleRenderer` gains `Motor?` slots at `GCHapticsLocality.leftTrigger` /
`.rightTrigger` beside the existing handles. A controller without trigger actuators degrades
silently — a nil engine yields a nil slot and `reconcile` no-ops — and absent localities are never
logged, because on most pads that is the normal case rather than a fault. The macOS DualSense
raw-HID branch stays a deliberate no-op: a DualSense has ADAPTIVE triggers, not trigger rumble
motors, and inventing a mapping there would buzz the wrong thing.

🛑 BUILT AHEAD OF A PRODUCER, DELIBERATELY, AND NOTHING HERE CLAIMS OTHERWISE. Nothing can currently
source trigger rumble on Windows and that is measured, not assumed: `XINPUT_VIBRATION` has two
members, and GameInput — the only four-motor API — does not enumerate an xinputhid-promoted Xbox pad
at all, verified against a REAL Microsoft Elite which is equally invisible to it while classic
XInput reads it live. So this path has never been exercised end to end and the comments say so.

VERIFIED
  * `cargo test -p punktfunk-core --features quic --lib` 378 passed on macOS, 203 on Windows;
    clippy `-D warnings` clean with and without default features; `cargo fmt --all --check` clean.
  * The generated header is regenerated and idempotent on re-run (CI diffs it).
  * SWIFT ACTUALLY COMPILES AND RUNS: `swift build` clean and `swift test` 262 passed / 0 failures
    in `clients/apple`, against a locally built xcframework. (Editor SourceKit errors about
    `PunktfunkCore`/`DualSenseHID` are index noise from that gitignored artifact — a real build
    resolves both, and the `DualSenseHID` references are untouched by this change.)
  * `cargo build -p punktfunk-host` clean on Windows.

NOT VERIFIED
  * End to end — see above; there is no producer.
  * Whether a real Xbox pad on Apple actually reports the two trigger localities. The degrade needs
    no code, but the positive case is untested.
  * `pf-client-core` (the SDL renderer) does not build on macOS at baseline and is unbuilt here. It
    only reads `RumbleCommand` fields and never constructs one, so added fields cannot break it, but
    it still calls `_cmd`; wiring `SDL_RumbleGamepadTriggers` is separate work.

ANDROID: NOT DONE, and it should stay that way for now. `pack_rumble` packs pad/backstop/low/high
into bits 0..52 of a `jlong` with `-1` reserved as a sentinel — two more `u16` do not fit. The right
fix if ever wanted is the direct-`ByteBuffer` shape `nativeNextHidout` already uses in the same file
(zero-allocation, caller-owned, the established idiom), not a second `jlong` (racy across two calls)
nor `long[]` (an allocation per pull). But no Android device exposes trigger actuators at all, so
there is nothing to render. Separately stale and also not fixed: `NativeBridge.kt`'s KDoc still
documents the v2 `ttl_ms` layout rather than `backstop_ms`.
2026-08-09 23:30:02 +02:00
enricobuehler 7f1f7ba87c fix(pads/windows): say WHY a pad index is taken, and stop the devtest lying when it is
Debugging the on-glass session, a devtest run died with

    error=create gamepad bootstrap mailbox Global\pfds-boot-0: Zugriff verweigert (0x80070005)
    (install/repair: punktfunk-host.exe driver install --gamepad)

and then — this is the part that cost real time — kept printing "virtual Xbox One S Controller up",
streamed frames into nothing, and let the operator measure the INCUMBENT pad on that index. The
XInput packet count sat frozen and read as "the pad is dead", which was a wrong conclusion drawn
from a harness that had already failed and not said so.

WHAT IT ACTUALLY WAS. Pad lifetime is deliberately tied to the SESSION (native/input.rs: "the
gamepads are created and torn down with the session"), and a live session's pad legitimately owns
`Global\pfds-boot-0`. The mailbox's SDDL is `D:P(A;;GA;;;SY)(A;;GA;;;LS)` — SYSTEM and LocalService
only — and the host service runs as LocalSystem while a hand-run devtest runs as an elevated
Administrator, which is in neither ACE. `CreateFileMappingW` over an existing name is really an
OPEN, access-checked against the incumbent's DACL, so it returned ACCESS_DENIED and bailed at the
`?` BEFORE reaching the `ERROR_ALREADY_EXISTS` branch that already had the right sentence. That
branch only ever fires when both processes run as the same account.

The name is per-index on purpose and stays that way: `Global\pfds-boot-{index}` is the rendezvous
the driver polls, and its existence doubles as host-liveness. Making it per-process would let two
hosts build two devices on one wire index — the "the game sees two controllers" bug. The collision
is correct; only the diagnosis was wrong.

  * `gamepad_raii.rs` classifies the failure: on ACCESS_DENIED it probes with `OpenFileMappingW`,
    which separates what the OS collapsed — object-manager lookup precedes the access check, so
    absent gives FILE_NOT_FOUND and present-but-forbidden gives ACCESS_DENIED. It now says the
    mailbox belongs to a live session's pad and that nothing is wrong with the drivers.
  * `pad_slots.rs` carries that as a typed `PadCreateFault` through the anyhow chain, so `ensure`
    prints the fault's remedy instead of the per-backend reinstall hint, plus the pad index.
  * `devtest.rs` now BAILS when no pad was actually built, instead of announcing success. This is
    the fix that matters: every probe an operator runs next will still find a device on that index.
  * `native.rs` names what a detached input thread still holds, since that is one of the ways a pad
    can outlive its session.

DELIBERATELY NOT CHANGED, with reasons: the session-scoped pad lifetime (intentional and
documented); the mailbox naming (load-bearing, above); the retry/backoff (latching would resurrect
the `broken` flag `PadGate` exists to kill); the 10 s thread-detach in `serve_session` and the
service's `TerminateProcess` shutdown — both are real ways a devnode can outlive its owner, but
neither is evidenced in the field case and inventing a fix for an unobserved path is how you get a
regression instead of a bugfix.

`pf-inject/lib.rs` drops the `cfg(any(linux, windows))` gate on `pad_gate`/`pad_slots`. Neither
touches an OS pad API, and the gate meant a classification whose entire subject is a `cfg(windows)`
failure could not be tested on a dev machine at all.

VERIFIED
  * ON WINDOWS (.173): `cargo test -p pf-inject --lib` 109/109; `cargo build -p punktfunk-host`
    clean. Both agents' Windows code was compile-UNVERIFIED before this run.
  * macOS: 5 new tests, including one that pins the anyhow downcast through the exact three-layer
    context chain the Windows code builds — the assumption that could not otherwise be checked.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * That a LocalSystem-owned mailbox really answers `OpenFileMappingW` with ACCESS_DENIED rather
    than FILE_NOT_FOUND from an Administrator token. That is reasoned from the object manager's
    lookup-then-access-check order, not measured. Repro on .173: hold a session pad on index 0, run
    the devtest from an elevated console, and check the new sentence appears.
2026-08-09 23:29:30 +02:00
enricobuehler d39843a858 Merge pull request 'The Game Mode takeover blamed polkit for a group it never named, and prescribed two remedies that cannot work' (#148) from worktree-dm-helper-preflight into main
apple / swift (push) Successful in 1m50s
ci / web (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 3m7s
ci / docs-site (push) Successful in 1m14s
ci / bun-nix (push) Successful in 30s
android / android (push) Successful in 6m53s
arch / build-publish (push) Successful in 8m35s
apple / screenshots (push) Successful in 6m15s
deb / build-publish (push) Successful in 3m43s
deb / build-publish-client-arm64 (push) Successful in 3m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 43s
windows-host / package (push) Successful in 12m7s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 11m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 41s
windows-host / canary-manifest (push) Successful in 25s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 9m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m29s
Reviewed-on: #148
2026-08-09 21:17:36 +00:00
enricobuehler fb309e0262 fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m9s
ci / rust-arm64 (pull_request) Successful in 3m25s
android / android (pull_request) Successful in 4m28s
ci / rust (pull_request) Successful in 19m29s
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:

    the packaged pf-dm-helper polkit action is missing or was denied (reinstall the punktfunk
    package, or install the display-manager polkit rule from the docs)

Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:

    pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
      Grant it with: sudo usermod -aG punktfunk nobara-user   (then re-login)

That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.

Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.

The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.

A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.

Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
2026-08-09 23:15:34 +02:00
enricobuehler 94c2f62490 test(tools): a GameInput probe — and it cannot see our promoted Xbox pad
`win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has
no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports
whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives
`SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is
positional, so a wrong slot calls a different method with the wrong signature.

WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never
exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four
(`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API
that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit
assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1
(the triggers) are inferred from field order and nothing else.

TWO THINGS MEASURED ON .173, 2026-08-09:

1.  GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably
   returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`:
   the API looks like a query and is really a cache someone else fills. A bounded poll fixes it.
   ⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing:
   `GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the
   default already admits background input. Do not "fix" this with `SetFocusPolicy`.

2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and
   sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads —
   and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment.

⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that
nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput
structurally cannot; GameInput can but does not see it.

⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides
the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use,
a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap
and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same
way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a
property of GameInput in a non-interactive session and not our defect — the same shape as the WGI
`ts=0` row, which a real Elite reproduced.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS).
  * Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and
    `SetRumbleState` is accepted.
  * The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than
    failing to link or crashing.

NOT VERIFIED
  * That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing
    observable was checked on that device, and it never reached ours.
  * `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the
    struct has variable-size members whose layout would have to be mirrored exactly; nothing here
    needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad
    has trigger motors" — worth adding if this line of enquiry continues.
2026-08-09 22:55:17 +02:00
enricobuehler 2b1843ed1c fix(drivers/pf-gamepad): the right stick is Z/Rz — as declared, it was dead
Found on glass, first real streaming session: everything worked except the right stick, and Steam
correctly showed "Xbox One S Controller". `XBOX_RDESC` declared the right stick as `Rx`/`Ry`.
`xinputhid`, which translates our HID collection into XUSB, maps `Z`/`Rz` to the right stick and
does not treat `Rx`/`Ry` as one, so those two axes reached nothing.

Two usage bytes. Left and right were declared identically here — same collection, same globals,
same size and count — so the usages were the entire difference, which is what makes the diagnosis
airtight rather than plausible. Note `DUALSENSE_RDESC`, a real capture, also uses `Z`/`Rz` for its
right stick and puts the TRIGGERS on `Rx`/`Ry`; that is most likely where the original mistake came
from.

⚠️ Byte offsets are unchanged — still 16×2 at bit 5.0 — so `xbox_proto`'s layout tests and the
host-side packing are untouched. This is a pure relabelling.

🛑 THE REAL LESSON IS THE HARNESS, AND IT IS FIXED HERE TOO. This survived every bench measurement
because `dualsense-windows-test` drove LS-X and the A button and left the other five analogue axes
at zero. `XInputGetState` read `RX [0..0]`, which I read as "the devtest doesn't move it" — true,
and useless: a harness that exercises one axis cannot tell "this axis is not mapped" from "nothing
is driving it", and the two are indistinguishable in every consumer. The devtest now sweeps all six
axes on distinct phases and ramps both triggers, so one run shows which axes arrive AND that they
are not crosstalking onto each other's bytes.

MEASURED ON .173, same run shape before and after, devtest sweeping all six axes:
  before:  LX [-11264..24576]  LY [-32768..31744]  RX [0..0]        RY [-1..-1]       LT [0..248]  RT [7..255]
  after:   LX  [-8192..26624]  LY [-32768..31744]  RX [-32768..31744] RY [-24576..10240] LT [0..248]  RT [7..255]

VERIFIED
  * `cargo test -p pf-inject --lib` 104/104 on Windows; `xbox` subset 11/11 on macOS — the layout
    tests still pass because nothing moved.
  * Driver rebuilds and signs; the descriptor is still 223 bytes so the `wReportLength` const assert
    is undisturbed.
  * `cargo fmt --all --check` clean.

NOT VERIFIED
  * Not yet re-tested in a real streaming session — that is the next on-glass run.
  * ⚠️ A leftover finding from the same session, unrelated to this fix and NOT investigated: the
    session's pad devnode SURVIVES client disconnect and keeps the `Global\pfds-boot-0` bootstrap
    mailbox, so a devtest run afterwards fails with `Zugriff verweigert (0x80070005)` and silently
    measures the stale pad instead. Restarting the service releases it. Worth its own look.
2026-08-09 22:19:25 +02:00
enricobuehler 4f9071b980 feat(pads/windows): three Xbox identities — Wireless, One S and Elite Series 2
Until now there was one Xbox identity, `device_type = 4` / `045E:0B13`, and Windows folded a
client's `XboxOne` request onto it because the only Windows Xbox backend was the XUSB companion,
which presents one fixed 360 identity and cannot vary it. The HID backend can, so the fold goes and
two identities join it:

  devtype 4  045E:0B13  pf_xboxwireless  Xbox Wireless Controller
  devtype 5  045E:02FD  pf_xboxones      Xbox Wireless Controller (One S)
  devtype 6  045E:0B22  pf_xboxelite     Xbox Elite Wireless Controller Series 2

`GamepadPref::XboxElite` takes wire byte 11 — the first unassigned one, and the round-trip test
previously asserted `from_u8(11) == Auto` with a comment saying assigning it must update that; the
sentinel moved to 12. The C ABI mirror and the generated header moved with it.

 ALL THREE SHARE ONE REPORT DESCRIPTOR, deliberately. In HID terms they are the same pad; the
descriptor is the report shape, not the identity. §3 of the handoff records that our single
hand-written descriptor already cost three separate bugs, and inventing two more would multiply
that debt for no measured gain. They differ in VID/PID, product string, hardware id and Device
Manager description only.

⚠️ All three install `pfGamepadXbox`, the section that attaches the `xinputhid` bus filter. That
was the open risk: Microsoft's `xinputhid.inf` promotes by an explicit hardware-id allow-list
containing `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — and NEITHER `02FD` NOR `0B22` is on
it. Measured on .173: promotion does not care, because it comes from our own AddReg rather than
from matching Microsoft's ids. All three gain `IG_00`, register an XUSB interface, and are read
live by classic XInput. Had this gone the other way the two new identities would have been strictly
worse than the one they joined.

The XUSB escape hatch needed a runtime degrade to stay honest. `pick_gamepad` is compile-time only,
so with `PUNKTFUNK_XBOX_BACKEND=xusb` the host would have resolved and echoed `xboxelite` in its
`Welcome` while actually building a 360 pad. `degrade_xbox_identity` folds the identity back at
runtime, mirroring `degrade_if_no_uhid`.

VERIFIED ON WINDOWS (.173 — none of this compiles on macOS; the driver needs the WDK and the rest
is `cfg(windows)`):
  * `cargo test -p pf-inject --lib` 104/104 — including `hwid_matches_inf`,
    `hwid_devtype_table_matches_the_driver` and `only_the_xbox_identity_installs_the_xinputhid_section`,
    all now sweeping the whole identity set and asserting the section split in both directions.
  * `cargo test -p punktfunk-core --lib gamepad` 7/7; `cargo check -p punktfunk-host` clean.
  * Driver builds and signs; the descriptor/`wReportLength` const asserts still hold with the
    descriptor shared three ways.
  * ON GLASS, per identity, via the new `--xboxones` / `--xboxelite` devtest legs: each gets its own
    devnode (`PF_XBOX_0` / `PF_XBOX_ONES_0` / `PF_XBOX_ELITE_0`), each HID child gains `IG_00`, each
    registers an XUSB interface, and XInput reads each live (packets advancing, `buttons=0x1000`).
  * macOS: `cargo fmt --all --check` clean in both workspaces.

NOT VERIFIED / NOT DONE
  * **Elite paddles are NOT implemented.** `BTN_PADDLE1..4` would need descriptor buttons, and once
    `xinputhid` promotes the pad it claims the HID collection exclusively — XInput has no paddle
    fields and the HID consumers that do may be locked out, so the buttons would likely reach
    nobody. The decisive measurement is cheap and named in the code: hold a paddle bit set and see
    whether a user-mode HID reader still gets reports. Until then the Edge remains the only virtual
    pad with native back-button slots and nothing should be advertised otherwise.
  * **No client picker offers the Elite**, and none can auto-detect it — SDL3's `GamepadType` has no
    Elite variant. It is reachable today only via `PUNKTFUNK_GAMEPAD=xboxelite` or a hand-edited
    client setting. All five clients ship the same curated six options by deliberate parity, so
    adding one is a cross-client UX change, not part of this.
  * Nothing here has run in a real streaming session; every measurement came from the devtest.
2026-08-09 21:41:53 +02:00
enricobuehler 1317901122 Merge pull request 'Uninstalling the Windows host left every audio device it minted behind forever — and the installer script documented that as a decision' (#145) from worktree-win-audio-uninstall-cleanup into main
android / android (push) Failing after 1m32s
ci / rust-arm64 (push) Successful in 1m53s
apple / swift (push) Successful in 1m34s
ci / bun-nix (push) Successful in 22s
ci / web (push) Successful in 1m48s
ci / docs-site (push) Successful in 1m44s
deb / build-publish-client-arm64 (push) Successful in 1m44s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 7m24s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 9m40s
deb / build-publish-host (push) Successful in 7m28s
windows-host / package (push) Successful in 13m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
docker / deploy-docs (push) Failing after 6m14s
Reviewed-on: #145
2026-08-09 19:30:31 +00:00
enricobuehler bd5735b803 feat(pads/windows): make the HID Xbox pad the default, and carry the trigger motors on the wire
Three changes that only make sense together: the HID backend becomes the default now that it is a
superset of the XUSB one, the rumble datagram grows the two Xbox impulse-trigger motors, and the
INF-shape tests learn about the Xbox identity's own install section.

WP-E — `PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`; `=xusb` is the escape hatch.
The knob existed for exactly one reason, recorded in its own doc comment: the HID pad could not
reach classic XInput, so defaulting to it would trade a known-working path for an unproven one.
That objection is gone — with the `xinputhid` bus filter the INF now attaches, the HID pad is
promoted like real hardware and keeps classic XInput while gaining everything XUSB never had
(Steam, SDL, RawInput, DirectInput, joy.cpl, WGI) plus rumble, which XUSB could not source at all.
The escape hatch stays because promotion leans on Microsoft's inbox `xinputhid.inf`; if a servicing
update changes it, one env var restores the old behaviour with no reinstall. An unrecognised value
takes the DEFAULT rather than the opt-out, so a typo cannot silently drop a user onto the path with
no HID collection.

WP-D — the `0xCA` rumble datagram gains a v3 form:
  v1  7 B: [0xCA][u16 pad][u16 low][u16 high]
  v2 10 B: … [u8 seq][u16 ttl_ms]
  v3 14 B: … [u16 lt][u16 rt]
v3 is built FROM v2's bytes rather than restating the layout, so the prefix relationship is
structural instead of a convention two encoders have to keep agreeing on, and every reader gates
with `>=`. The four levels share one seq and one ttl on purpose: they are one statement of the
pad's feedback at one instant, and sharing means the whole v2 apparatus — renewal cadence, stop
burst, the client's seq gate, the lease clamp — governs the triggers with no new code. The new
`RumbleUpdate` fields are plain `u16`, not `Option`: on a level-triggered plane "absent" must mean
zero, because "absent → keep the previous value" is the stuck-rumble bug in a new costume.
Only one backend can ever source them — the Windows HID Xbox pad, whose output report 0x03 carries
them. `XINPUT_VIBRATION` and evdev `FF_RUMBLE` have two members and no third, so every other
producer sends `lt = rt = 0`.

⚠️ The two TRIGGER `enable`-mask bits remain CONJECTURE. Bits 2/3 = left/right handle are measured;
bit 0/1 = the triggers are inferred from field order and nothing else. `parse_xbox_output` says so
inline, and no test asserts them — every test vector uses masks (0xFF, 0x00, 0x0C, 0xF3) whose
expectations hold whichever bits turn out to be right. XInput cannot settle this: it has two
motors.

The INF tests — `hwid_matches_inf` matched the install section by the exact string `=pfGamepad,`
and so stopped seeing the Xbox hardware ids the moment that identity moved to its own
`pfGamepadXbox` section. It failed loudly, which is the good outcome; it is now prefix-matched and
tolerant of further per-identity sections. Added
`only_the_xbox_identity_installs_the_xinputhid_section`, which asserts the split in BOTH
directions: the Xbox line must not install the shared section, and no other line may install the
Xbox one. Merging them back is a one-line edit that looks like tidying and would hand a DualSense
to Microsoft's Xbox translator.

VERIFIED
  * ON WINDOWS (.173, the only place this code compiles): `cargo test -p pf-inject --lib` 104/104,
    including the new trigger tests and both INF tests; `cargo check -p punktfunk-host` clean.
  * macOS: `cargo fmt --all --check` clean; `cargo test -p punktfunk-core --features quic` rumble
    suite 22/22, including v3 round-trip and v3<->v2 cross-version parsing.
  * The pre-existing `c_abi_harness_round_trips` failure on macOS is `ld: library 'opus' not found`
    and reproduces with these changes stashed.

NOT VERIFIED
  * No trigger rumble has ever been observed end to end — nothing can drive it yet (see the
    conjecture note above), and no client renders it.
  * The default flip has NOT been exercised in a real streaming session; every measurement so far
    came from the devtest harness. That is the on-glass run.
  * Non-Rust clients do not decode v3. They are blocked on a C ABI entry point first
    (`punktfunk_connection_next_rumble_cmd` has fixed out-params, ABI_VERSION 17); Apple could
    render it via GCHapticsLocality.leftTrigger/.rightTrigger, Android structurally cannot (its
    packed jlong is full) and has no trigger actuators anyway.
2026-08-09 21:10:08 +02:00
enricobuehler 7a9fa4501c Merge pull request 'Nobara could never use the patched gamescope — and the RPM it was told to install was unsigned' (#144) from worktree-gamescope-pin-bump-nobara into main
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Failing after 2m37s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 2m27s
deb / build-publish-client-arm64 (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish (push) Successful in 3m37s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / screenshots (push) Successful in 5m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / deploy-docs (push) Skipped
arch / build-publish (push) Successful in 9m55s
deb / build-publish-host (push) Successful in 6m53s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 11m23s
flatpak / build-publish (push) Successful in 9m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
windows-host / package (push) Successful in 18m53s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
nix / flake (push) Failing after 17m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #144
2026-08-09 18:53:59 +00:00
enricobuehler d87a8df28d fix(windows): uninstall removes the audio devices the host mints
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 7m37s
The field report: uninstalling punktfunk left "Punktfunk Speakers",
"Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints
sitting in Windows' Sound settings forever.

They have no installer payload behind them, which is why nothing in the
uninstall touched them. The host mints them at RUNTIME as extra devnodes
on Valve's streaming-audio drivers, and both providers deliberately
re-resolve their devnode across restarts instead of re-minting it — so
they persist by design. Persistent across restarts must not mean
permanent: the .iss even documented leaving them behind as a decision.

New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry,
after the two driver legs and well after `service uninstall`, since a
live host re-mints on its next wiring pass):

* restores the default playback device first, if a host that died
  mid-stream left it parked on our loopback sink — otherwise Windows
  re-picks by its own ranking rather than giving the operator back the
  device they had;
* removes every MEDIA-class devnode carrying one of our three durable
  owner markers (pad slot, minted role, probe), phantoms included;
* deletes each endpoint's MMDevices record, resolved through the
  devnode link BEFORE the devnode goes.

Marker-matched, never name-matched: our instances are name-identical to
Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the
era when we bundled one carry no marker and stay untouched. A ROOT\
enumeration guard means a marker-shaped value on a real sound card can
never cost the user their hardware.

The registry half is best-effort: those keys are SYSTEM-owned and the
uninstaller runs elevated but as a user, so on a stock box the record
survives as an inert NOTPRESENT entry that Sound settings only shows
behind "Show Disconnected Devices". The device itself is gone either
way, and seizing ownership of SYSTEM registry keys from an uninstaller
is a worse thing to ship than that scrap.
2026-08-09 20:53:22 +02:00
enricobuehler 46390739d8 fix(pf-vdisplay): the box's OWN session unit needs the gamescope bind too
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m40s
nix / flake (pull_request) Failing after 19m15s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-site (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 1m28s
ci / rust (pull_request) Successful in 6m26s
`launch_session` spawns a transient unit and can hand `systemd-run` the
`BindReadOnlyPaths` directly, but a box that owns an autologin
`gamescope-session-plus@<client>.service` is RESTARTED IN PLACE instead — no `systemd-run`,
so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit
fixed only half the problem. Found on the box: after a reboot the host took the
`ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one.

Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the
box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the
wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or
systemd runs the old unit. Best-effort: a failure to write it must not block a restart that
would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`.

⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and
cursor flags — under the user's ORDINARY game mode, which is exactly what
`packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The
bind is ours only for as long as we are driving the session.

`ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already
had it in scope (`self.hdr`, and `create_managed_session`'s parameter).

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
2026-08-09 20:28:00 +02:00
enricobuehler 77f0a25d18 feat(drivers/pf-gamepad): ship the xinputhid bus filter, so Windows finally promotes our Xbox pad
The field report that started this work was an Xbox controller that no game could see on a Windows
host for two weeks. Root cause was that our Xbox pad reaches no Windows input API a modern title
uses. This is the fix, and it is two registry values.

Windows promotes Xbox pads with `xinputhid`, whose INF is an explicit hardware-id ALLOW-LIST — its
own comment says "we can not use a Compatability ID for the loading of this driver, and so rely on
individual hardware IDs". A software-enumerated devnode can never match those ids, so we write what
the matching install sections would have written. `045E:0B13`, the PID this identity already
claimed, is on that allow-list twice, so the identity choice turned out to be exactly right.

🛑 THE PAIRING IS THE WHOLE FINDING, AND THE TWO VALUES GO IN DIFFERENT KEYS. `UpperFilters` is a
`.HW` AddReg (hardware key); `DevicePropertyFlags` is a DDInstall AddReg (software key). A live A/B
on .173: removing `DevicePropertyFlags` alone reverts EVERYTHING — no `IG_00`, no XUSB interface, no
XInput, no WGI entry — while `UpperFilters` alone is completely inert. `1` = `BusDevice`, which
Microsoft glosses as "a focused bus filter driver for the IG_ problem". It is not a description of
the device, it is the switch. An earlier session installed the filter WITHOUT it, measured a device
that produced nothing, and recorded "never ship it". The filter was never broken; it had never been
switched on. That conclusion is now retracted.

⚠️ The Xbox line gets its OWN DDInstall section, `pfGamepadXbox`. All five identities previously
shared `pfGamepad`, so an AddReg there would have handed a DualSense, DualShock 4, Edge and Steam
Deck to Microsoft's Xbox translator. The regression check below exists for exactly that.

MEASURED ON .173 (Win11 26200), INF-SHIPPED — no hand-written registry values:
  * `UpperFilters=xinputhid` lands on the hardware key and `DevicePropertyFlags=1` on the software
    key, applied by the INF at install.
  * The HID child gains the `IG_00` token: `HID\PUNKTFUNK&IG_00\...`.
  * An XUSB interface appears: `\\?\hid#punktfunk&ig_00#...#{ec87f1e3-...}`.
  * classic XInput reads it live — packets ADVANCING, `buttons=0x1000` (the devtest's A), and the
    stick sweeping. XInput had NEVER seen this backend before.
  * `XInputSetState` rumble round-trips: `rumble from game: pad=0 low=65535 high=32767`.
  * REGRESSION CHECK PASSED: with the DualSense identity up, its devnode has an EMPTY
    `UpperFilters` and no `DevicePropertyFlags`. The PlayStation pads are untouched.

WGI `Gamepad` lists the pad but reads `ts=0`. That is NOT ours: a real Xbox Elite Series 2, promoted
by Microsoft's own driver on the same box, reads `ts=0` in WGI at the very moment classic XInput is
reading live data from it (`buttons=0x1000 LY=-32768`). Our pad is behaviourally indistinguishable
from real hardware here; the row is a property of the non-interactive session.

NOT VERIFIED
  * On-glass in a console session. Everything above ran over ssh, which is what makes the WGI row
    unreadable; the real-Elite control is what settles it, not a clean WGI reading.
  * GameInput — no binding in the `windows` crate, still unmeasured for this backend.
  * `PUNKTFUNK_XBOX_BACKEND` still defaults to XUSB. This changes what the HID backend CAN do; it
    does not change which backend is chosen. That is WP-E and it is a separate decision.
  * Trigger-actuator enable bits, still conjecture — `XINPUT_VIBRATION` has two members and cannot
    exercise them.
2026-08-09 20:18:49 +02:00
enricobuehler 3500e95660 fix(pf-vdisplay): make Nobara's session run the patched gamescope, and stop the WSI layer killing every client
Two independent reasons a Nobara box could never stream from a gamescope session,
both found on glass (VM 123, Nobara 44, RTX 5070 Ti).

**1. The session ran a stock gamescope, so the host refused it.**

Nobara's `gamescope-session-plus` builds its command as

    GAMESCOPECMD="/usr/bin/gamescope \

and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env
var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session
ran stock gamescope, the capability probe rejected it, and every session died with
"pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim".
`~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles
that in one line, and is the first thing to read on any such report.

Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount
namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope
ships under its own name precisely so it sits BESIDE the distro package, and the bind is
scoped to the session — nothing outside it sees the redirect and nothing is written to
`/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`.

**2. With the patched gamescope finally running, every Vulkan client died — black screen.**

The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its
`gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's
`swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no
other symptom, which is what makes it expensive to find.

Measured with `vkcube` under each build, layer on:

    ours 3.16.25-17  ON  -> 1 rejected client
    ours 3.16.25-17  OFF -> 0
    OLD pin 3.16.25-4 ON -> 1 rejected client
    stock 3.16.23.2  ON  -> 0

 The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and
our pin — same interface version, same `uuuuuus` signature — so this is the distro patching
gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a
floor, and an unreadable version on either side leaves the layer alone rather than degrading
a box that works (Bazzite/SteamOS, where it has always been fine).

⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is
pre-existing, not a regression from 5fb8dce4.

Verified against the UNPATCHED distro script, reproducing exactly what this code emits:
the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`,
with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts.

Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean.
Non-vacuity re-verified per the xcheck note — a planted type error in the new function
produced 3 errors, and removing it went back to Finished.

Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in
`destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect.
2026-08-09 20:15:52 +02:00
enricobuehler f9fe496dbc feat(drivers/pf-gamepad): declare the rumble output report, and the Xbox pad gets rumble at all
`XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if
the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the
out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The
entire host-side rumble plane was already built, wired and tested, and was simply never fed. The
HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble.

This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized
to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already
specify. It is declared AFTER the final Input item and re-states every global it uses, so the
16-byte input layout `xbox_proto`'s tests pin is untouched.

⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A
reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction
carries no output collection for that pad — so there was nothing to copy. The comment says so and
asks for a Linux hidraw capture to replace it.

Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`.
Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY:
hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates
truncated or not at all with nothing naming the cause. It now cannot build out of step. This
caught nothing today because I updated both by hand, but it is exactly the trap this descriptor
has already sprung twice in other forms.

MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config:
  * `XInputSetState(0xFFFF, 0x8000)` produced, on the host side,
      `rumble from game: pad=0 low=65535 high=32767`
      `rumble from game: pad=0 low=0 high=0`
    i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring ->
    parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered.
  * The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is
    right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers.
  * This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports
    to a synthesized 045E:0B13 — which was blocking the whole trigger plane.
  * classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and
    `LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only
    LS-X and A.

VERIFIED
  * `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended.
  * `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report
    0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the
    wire, feature 0x85 unchanged, `structure: OK`.
  * Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five
    descriptor/wReportLength pairs agree.
  * fmt clean on both tools; .173 fully reverted afterwards.

NOT VERIFIED
  * The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two
    members, so XInput can never drive them and this run could not exercise them. Still open, as
    trigger-rumble-plane.md WP0 says.
  * That this equals the real pad's output collection, byte for byte. Needs Linux hidraw.
  * Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the
    promotion config ships. The rumble descriptor is inert until something drives it.
2026-08-09 20:07:34 +02:00
enricobuehler 13438b1287 test(tools): ask Windows which input APIs can see the pad, and find what promotes it
The Xbox-pad-on-Windows programme is a five-row matrix — classic XInput, WGI `Gamepad`, WGI
`RawGameController`, GameInput, and the HID/DirectInput/Steam family — and nothing in this tree
measured any of it. Every reading in the handoff came from ad-hoc off-tree tools, which is why
several could not be reproduced later and why one was a false positive. `win-input-matrix` makes
the matrix a command you can run twice and diff.

Two traps are baked into it because both have already cost this programme a wrong conclusion.
`--watch` samples repeatedly and reports LIVE vs MUTE per device, because an API listing a pad that
never reports is the exact failure mode here — worse than not listing it, since a title that binds
the first gamepad latches a dead one. And the doc comment insists on a baseline with the virtual pad
STOPPED: a real Xbox pad owns XInput slot 0, which is how `rc=0 LX=-885` was once read as success
with our pad already killed.

 `wake_wgi()` is not optional and is commented as such. `Gamepad::Gamepads()` and
`RawGameController::RawGameControllers()` return a cache filled by WGI's device-watcher, which a
GUI app has already started and a console app has not. Without subscribing to the Added events
first, BOTH collections come back empty with real controllers attached — measured here: a DualSense
sitting in the HID interface class, `RawGameControllers` count=0. A probe missing this reports "WGI
cannot see the pad" when WGI could not see anything.

WHAT IT FOUND (full record in measurements/2026-08-09-xbox-hid-xinputhid-busfilter.md): with
`UpperFilters=xinputhid` on the pad's PARENT devnode AND `DevicePropertyFlags=1` in that parent's
SOFTWARE key, the HID Xbox pad is promoted for the first time — the child gains the `IG_00` token,
an XUSB interface appears, classic XInput admits it, and WGI `Gamepad` lists it. All four had never
happened on this backend. A one-value A/B proves `DevicePropertyFlags` is the decisive half:
removing it alone reverts all four. That retro-explains the earlier "the filter installs fine and
produces nothing" result — the filter was loading without ever being put in bus-filter mode, which
is what `BusDevice = 0x1` means in Microsoft's own comment in `xinputhid.inf`.

Not a workspace member, for the same reason as `hid-descriptor-dump`: it is a Windows-only
bring-your-own-hardware tool with no business on a CI leg.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS; the target is installed).
  * Builds and runs on .173 (Win11 26200).
  * Self-checked against known-good hardware before any conclusion was drawn from it: baseline
    reads the USB DualSense as LIVE in both WGI collections and the resting 8BitDo as MUTE.
  * The A/B was run in both directions on the same box in one session.
  * `cargo metadata` on the root workspace resolves and does NOT list this crate.
  * .173 fully reverted: registry values removed, devnodes removed, oem100.inf deleted, both certs
    delstored, 6 pre-existing pf_gamepad packages and the production service untouched.

NOT VERIFIED
  * GameInput — no binding in the `windows` crate, needs hand-written COM vtables. Not covered;
    the doc comment says so.
  * That the promotion survives a reboot or a devnode re-create from a shipped INF `AddReg` rather
    than a hand-written registry value. Nothing is shipped: `pf_gamepad.inx` is UNCHANGED and still
    contains no AddReg of any kind.
  * WHY the promoted pad still translates no data. Enumeration is fixed; translation is not. The
    evidence points at the report descriptor, which is gated on the §3.3 decision.
2026-08-09 19:24:28 +02:00
enricobuehler ae35e8b4d7 test(tools): capture the real Xbox descriptor, because ours was invented and disagrees with it
`XBOX_RDESC` is the only report descriptor in `pf-gamepad` that was hand-written rather than
captured off hardware, and its own provenance warning has now come true three times. The fix for
that class of bug is not another careful reading — it is a tool that goes and asks the device.

`tools/hid-descriptor-dump` does that: it dumps a real HID device's report descriptor, decodes it
into an annotated item listing plus a bit-offset LAYOUT TABLE, and can decode a blob we already
ship through the same decoder (`--rust-source <file> --symbol <NAME>`) so the two are diffable
line for line. `--read N` pulls live wire bytes, which is the only ground truth a reconstructed
descriptor cannot give you.

Deliberately NOT a workspace member — it pulls `hidapi`, a C library wanting libudev on Linux,
which has no business in `cargo build --workspace` or on a CI leg with no pad attached. It is a
bring-your-own-hardware tool and it is excluded in the root manifest, so CI never sees it.

The captured Elite disagrees with our blob in four ways, and the dangerous one is field ORDER:
the real pad reports sticks, ONE combined 16-bit Z trigger, then BUTTONS, then the hat, in an
UNNUMBERED 15-byte report; ours declares Report ID 1, two Simulation-page trigger axes, then the
hat, then 15 buttons. Since we claim a genuine Microsoft VID/PID and SDL/Steam/Windows all apply
stock mappings keyed on it, that ordering difference is exactly how every control silently lands
on the wrong action. The driver comment now records the diff and the two blockers that stop the
capture from simply being pasted in.

VERIFIED
  * `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` clean (macOS).
  * The tool builds and runs on macOS and on .173 (Windows 11 26200, cargo 1.96, MSVC, no WDK).
  * TOOL VALIDATED AGAINST A KNOWN-GOOD CONTROL: pointed at the live DualSense on .173, it
    reproduces the real `DUALSENSE_RDESC` layout exactly (input 0x01, 64 B, X,Y,Z,Rz,Rx,Ry at
    bytes 1..6, hat 8.0, 15 buttons 8.4, output 0x02, the full feature ladder), and `--read`
    returned live len=64 reports with sticks centred at 80 80 80 80 and the counter incrementing.
  * `cargo metadata` on the root workspace still resolves and does NOT list this crate.
  * The Elite capture is reproducible: `--vid 045E --pid 0B22`.

NOT VERIFIED
  * That the capture equals the pad's NATIVE report map. Windows exposes no API for a device's
    literal descriptor bytes, so hidapi reconstructs from `HidD_GetPreparsedData` — faithful in
    structure, item order and bit offsets, not byte-exact (measured: the DualSense's real 273-byte
    descriptor reconstructs to 467). `xinputhid` also filters that pad, and the captured shape is
    the legacy DirectInput view. A byte-exact answer needs Linux hidraw.
  * Why the Elite returned ZERO input reports across two runs (72 s and 90 s) while the DualSense
    streamed fine on the same code path — untouched pad, or exclusive claim by the XInput
    translator. Unresolved.
  * Nothing here was built on Windows as a driver: `XBOX_RDESC` itself is UNCHANGED, so no
    behaviour changes. The only edit to the driver is its provenance comment.
2026-08-09 19:02:13 +02:00
enricobuehler f34acf1d73 fix(drivers/pf-gamepad): the Xbox descriptor never declared the channel-proof report, so the pad served neutral forever
`XBOX_RDESC` declared only Input report 1. The sealed pad channel delivers its DATA section
over a vendor Feature report `0x85` (`ProofTransport::HidFeatureReport`), and the proof
handler's own comment records the assumption that made this invisible — "0x85 is already
declared as a Feature report in all three captured descriptors". True of the captured
PlayStation blobs; false of this hand-constructed one.

So hidclass rejected the host's `HidD_GetFeature` before the driver ever saw it, the host
refused to hand over the section, and the pad answered every read with its neutral report.
The HID Xbox pad had never delivered a single input report since it was written.

Declaring `0x85` with a 63-byte payload (1 id + 63 = 64 = FeatureReportByteLength) fixes it.
Verified on glass on .173: `gamepad driver attached to the shared section proto=3 late=false`,
and WGI's RawGameController path then reads the pad live — advancing timestamps, the devtest's
left-stick sweep, buttons toggling. Before the fix: 12 consecutive samples, one frozen
timestamp, every axis at dead centre.

This is the descriptor-provenance warning in this file coming true. It is still CONSTRUCTED
rather than captured, and that remains the open risk — `xinputhid` appears to validate the
descriptor and refuses ours, and a real Elite is a multi-collection device where ours has one.

Codec layout tests still 11/11; fmt clean. Only device_type 4 is affected, which nothing
shipping uses yet.
2026-08-09 18:16:24 +02:00
enricobuehler bc9201d136 fix(packaging/gamescope): bump the pin past upstream's capture-format probe, and sign the RPM
Three things, one delivery path — a Fedora/Nobara box getting the patched gamescope.

**The pin moves 8c676c39 -> 5fb8dce4** (3.16.25-1 -> 3.16.25-11). The commit that matters
is ff6b924, `rendervulkan: fall back to XBGR2101010 when XRGB2101010 is unsupported`: it
probes `linearTilingFeatures` for STORAGE+SAMPLED and captures as XBGR2101010 where
A2R10G10B10 linear storage is unavailable — which is every NVIDIA. That covers the paths
that are upstream's rather than ours: the RGB intermediate `paint_pipewire()` acquires when
the stream is YCbCr, and AVIF screenshots. #143 fixed our own node host-side; this is the
other half, and its commit message asked for exactly this bump.

All six patches rebased. Only 0006 conflicted: upstream's f8be7ee added
`vulkan_has_drm_modifiers_for_features()` immediately above the `g_device` declaration our
patch turns into a reference — both kept. 0003 and 0005 come out byte-identical; 0006 also
picks up the `--zero-commit --no-signature` form 0001-0005 already used.

**Patch 0001 now offers `xBGR_210LE` BEFORE `xRGB_210LE`**, mirroring the host-side
`HDR_FORMAT_ORDER` rationale on the producer end. A consumer takes the first pod it can use,
and we were handing third-party consumers (OBS and friends) the one format NVIDIA fills
byte-reversed under a correct-looking label. Deliberately NOT done by calling upstream's
`vulkan_get_rgb10_capture_format()`, which is what pw_pods.rs proposes: that symbol landed
after 3.16.25, so it would break `packaging/nix/gamescope.nix` — which applies these patches
to whatever gamescope nixpkgs pins — with an opaque C++ error instead of a patch conflict.
The reorder gets the same outcome on any base. Note added there so the next reader does not
"fix" it.

**And the RPM was never signed.** `Sign RPMs` runs right after `Build RPM`; the gamescope
RPM is built ~90 steps later, behind its own ~10-minute cache, so it missed the signing pass
entirely — every punktfunk-gamescope RPM ever published went out unsigned. The repo file we
tell users to install carries `gpgcheck=1`, so `dnf install punktfunk-gamescope` failed with
"The package is not signed" on every Fedora and Nobara box. The package was in the channel
the whole time and could not be installed from it, which is worse than absent: the notes and
the docs-site both say it is there. `sign-rpms.sh` now takes explicit paths (defaulting to
`dist/*.rpm` as before) and a second pass signs this one before publish, fail-closed on a tag
like the first.

Verified on Nobara 44 (VM 123, RTX 5070 Ti passthrough), canary 0.27.0-0.ci12611.g516a2954:

* Builds clean in the fc44 CI image; banner `3.16.25-17-ga87390d+pfhdr4` (11 upstream + our
  6), so the marker the host probes still reads 4 — no capability moved, hence pkgrel 3 and
  `.pfhdrN` staying put.
* `pw-cli enum-params` on the live node: BGRx, NV12, **xBGR_210LE (81), xRGB_210LE (80)** —
  8-bit consumers still negotiate bit-for-bit, 10-bit now leads with the safe one.
* All four patched flags present, `--pipewire-composite-external-overlay` included.
* Patch 0006 confirmed working by comparison, which is the only way to see it: the new build
  exits 0 where both the pre-0006 `+pfhdr2` build and the stock 3.16.23.2 abort with 134.
* Signing fix proven with a throwaway key: `Signature: (none)` -> `digests signatures OK`.
* Host health on the canary: synthetic spike 300/300 encoded, loopback 300 recovered, 0
  mismatches.

One unexplained one-off: the very first headless run after install segfaulted at exit
(SIGSEGV, after "Primary child shut down!"). Not reproduced in 11 subsequent runs across
every flag combination, so it is recorded rather than diagnosed — the binary is stripped and
there is no symbolised core.
2026-08-09 18:00:28 +02:00
enricobuehler 003ce8bea7 Merge pull request 'Every NVIDIA gamescope HDR stream had red and blue swapped — and a sysext step added in a release was unreachable forever' (#143) from worktree-hdr-rb-swap-nvidia into main
arch / build-publish (push) Failing after 3s
ci / rust (push) Failing after 2s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish (push) Failing after 3s
deb / build-publish-host (push) Failing after 0s
deb / build-publish-client-arm64 (push) Failing after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / swift (push) Successful in 1m40s
ci / web (push) Successful in 1m8s
ci / docs-site (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
docker / deploy-docs (push) Failing after 1m41s
android / android (push) Successful in 5m38s
apple / screenshots (push) Successful in 5m53s
windows-host / package (push) Successful in 16m59s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m1s
Reviewed-on: #143
2026-08-09 15:34:02 +00:00
enricobuehler 235b8e55d4 Merge pull request 'chore(web): console onto @unom/ui 0.9.2' (#142) from worktree-console-unom-092 into main
audit / license-gate (push) Failing after 2s
audit / cargo-audit (push) Failing after 2s
audit / docs-site-audit (push) Successful in 22s
audit / bun-audit (web) (push) Failing after 22s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (plugin-kit) (push) Successful in 29s
audit / pnpm-audit (push) Successful in 20s
ci / rust-arm64 (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 2s
ci / rust (push) Failing after 2s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
arch / build-publish (push) Canceled after 1m29s
ci / web (push) Canceled after 1m12s
ci / docs-site (push) Canceled after 1m9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
deb / build-publish (push) Canceled after 1m14s
deb / build-publish-host (push) Canceled after 1m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1m3s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 25s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Canceled after 1m1s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
nix / flake (push) Failing after 4m16s
Reviewed-on: #142
2026-08-09 15:32:35 +00:00
enricobuehler d2a2bcc25d feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack
The two things this driver's README has always listed as the missing WGI/GameInput work,
both user-mode, neither needing a bus driver:

`IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic
timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the
synchronous path serves. Declining it was enough for classic xinput1_4, which just falls
back to sync GET_STATE polling — that is why the pad has always worked there. It is not
enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not
a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet
spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to
signal on.

The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put
that filter on the HID child of the *other* backend, which was simply the wrong devnode:
XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this
driver registers.

Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode
starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput
still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async
queue is no regression to the path that already worked.

NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for
this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so
its absence says nothing either way. That needs a real WinRT/GameInput enumeration test.
2026-08-09 17:29:10 +02:00
enricobuehler 0b252403cd fix(web): fix the card inset at the root, not at the call sites
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m35s
ci / web (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 3m16s
ci / rust (pull_request) Failing after 9m12s
nix / flake (pull_request) Failing after 19m50s
The broken inset on the Displays configuration card was the symptom. The cause is
structural, and it had already been diagnosed at least twice in-tree without being fixed.

Two faults, both in components/ui/card.tsx:

1. The padding was a RESPONSIVE COMPOUND: `p-4 pt-0 sm:p-6 sm:pt-0`. tailwind-merge
   resolves conflicts only within a variant, so any call-site override won at the base
   and lost at `sm:` — correct on a phone, wrong on every desktop. Measured on the
   Displays card before this change: padding-top 24px at 500px, 0px at 1440px.

2. `pt-0` encoded an assumption about a SIBLING that nothing enforced — "a CardHeader is
   above me and supplies the top inset". Delete the header, which is exactly what tabbing
   a page does since the tab label replaces the card title, and the top inset silently
   vanishes at ≥640px.

Fix:

- One single-variant utility, `p-padding-card` — the same `--spacing-padding-card` token
  @unom/ui's own Card uses, so nested cards finally agree on their inset. A single
  variant cannot half-lose an override.
- Top inset is now self-correcting: `[&:not(:first-child)]:pt-0`. Ask the DOM instead of
  the author. A headerless CardContent keeps its inset with nothing to remember.

Seven call sites had grown their own compensation in five dialects — `p-6`,
`p-card pt-card sm:pt-card` (×3), `p-4 sm:pt-6` (×3), `pt-4 sm:pt-6`, and my own `pt-6`
from the tabs commit. All removed; they are the symptom-fixes this replaces. LogsCard
even carried a six-line comment correctly describing the trap and working around it
locally — that comment is now three lines saying it no longer needs saying.

`flush` stays: full-bleed content is a real intent, expressed as a prop the component
honours rather than a utility that has to out-argue the one already there.

Guarded by UI/Card → "Inset with and without header", a headered/headerless pair that has
to look identical on every side. It must be checked at BOTH widths — a single width
cannot show this class of bug, which is why it kept surviving.

Verified by measuring computed padding at 500px and 1440px: first child 20px on all four
sides, after-a-header 0px top and 20px elsewhere, identical at both widths. tsc clean,
biome clean on every touched file, 9/9 server tests, build + i18n clean, 32/32 screenshots.
2026-08-09 17:23:09 +02:00
enricobuehler 0ab17ee81d fix(packaging): a post_merge step added in a release was unreachable forever
ci / bun-nix (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m52s
ci / rust (pull_request) Failing after 8m7s
A sysext upgrade is driven by the script from the OLD image -- /usr/bin/punktfunk-sysext
is replaced by the very `systemd-sysext refresh` that runs mid-upgrade -- so a
post_merge step ADDED in the new release is executed by nobody. The old script
does not have it, and the new script never gets a turn: from then on `update`
matches the "already on $cur" branch and returns before post_merge. The step is
permanently unreachable on exactly the installs that need it, and nothing says so.

Field-proven on the Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09). The
casualty was the `punktfunk` group, which post_merge learned to create in 0.26.0
(62a6fa9f): 0.25.0's script ran the upgrade, so the group was never created, and
every `punktfunk-sysext update` since has said "nothing to do". `pf-dm-helper`
gates on membership in that group, so it refused every caller -- pkexec authorised
it and the helper then declined itself -- and every managed gamescope takeover fell
back to "stopping the display manager needs privilege", leaving sddm's autologin
Relogin loop churning logind sessions for the whole stream.

Re-run post_merge when already current. Everything in it is idempotent (guarded
getent/groupadd, `install` of /etc mirrors, udevadm reload/trigger, sysctl,
modprobe), so convergence is the honest behaviour and "nothing to do" was a lie
about host state. Add an explicit `reapply` verb too, so the steps a sysext image
cannot carry can be re-applied without reinstalling the image.

Also print the membership hint. Creating the group is necessary but NOT sufficient
and the difference is invisible until a stream fails: joining stays opt-in by
design (writing vhci `attach` materialises an arbitrary emulated USB device), so
post_merge now names the exact usermod when SUDO_USER is not a member. Matched with
`grep -qx` so `punktfunk-update` does not read as `punktfunk`.

bash -n clean; shellcheck clean apart from the pre-existing SC1091 on
`. /etc/os-release`, which fires on the unmodified file too.
2026-08-09 17:08:24 +02:00
enricobuehler 4e04c2bbf8 feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid
Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real
HID device instead of the XUSB companion, and adds the knob that selects between them.

Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read
today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can
never have, but whether Windows promotes it into an Xbox-profile device that XInput and
WGI Gamepad accept is still the open question. Flipping the default before that is
settled would trade a known-working path for an unproven one. The two backends are
mutually exclusive per pad by construction — one match arm or the other — because
presenting both hands a game two controllers for one pair of hands.

Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean,
`cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean.
2026-08-09 17:03:25 +02:00
enricobuehler 31aef4b09f feat(web): tab the Virtual displays page
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 3m59s
ci / bun-nix (pull_request) Successful in 4m39s
ci / web (pull_request) Successful in 5m1s
ci / rust (pull_request) Failing after 13m18s
nix / flake (pull_request) Failing after 23m11s
Same pill strip the plugin UIs use, via @unom/ui's Tabs: Configuration | Live displays.

The page was two stacked cards, and the configuration card ALONE is taller than the
viewport — the existing comment on the unsaved badge says as much, because that height
is how pending edits went unnoticed. The live-display list sat below all of it, so in
practice it was off screen.

Two details that are not cosmetic:

- The dirty marker moved from the card header onto the Configuration TRIGGER. Behind a
  tab the old badge would vanish entirely while Live was open — a strictly worse version
  of the problem it was added to solve. On the trigger it survives both tabs, and the
  Custom block keeps its own inline badge for when the tab IS open.
- The strip is extracted as a presentational `DisplayTabs` rather than inlined in
  `DisplaySection`. The container calls `useBlocker`, which needs a router, so it cannot
  render in Storybook — and this page's story exists specifically to pin the MOTION
  NESTING of the preset grid (a card sets no delayChildren, so tiles nested one level
  deeper stop staggering). Inserting tabs changes that ancestor chain, so the story has
  to render the real one or it passes for the wrong reason.

Adds Pages/Displays → "Unsaved on other tab", which switches to Live with a dirty draft:
if the marker ever goes silent there, the warning is gone exactly when it matters.

Verified: tsc clean, biome clean, `bun test server/` 9/9, vite build + i18n check clean,
Storybook builds, 32/32 screenshots.
2026-08-09 16:58:46 +02:00
enricobuehler 97928516a0 fix(pf-capture): every NVIDIA HDR stream had red and blue swapped
gamescope's capture textures are mappable, hence linear-tiled, and NVIDIA does
not implement linear-tiled STORAGE for A2R10G10B10_UNORM_PACK32. Upstream says
it plainly in rendervulkan.cpp: "imageStore lands in XBGR order there, swapping
R/B". So the composite writes XBGR bytes into a buffer still LABELLED
XRGB2101010, and our patch's spa_format_to_drm() derives that label from the
negotiated SPA format alone, never asking the hardware what it can actually
write.

The host then believed the label, correctly at every step:
xRGB_210LE -> PixelFormat::X2Rgb10 -> NV_ENC_BUFFER_FORMAT_ARGB10. DRM
XRGB2101010 really is "B in the low 10 bits" and NVENC ARGB10 really is "B in
the lowest 10 bits"; the Windows twin (R10G10B10A2 -> ABGR10) is correct by the
same rule. Every mapping audits clean because the label was right and only the
CONTENT was wrong -- which is why this survived a full trace of both ends.

Fix the preference host-side: offer xBGR_210LE FIRST. The first compatible
consumer pod wins, so that is what a gamescope session lands on, and an
XBGR2101010 texture is one NVIDIA writes in its own order -- label and content
agree. It costs nothing elsewhere: A2B10G10R10_UNORM_PACK32 is the universally
supported packed-10 format, it is what upstream's own fallback picks, and
X2Bgr10 has a first-class encoder path (NVENC ABGR10, VAAPI X2BGR10LE).
xRGB_210LE stays as the second pod so a producer offering only it can still
negotiate HDR instead of dropping to the SDR downgrade.

Doing it here rather than in the patch set is deliberate: the real fix is for
spa_format_to_drm() to offer only what vulkan_get_rgb10_capture_format()
reports, but that function landed after 3.16.25 and the pin is
3.16.25-7-g60561e2+pfhdr4 (0 "2101010" strings in the shipped binary), so the
deployed gamescope cannot self-correct. This ships in the host binary with no
gamescope rebuild.

Field-confirmed on the RTX 5070 Ti Bazzite host with 0.26.0, and confirmed
host-side rather than client-side by reproducing the identical swap from two
unrelated clients (16" MacBook Pro and Mac Studio). SDR was never affected --
it takes no packed-10 path.

Gate (pf-lxcheck2, linux/amd64): fmt clean, clippy --all-targets -D warnings
clean, cargo test -p pf-capture 60 passed / 0 failed incl. the new
hdr_offers_xbgr_before_xrgb order pin.
2026-08-09 16:58:15 +02:00
enricobuehler d498ff4a60 test(drivers): give the Xbox identity a root-enumerated id, and verify the whole thing on Windows
`root\pf_xboxwireless` alongside the plain id, mirroring the DualSense model line — the
INF already documents that variant as the one devgen/devcon tests bind, and without it
the Xbox identity could only be exercised through a running host.

Verified end to end on .173 (Windows 11 26200, WDK 10.0.26100.0):
- build-gamepad-drivers.ps1 builds + signs + catalogs the driver, exit 0
- infverif /v /w on the generated pf_gamepad.inf: "INF is VALID"
- pnputil stages the package; devgen creates the devnode; it starts clean:
  Status OK, Class HIDClass, "Punktfunk Virtual Xbox Wireless Controller"
- it enumerates a HID child, Status OK, carrying HID_DEVICE_SYSTEM_GAME and
  HID_DEVICE_UP:0001_U:0005 — Windows parsed the constructed report descriptor and
  classified the pad as a Game Pad (usage page 0x01, usage 0x05), which is precisely
  what pf-xusb could never do

Test devnode, phantom child, driver package and both certs were removed afterwards.

Two build gotchas worth knowing, both already handled inside build-gamepad-drivers.ps1
and both of which cost a cycle here: CARGO_TARGET_DIR pointing outside the workspace
breaks wdk-sys (wdk-build walks up from OUT_DIR looking for a Cargo.lock and finds
none), and the WDK version must be pinned via Version_Number=10.0.26100.0 or bindgen
picks SDK 10.0.28000.0, which ships no km/crt headers.

Still open: the SwDeviceCreate USB identity (HID\VID_045E&PID_0B13) cannot be checked
through a devgen node, which has no USB hardware ids — that needs the host path. So the
WGI-promotion question is still unanswered, and host routing is still unwritten.
2026-08-09 16:54:00 +02:00
enricobuehler d13d253c2f chore(web): @unom/ui 0.8.16 → 0.9.2
ci / rust-arm64 (pull_request) Failing after 31s
ci / docs-site (pull_request) Successful in 3m1s
ci / bun-nix (pull_request) Successful in 3m27s
ci / web (pull_request) Successful in 4m29s
ci / rust (pull_request) Failing after 13m16s
nix / flake (pull_request) Failing after 19m47s
Brings the console onto the current design system. 0.9.x adds the Badge, Spinner,
Skeleton, Switch, Table, EmptyState and CodeBlock primitives, and 0.9.2 carries the
form fixes found while overhauling the rom-manager plugin UI:

- Select's border and focus ring resolved to `--main`, which is the FOREGROUND here
  (`--main: var(--foreground)` in web/src/styles.css), so the trigger wore a near-white
  border and a 3px near-white focus ring. Its chevron and placeholder were painted
  `--secondary`, a SURFACE colour, and all but vanished. Now on `--input`/`--ring`, the
  same tokens InputText already used.
- InputNumber declares a color-scheme, so the browser-drawn spinner arrows stop being
  near-black on a near-black field.

Both defects were live in this console too — the console palette is what exposes them.

Verified: codegen + vite build clean, `tsc --noEmit` clean, `bun test server/` 9/9,
Storybook builds, 31/31 screenshots. A probe over all 61 stories reports ZERO page
errors, and the two stories containing a Select now render it at h-input-height with
`border: rgb(42, 33, 72)` (the input token) and a muted-foreground chevron.

Note: the console's components/ui/ wrapper layer is unchanged and still required —
@unom/ui's DialogContent remains a surface with no Portal or placement, which is
exactly what web/src/components/ui/dialog.tsx supplies.
2026-08-09 16:27:08 +02:00
enricobuehler 99f2130b28 feat(host/pads): the Windows Xbox backend, compiled and tested on Windows
Adds `xbox_windows` — the host half of the HID Xbox pad: the sealed-channel open under
the Bluetooth identity (SwDeviceCreate `pf_xboxwireless` + `USB\VID_045E&PID_0B13`, so
hidclass derives the real-pad `HID\VID_045E&PID_0B13` child ids), device_type 4 stamped
before the magic, and the `PadProto` impl that publishes through `xbox_proto`. No rich
plane: an Xbox pad has no touchpad, lightbar, adaptive triggers or IMU in its HID
contract, so apply_rich/clear_rich/neutralize_gyro are deliberately no-ops.

Rumble comes back off the driver's republished output reports. The Bluetooth rumble
report carries magnitudes on a 0..100 scale, not 0..255 — assuming otherwise silently
costs 60% of the range — and the enable mask gates each motor independently.

The two INF/driver guard tests now cover the new identity. `hwid_devtype_table_matches
_the_driver` caught the addition on its vacuity count, which is exactly what it is for.

Verified on the Arc laptop (.221, Win11 26200): `cargo test -p pf-inject --lib` 100/100
green, `cargo clippy --lib --profile test -- -D warnings` clean, fmt clean. Note
`clippy --all-targets` fails there on a PRE-EXISTING issue unrelated to this change —
tests/motion_contract.rs imports the linux-gated `switch_proto`.

Still unbuilt: the driver itself (.221 has no WDK) and the host routing that would send
an Xbox pad here instead of to XUSB. The report descriptor remains constructed rather
than captured — diff it against a real pad before shipping.
2026-08-09 12:24:14 +02:00
enricobuehler f266636392 feat(host/pads): an Xbox pad on Windows becomes a real HID device, so Steam can see it
`pf-xusb` registers only GUID_DEVINTERFACE_XUSB and exposes no HID collection, so
Steam's hidapi enumeration, DirectInput, joy.cpl and WGI/GameInput cannot see the pad
at all — only classic XInputGetState via xinput1_4's interface walk ever does. A
reporter spent two weeks on a dead controller for exactly that reason; switching the
client to DualSense, a real HID pad through the pf-gamepad UMDF driver, fixed it in
seconds.

This gives the Xbox pad that same footing: a new device_type 4 on the existing HID
minidriver, identified as a Bluetooth Xbox Wireless Controller (045E:0B13). The wired
ids the tree already uses (045E:028E, 045E:02EA) are vendor-class XUSB/GIP devices with
no HID interface on real hardware, so a HID child claiming one is a device that has
never existed and has nothing for Windows to promote.

Driver: identity, a constructed 132-byte Game Pad report descriptor, neutral report,
strings and the pf_xboxwireless hardware id. Host: `xbox_proto`, the byte-exact codec
mirroring that descriptor, with 11 layout tests.

One shared-path fix falls out. The timer completed every pended READ_REPORT with the
full 64-byte slot, and `copy_to_output` REFUSES a source longer than hidclass's buffer
rather than truncating it — so a pad declaring a shorter report would have failed every
read and looked dead. Report length is now per-identity; it returns 64 for all four
pre-existing pads, so their behaviour is provably unchanged.

NOT BUILT AND NOT RUN ON WINDOWS — no box was reachable. The Rust codec and its tests
pass on macOS; the driver, the INF and the report descriptor have never been compiled,
infverif'd, or seen by a real pad. The descriptor is constructed rather than captured,
which matters because we claim a real Microsoft VID/PID and SDL/Steam/Windows carry
stock mappings keyed off it — diff it against a capture before shipping.
2026-08-09 12:11:04 +02:00
enricobuehler 516a295432 Merge pull request 'My gamescope gate withheld the host .deb it was meant to protect — the release still ships the KDE-breaking one' (#140) from worktree-gamescope-gate-placement into main
ci / web (push) Successful in 1m5s
ci / bun-nix (push) Successful in 17s
ci / rust-arm64 (push) Successful in 1m38s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m38s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 3m45s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 6m39s
ci / rust (push) Successful in 10m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
Reviewed-on: #140
2026-08-09 09:12:55 +00:00
enricobuehler 2c190b27b4 Merge pull request 'Switching audio device mid-stream killed the sound for the rest of the session — AVAudioEngine stops itself, and nothing ever restarted it' (#141) from worktree-audio-device-switch-silence into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 1m41s
release / apple (push) Successful in 10m9s
apple / screenshots (push) Successful in 6m10s
Reviewed-on: #141
2026-08-09 09:10:52 +00:00
enricobuehler 3cfa5ca194 Merge pull request 'The capability-hint test asserted the environment, not the code — main is red on a machine where nothing is wrong' (#139) from worktree-kwin-capability-test-env into main
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 1m55s
deb / build-publish-host (push) Canceled after 1m10s
deb / build-publish-client-arm64 (push) Canceled after 50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
android / android (push) Successful in 6m20s
windows-host / package (push) Successful in 11m23s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
arch / build-publish (push) Successful in 11m50s
Reviewed-on: #139
2026-08-09 09:10:33 +00:00
enricobuehler bf913c5706 fix(apple): switching audio device mid-stream killed the sound for the rest of the session
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / rust (pull_request) Failing after 9m43s
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.

An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.

Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.

The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:

  - the engine's own configuration-change notification, every platform — the direct
    signal, but it can only be posted BY an engine, so it cannot report a rebuild that
    failed to start;
  - a CoreAudio HAL default-output-device listener on macOS — independent of any engine
    and of the engine's topology. This is what makes the recovery work for the
    voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
    cancellation both default on) and whose notification behaviour could not be verified:
    no Mac in the fleet can initialize VPIO at all;
  - route-change and media-services-reset on iOS/tvOS, where the session rather than the
    device is what moves. The route observer is now installed for mic-off (.playback)
    sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
    but every platform has engines a route change can stop.

They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.

A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.

The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.

AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
2026-08-09 11:03:10 +02:00
enricobuehler 5bd92dac5d fix(ci): my gamescope gate withheld the host .deb it was supposed to protect
ci / bun-nix (pull_request) Successful in 25s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 8m9s
The gate #135 added fails the job at the gamescope BUILD step. In deb.yml that
step runs before "Publish to the Gitea apt registry" and "Attach the host .deb
to the Gitea release", so failing it skipped both.

Consequence on the v0.26.0 tag, and it is the worst thing in this release so
far: the host .deb on the release is from 00:17 — re-point #1, BEFORE #136
revoked CAP_SYS_NICE. Every other .deb is from 08:29-08:31. So the published
Debian host still runs `setcap cap_sys_nice=ep` in its postinst, which is
exactly what makes the host unidentifiable to KWin and kills every KDE desktop
session. A gate meant to protect the release withheld the fix for it and left
the broken artifact in place.

rpm.yml has the identical latent bug and only escaped it because Fedora went
green: a gamescope failure there would skip the sysext image, the feed publish
and the release attach, withholding the punktfunk RPMs and .raw images too.

Both now warn at the build/package steps and gate as the LAST step of the job,
after everything has published. A missing EXTRA must never stop a good artifact
shipping — go red afterwards instead.

Also: name noble's dependencies outright. `apt-get build-dep gamescope` gives it
almost nothing (the distro has no comparable package), which is why this peeled
one dep per CI cycle — wayland-protocols, then xdamage. The full set is derived
from the Arch package's depends+makedepends, which is the build that demonstrably
works, plus wlroots' own (it is a forced fallback subproject).

One `apt-get` per name on purpose: a single transaction aborts wholesale on one
unknown package, installing NOTHING and hiding the real gap behind a name typo.
Per-package, best-effort, with the missing name echoed; the end-of-job gate is
what actually decides.

⚠ Verification: both YAML files parse; every gamescope-touching `run:` block is
`bash -n` clean with matrix placeholders substituted (9 blocks); the .deb glob
matches build-gamescope-deb.sh's documented output
(`dist/punktfunk-gamescope_<version>_<arch>.deb`) and the RPM glob excludes
debuginfo/debugsource exactly as the attach loop above it does. The noble dep
NAMES cannot be proven from macOS — that is what the next tag run decides, and
it now decides it without holding the host .deb hostage.
2026-08-09 10:52:42 +02:00
enricobuehler e8a4f54c07 fix(pf-vdisplay): the capability-hint test asserted the environment, not the code
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 5m0s
ci / bun-nix (pull_request) Successful in 42s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m48s
ci / rust-arm64 (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 6m57s
`silent_without_capabilities` called the real `capability_denial_hint()` and
asserted it returns "", on the strength of a doc comment that read "The test
process has no capabilities."

That is true on a dev box and false in CI, where the runner container is root
with a full permitted set. main went red on 0f79587d with:

    left: " — NOTE: this process carries capabilities (CapPrm=0x000001ffffffffff) …"
   right: ""

Nothing was wrong: the hint fired correctly, on a process that really did hold
every capability. The test was reading the ambient environment and calling it a
property of the code.

`permitted_caps_from_status` had already been split out for exactly this reason
— "so that shape is testable without a capability-carrying process to point at"
— but only the PARSE half. The message half still went to /proc/self/status.
This finishes the split: `capability_denial_hint_for(Option<u64>)` holds the
formatting and takes the mask, `capability_denial_hint()` reads /proc and
delegates. Both keep their callers, so neither is dead code.

Also adds `names_the_mask_and_the_repair_when_capped`. Without it the silent
case passes just as well against a function that returns "" unconditionally —
which is the failure mode this repo has been bitten by before, and the reason
every decode fix carries a counterfactual.

No behaviour change: the three error paths call the same function and get the
same string.

⚠ Verification is CI. `kwin.rs` is `#[cfg(target_os = "linux")]`, so it does not
compile on the macOS host this was written from; `cargo fmt --all --check` is
clean and a Linux container check was attempted but the stock rust image has no
cmake for audiopus_sys, so it never reached the test. ci.yml going green on main
is the proof — and unlike the case it replaces, this test now fails or passes
for reasons that have nothing to do with the machine running it.

Does not touch the v0.26.0 tag: ci.yml runs on `push: branches: [main]` and
`pull_request` only, and no tag leg runs cargo test.
2026-08-09 10:39:41 +02:00
enricobuehler f80636f901 Merge pull request 'The release notes advertise a privilege 0.26.0 deliberately does not grant' (#138) from worktree-notes-capsysnice-correction into main
android-screenshots / screenshots (push) Successful in 1m29s
release / apple (push) Successful in 12m13s
decky / build-publish (push) Successful in 37s
windows-host / package (push) Successful in 11m48s
windows-host / canary-manifest (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 1m27s
deb / build-publish (push) Successful in 4m18s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
linux-client-screenshots / screenshots (push) Successful in 2m54s
sbom / sbom (push) Successful in 20s
deb / build-publish-host (push) Failing after 5m18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows-host / winget-source (push) Successful in 21s
docker / builders-arm64cross (push) Successful in 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 10m26s
arch / build-publish (push) Successful in 11m24s
web-screenshots / screenshots (push) Successful in 5m5s
flatpak / build-publish (push) Successful in 16m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
ci / rust-arm64 (push) Successful in 1m40s
ci / web (push) Successful in 2m5s
ci / docs-site (push) Successful in 1m12s
ci / bun-nix (push) Successful in 38s
ci / rust (push) Canceled after 1m31s
2026-08-09 08:13:45 +00:00
enricobuehler 0f79587dd6 docs(release): the notes claimed a privilege 0.26.0 deliberately does not grant
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m51s
ci / bun-nix (pull_request) Successful in 35s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 8m47s
The user-facing v0.26.0 notes said, of the PyroWave GPU-priority lever:

    "it is now, and the package grants the host the permission that switch needs"

That was true of 0.26.0-1 and is now the opposite of true. Granting CAP_SYS_NICE
made the host unidentifiable to KWin and killed desktop streaming on every KDE
box across all five Linux channels, so 0.26.0-2 revokes it everywhere and must
keep doing so. The lever is wired natively on Linux for the first time — that
part stands — but it is dormant on an ordinary install, and the notes have to
say so rather than advertise a speed-up nobody gets.

CHANGELOG.md was already corrected in #136 (the 0.26.0-2 note under PW1 and the
qualifier on the owed A/B). This is the user-facing half, which #136 did not
touch:

  * the PyroWave bullet now leads with what DID land (two encoder handles, the
    capture buffer headroom) and describes the priority switch as present but
    dormant, with the reason.
  * a new Fixed entry for the KDE breakage itself. Worth telling users even
    though the release was never announced: 0.26.0-1 packages did reach the
    registries, and anyone who pulled one has a desktop session that fails with
    a missing-screencast error surviving a clean reinstall. It also explains the
    dormancy the bullet above now refers to.

Deliberately NOT written as a "Before you update" action: upgrading strips the
capability by itself on every channel, so there is nothing for a reader to do.

Commit count 47 -> 52.

Voice check clean (0 internal-vocabulary hits above "## For developers"); notes
67 lines.
2026-08-09 10:12:58 +02:00
enricobuehler 651a7a82a1 Merge pull request '0.26.0-1 gave the host CAP_SYS_NICE, which made it invisible to KWin — every KDE desktop session died, on five packaging channels' (#136) from worktree-kwin-capability-identification into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Failing after 2m20s
ci / rust (push) Failing after 2m21s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 30s
deb / build-publish-client-arm64 (push) Successful in 1m56s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
android / android (push) Successful in 6m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
apple / screenshots (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m32s
deb / build-publish-host (push) Successful in 6m11s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
arch / build-publish (push) Successful in 11m40s
windows-host / package (push) Successful in 12m30s
windows-host / winget-source (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m58s
nix / flake (push) Successful in 18m34s
windows-host / canary-manifest (push) Successful in 14s
Reviewed-on: #136
2026-08-09 08:04:43 +00:00
enricobuehler 08eaf337e8 Merge pull request 'v0.26.0 promised a Fedora and an apt gamescope that were never built' (#135) from worktree-gamescope-rpm-deb-builddeps into main
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 1m35s
ci / bun-nix (push) Successful in 27s
ci / docs-site (push) Successful in 1m12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 1m30s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
deb / build-publish-host (push) Successful in 5m53s
deb / build-publish (push) Successful in 6m9s
docker / builders-arm64cross (push) Successful in 7s
ci / rust (push) Successful in 11m22s
docker / deploy-docs (push) Failing after 6m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m20s
2026-08-09 07:22:59 +00:00
enricobuehler 39869031be fix(ci): the gamescope RPM and .deb never built, and a warning let the tag ship anyway
ci / bun-nix (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 11m20s
v0.26.0's notes and docs-site say the patched gamescope is now installable on
Fedora and on Debian/Ubuntu. Neither package exists on the release. Both builds
failed inside best-effort steps that emit `::warning::` and return 0, so every
job stayed green and the only evidence was a warning nobody reads. Arch built
fine, which is why it is the sole gamescope package attached.

Two distinct missing build deps, same root cause: `dnf builddep gamescope` /
`apt-get build-dep gamescope` resolve the DISTRO'S OLDER PACKAGED gamescope,
which does not need what the pinned master tree needs.

  Fedora (f43 AND f44)
    /usr/sbin/ld: cannot find -lstdc++
    have you installed the static version of the stdc++ library ?
    ERROR: Compiler sccache c++ cannot compile programs.

  build-punktfunk-gamescope.sh appends `-static-libstdc++ -static-libgcc` to
  LDFLAGS deliberately, so the binary still starts on SteamOS's older libstdc++.
  Without libstdc++-static that trips meson's very FIRST sanity check, so
  nothing builds at all.

  Debian/Ubuntu noble
    protocol/meson.build:7:17: ERROR: Neither a subproject directory nor a
    wayland-protocols.wrap file was found.

  The tree carries no wrap fallback for wayland-protocols.

Both proven deps are installed WITHOUT `|| true` so a rename is loud. The
remaining Arch makedepends the older packaged gamescope may not pull (glm,
cmake, libXcursor, wayland-protocols-devel on Fedora) stay best-effort, since
meson finds fallbacks and a name that moves between releases should not fail
the job.

And the part that actually matters: on `refs/tags/v*` a missing gamescope is
now an ERROR, not a warning. A release must not be able to make a claim its own
CI silently dropped. Gated in two places per platform — the build step, and the
packaging step that is authoritative and also covers the cache path (the build
step is skipped entirely on a cache hit, so a stale cache would otherwise reach
packaging and skip in silence). Canary keeps the old best-effort behaviour.

Deliberately NOT gated: the sysext leg. The notes make no claim about gamescope
inside the sysext, and with the build fixed gs-cache is populated so it gets the
binary anyway — gating it would add release-blocking risk with no matching
promise.

⚠ Verification is CI itself: both YAML files parse, and every gamescope-touching
`run:` block is `bash -n` clean with the matrix placeholders substituted. The
dep names cannot be proven from macOS; the rpm and deb legs on the next tag are
the proof, and they are now hard-gated, so a wrong name fails loudly instead of
shipping another empty promise.
2026-08-09 09:22:20 +02:00
114 changed files with 9604 additions and 675 deletions
+56
View File
@@ -344,10 +344,45 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
# gamescope — where noble has one at all — so it misses what the master tree needs, and
# wayland-protocols is the gap that actually stops the build: meson dies in
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
# file was found", because the tree has no wrap fallback for it. That is what happened on
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
# shipped with no gamescope .deb while the notes said it had one.
apt-get install -y --no-install-recommends wayland-protocols
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
# releases should not fail the job. (No libstdc++ static package is needed here — g++
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
# purpose: a single transaction aborts wholesale on one unknown package, which would
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
# the missing one named; the end-of-job gate below is what actually decides.
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
cmake xwayland; do
apt-get install -y --no-install-recommends "$p" \
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
done
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
# skips the host .deb's own publish + release-attach steps below, which is how the
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
# meant to protect the release. Never let a missing EXTRA stop a good artifact
# shipping; go red afterwards instead.
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
@@ -357,6 +392,7 @@ jobs:
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
@@ -387,6 +423,26 @@ jobs:
upsert_asset "$RID" "$DEB"
done
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
# because every failure on this path was a `::warning::` that returned 0.
#
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
# gate protecting the release withheld the fix for it. Everything good ships first; the job
# goes red afterwards.
- name: A stable tag must ship the gamescope .deb
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope_*.deb)
if [ ${#built[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope .deb present: ${built[*]}"
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
+65
View File
@@ -206,10 +206,26 @@ jobs:
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
@@ -227,9 +243,35 @@ jobs:
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -310,3 +352,26 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+1 -1
View File
@@ -14,7 +14,7 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.26.0
47 commits since v0.25.0.
52 commits since v0.25.0.
### Versions
+5
View File
@@ -46,6 +46,11 @@ members = [
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
"tools/hid-descriptor-dump",
"tools/win-input-matrix",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
@@ -0,0 +1,129 @@
// "The audio output moved under us" the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
// which needs a host, and punktfunk-host does not build on macOS so the wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}
@@ -43,8 +43,21 @@ public enum AudioDevices {
}
private static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
/// The device the system is currently playing to what an engine with no pinned speaker UID
/// follows, and so what `SessionAudio` compares its live output device against when the
/// default moves (AirPods in or out, a headset unplugged).
static func defaultOutputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
}
private static func systemDevice(
_ selector: AudioObjectPropertySelector
) -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
@@ -21,6 +21,10 @@
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes).
//
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
// device changes underneath a running engine, the engine stops itself and stays stopped. The
// session therefore watches for that and rebuilds its engines see "Device changes" below.
import AVFoundation
import os
@@ -79,14 +83,49 @@ public final class SessionAudio {
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
#if os(iOS)
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
#if !os(macOS)
/// Token for the route-change observer: it revives an engine the route change stopped, and on
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
private var routeObserver: NSObjectProtocol?
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
let micUID: String
let micChannel: Int
let micEnabled: Bool
let echoCancel: Bool
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private static let rebuildAttempts = 3
public init(connection: PunktfunkConnection) {
self.connection = connection
}
@@ -96,10 +135,14 @@ public final class SessionAudio {
/// Engine teardown still belongs to stop().
deinit {
flag.stop()
#if os(iOS)
// The observer only holds self weakly, so we can be deinited with it still registered;
// drop the token here too rather than leaking it when an owner skips stop().
// The observers only hold self weakly, so we can be deinited with them still registered;
// drop them here too rather than leaking them when an owner skips stop().
deviceWatcher?.stop()
#if !os(macOS)
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
if let mediaResetObserver {
NotificationCenter.default.removeObserver(mediaResetObserver)
}
#endif
}
@@ -120,6 +163,12 @@ public final class SessionAudio {
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -189,10 +238,10 @@ public final class SessionAudio {
#if os(iOS)
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
// output override so the mic-off (`.playback`) path deliberately does neither.
if micEnabled {
steerBuiltInOutputToSpeaker(session)
installRouteObserver()
}
// (The route OBSERVER that re-applies this per route is installed by
// `installDeviceChangeRecovery`, for every session a `.playback` session steers
// nothing but still has engines a route change can stop.)
if micEnabled { steerBuiltInOutputToSpeaker(session) }
#endif
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
@@ -220,11 +269,20 @@ public final class SessionAudio {
}
}
#endif
#if !os(macOS)
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
/// the stream back to the built-in output. iOS drops an output override whenever the route
/// changes which is what lets a newly-connected headset win so the earpiece steer is a
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
/// Bluetooth mid-stream would land the game on the earpiece.
/// the stream back to the built-in output. Two things follow from that.
///
/// iOS drops an output override whenever the route changes which is what lets a newly-
/// connected headset win so the earpiece steer is a property of the CURRENT route and has to
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
/// earpiece.
///
/// And on every platform a route change can take the engines down with it (see
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
/// on tvOS too, where there is no earpiece to steer away from.
private func installRouteObserver() {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
@@ -235,7 +293,10 @@ public final class SessionAudio {
// other call into it.
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
#if os(iOS)
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
#endif
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
}
}
stateLock.lock()
@@ -252,6 +313,7 @@ public final class SessionAudio {
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -325,33 +387,27 @@ public final class SessionAudio {
public func stop() {
flag.stop() // before taking the engines see stateLock's comment
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
#if os(iOS)
let watcher = deviceWatcher
deviceWatcher = nil
#if !os(macOS)
let route = routeObserver
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
#endif
stateLock.unlock()
#if os(iOS)
// Before the deactivate below, so a route change during teardown can't re-steer a session
// we are in the middle of releasing.
// Every watcher goes before the engines do: a device change landing during teardown must
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
// guards that, but not arming the trigger is better than catching it.) On iOS this is
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
watcher?.stop()
#if !os(macOS)
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
#endif
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
tearDownEngines()
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
@@ -372,6 +428,234 @@ public final class SessionAudio {
}
}
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
/// the audio session alone the teardown half shared by `stop()` and a rebuild. Safe from any
/// thread; the engines are taken under the lock before any of them is touched.
private func tearDownEngines() {
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
}
// MARK: - Device changes
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
/// running engine AirPods taken out of an ear, a headset unplugged, the default switched in
/// System Settings the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
/// device changes, and both restarting the same engine and building a fresh one resume them.
///
/// Three triggers feed one rebuild, because no single one of them covers the ground:
///
/// - the engine notification, everywhere the direct signal, but only an engine that still
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
/// - the HAL default-output-device listener, macOS independent of any engine and of the
/// engine's topology. It is what makes the recovery work for the voice-processing engine
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
/// a VPIO engine posts the notification the plain one demonstrably does;
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
stateLock.lock()
let already = deviceWatcher != nil
stateLock.unlock()
guard !already else { return } // a second start() on one SessionAudio: keep the first set
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
watcher.start()
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
#endif
}
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
/// our business identity only, the object is never resurrected.
private func ownsEngine(_ posted: AnyObject?) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
}
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
defaultOutputChanged()
#else
break // the watcher only raises this one on macOS
#endif
}
}
/// Restart the engines if and only if playback is down. The conservative trigger: it is
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
/// that followed the change on its own must not be interrupted for it.
///
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
/// difference between recovering a session whose very first `startPlayback` failed no
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
/// before asking AVAudioEngine anything the lock guards our handles, not the framework.
private var playbackIsLive: Bool {
stateLock.lock()
let playback = playbackEngine
let combined = combinedEngine
stateLock.unlock()
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
}
/// Coalesce: one device switch produces a burst the old device leaving, the default moving,
/// the new device settling, and each engine we own posting its own change and one rebuild
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
/// from spinning the session. Main thread.
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
/// renegotiates its own. The RING is deliberately not touched it is the one thing carried
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
// Did playback actually come back? A device caught mid-transition can refuse to start, and
// a rebuild that fails leaves no engine to post the next notification so this is the one
// path that must not just give up. (`startEngines` has logged the reason already.)
if playbackIsLive {
log.info("audio engines restarted on the current device")
return
}
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
audio did not come back after the device change the default-output watcher will \
try again when a device appears
""")
#else
log.error("audio did not come back after the route change")
#endif
return
}
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
}
}
#if os(macOS)
/// The system's output device moved. Rebuild only when it actually concerns this session: the
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
/// listener block is registered against it).
private func defaultOutputChanged() {
guard !flag.isStopped, let config = startConfig else { return }
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
let playingOn = Self.currentDevice(of: unit)
else {
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
reviveStoppedEngines("the default output device moved")
return
}
// Empty UID = follow the system default; a pinned UID only moves if that device itself
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
let shouldBeOn = config.speakerUID.isEmpty
? AudioDevices.defaultOutputDevice()
: AudioDevices.deviceID(forUID: config.speakerUID)
guard let shouldBeOn, shouldBeOn != playingOn else { return }
scheduleEngineRebuild(reason: "the output device changed under the session")
}
#endif
#if !os(macOS)
/// The audio server can die and restart. It takes the session's configuration and every engine
/// with it, and the documented recovery is to build all of it again the same rebuild a route
/// change uses, with the session activation back in front of it.
private func installMediaResetObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
) { [weak self] _ in
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.scheduleEngineRebuild(reason: "the audio services were reset")
}
}
}
stateLock.lock()
let stale = mediaResetObserver
mediaResetObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
@@ -437,6 +721,21 @@ public final class SessionAudio {
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
#if os(macOS)
/// Whether playback is rendering, and the device it is rendering to. The device-change
/// recovery has exactly one observable signature from outside "running again, on the device
/// the system just moved to" and nothing else here could tell the two halves apart: a
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
/// Used by `AudioDeviceSwitchTests`.
var playbackState: (running: Bool, device: AudioDeviceID?) {
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine else { return (false, nil) }
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
}
#endif
// MARK: - Playback (host speaker)
/// The playback jitter ring + the source node draining it shared by the plain playback
@@ -1002,22 +1002,34 @@ public final class PunktfunkConnection {
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine the
/// uniform replacement for per-platform rumble policy. The engine owns every decision
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
/// so apply commands verbatim: all-zero = stop now, non-zero = run at this level.
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs the
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
///
/// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger
/// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA
/// plane's v3 tail. This calls the core's `_cmd2` entry point `_cmd` is the frozen
/// two-handle form kept for out-of-tree embedders, and there is no reason for this client to
/// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply
/// has no engine for those levels and they go nowhere, which is the normal case.
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
-> (
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
backstopMs: UInt32
)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
var lt: UInt16 = 0, rt: UInt16 = 0
let rc = punktfunk_connection_next_rumble_cmd2(
h, &pad, &low, &high, &lt, &rt, &backstop, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, backstop)
return (pad, low, high, lt, rt, backstop)
case statusNoFrame:
return nil
case statusClosed:
@@ -172,7 +172,8 @@ public final class GamepadFeedback {
while rumbleBurst < 64, !flag.isStopped,
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
self?.routeRumble(
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
rumbleBurst += 1
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
@@ -225,12 +226,21 @@ public final class GamepadFeedback {
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
private func routeRumble(
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high)
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
//
// HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so
// the trigger levels would have to be folded to arrive at all and folding continuous
// impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor
// this mirror has would buzz the phone flat-out for the whole race at a level the game
// never requested. Dropping them matches the core engine's policy for every pad without
// trigger motors.
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
}
@@ -36,7 +36,9 @@ enum RumbleTuning {
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
/// proven mid value. The impulse-trigger motors are small and light the same character as
/// the high/right buzzer so they reuse `sharpnessHigh` rather than introduce a number
/// nobody has measured on real trigger hardware.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
private var controller: GCController?
private var low: Motor?
private var high: Motor?
/// Wire-truth target (raw wire units) the engine command's level, applied verbatim; the
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
private var target: (low: UInt16, high: UInt16) = (0, 0)
/// The two Xbox impulse-trigger motors, when the pad offers
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** every pad but
/// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads
/// on Apple exposing no haptics engine at all so their absence is never logged and never
/// counts as a setup failure. Independent of the handle split: a pad may offer trigger
/// localities with or without split handles, and losing one does not implicate the other.
private var leftTrigger: Motor?
private var rightTrigger: Motor?
/// Wire-truth target (raw wire units) the engine command's four levels, applied verbatim;
/// the core policy engine owns when it ends (explicit zero commands), so no deadline lives
/// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every
/// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and
/// evdev's `FF_RUMBLE` each carry exactly two magnitudes).
private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) =
(0, 0, 0, 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
/// Called with every 0xCA state the host sends level changes AND renewals and the core
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
///
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
/// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what
/// you had".
func apply(
low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0,
rightTrigger rtAmp: UInt16 = 0
) {
queue.async {
let active = lowAmp != 0 || highAmp != 0
let next = (lowAmp, highAmp, ltAmp, rtAmp)
let active = next != (0, 0, 0, 0)
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
}
guard (lowAmp, highAmp) != self.target else { return }
self.target = (lowAmp, highAmp)
guard next != self.target else { return }
self.target = next
self.render()
}
}
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.target = (0, 0, 0, 0)
self.wasActive = false
self.teardown()
self.closeHID()
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
let audible = target != (0, 0, 0, 0)
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
@@ -274,6 +294,18 @@ final class RumbleRenderer: @unchecked Sendable {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
// Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into
// the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them
// silently drops the levels which is the correct degrade and the common case.
//
// Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear
// down the handle engines (which are what the pad's rumble mostly is) nor flip
// `preferCombined`, which is a statement about the handle split and nothing else. Nothing
// is orphaned by that a failed reconcile leaves the slot's Motor in place, so the next
// tick simply retries it, and an engine that is genuinely dead fires its
// stopped/reset handler, which tears down all four slots for a lazy rebuild.
_ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger))
_ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger))
if !ok {
let wasSplit = high != nil
teardown()
@@ -410,9 +442,11 @@ final class RumbleRenderer: @unchecked Sendable {
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
let needed = target != (0, 0, 0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
@@ -477,6 +511,26 @@ final class RumbleRenderer: @unchecked Sendable {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
// Return before the trigger engines: the retry path re-enters setup() on the same
// `low == nil, high == nil` condition, so building them here would leak a fresh pair
// on every attempt (teardown() only runs on the failure paths above, and this is not
// one of them).
return
}
// Impulse-trigger motors, built last and best-effort. Independent of the handle split
// the localities are separate and a pad can offer either, both or neither and NOT part
// of the failure test above: nil here is the ordinary state of every pad that is not an
// Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle
// engines, or produce a log line on a path that runs per controller attach.
//
// Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass.
// The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil,
// and `reconcile` no-ops on them.
if localities.contains(.leftTrigger) {
leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
if localities.contains(.rightTrigger) {
rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
}
@@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable {
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
}
low = nil
high = nil
leftTrigger = nil
rightTrigger = nil
}
private func seconds(since t: DispatchTime) -> TimeInterval {
@@ -624,6 +680,16 @@ final class RumbleRenderer: @unchecked Sendable {
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
///
/// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to
/// invent.** A DualSense has *adaptive* triggers force resistance on a trigger you press,
/// driven by the separate 0xCD `HidOutput.Trigger` plane and no trigger *motors*. The two
/// features are unrelated hardware that only share a word: an Xbox Series pad has trigger
/// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble
/// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger
/// parameter block would fabricate feedback the game never asked for. This path returning
/// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above,
/// which is correct for the same reason.
private func renderHID() -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
@@ -0,0 +1,102 @@
// The device-switch regression, end to end against a real session.
//
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
// output moved mid-session AirPods taken out of an ear, a headset unplugged, the default
// changed in System Settings played silence from that moment on: nothing on the speakers the
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
//
// This drives the real `SessionAudio` against the loopback host and moves the system's default
// output device out from under it, twice out and back, the exact shape of the field report.
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
// permission and the voice processor into a test that is about neither.
//
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
#if os(macOS)
import AVFoundation
import CoreAudio
import XCTest
@testable import PunktfunkKit
final class AudioDeviceSwitchTests: XCTestCase {
/// Set the system default output device. Test-local on purpose: nothing in the app ever
/// changes the user's device, it only follows it.
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
/// sleeping test would block the very thing it is waiting for.
private func waitForPlayback(
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let state = audio.playbackState
if state.running, state.device == device { return true }
}
return false
}
func testPlaybackFollowsAnOutputDeviceChange() throws {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
let audio = SessionAudio(connection: conn)
// "" speaker UID = follow the system default, which is what the report was running and
// the only configuration a default-device change is supposed to move.
audio.start(
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
defer {
audio.stop()
_ = setDefaultOutput(original)
}
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 5),
"playback never started on the current default output device")
// Out: the device the stream was playing to goes away underneath it.
XCTAssertEqual(setDefaultOutput(target), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: target, timeout: 10),
"playback did not come back after the output device changed — this is the field "
+ "report: no sound on the device the system moved to, until the stream is "
+ "restarted")
// And back: the second half of the report, where putting the AirPods back in produced a
// second stop rather than a recovery.
XCTAssertEqual(setDefaultOutput(original), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 10),
"playback did not come back after the output device changed back")
}
}
#endif
@@ -0,0 +1,121 @@
// The trigger half of surviving a device change: does the session actually get TOLD?
//
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
// everything downstream of these notifications is dead code if the notification never arrives. The
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
// but the wiring does not and the wiring is where a silent failure costs a session all of its
// audio, which is exactly the shape of the bug this watcher exists to fix.
import AVFoundation
import XCTest
#if os(macOS)
import CoreAudio
#endif
@testable import PunktfunkKit
final class AudioDeviceWatcherTests: XCTestCase {
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
/// for. Pumps until `predicate` holds or the deadline passes.
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
return predicate()
}
/// The identity gate is the one line that could swallow every notification silently: get it
/// wrong and the recovery compiles, installs, runs and never fires.
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
XCTAssertTrue(
pump(until: { reasons.contains(.engineConfiguration) }),
"the session was never told its engine's configuration changed")
}
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
/// process are not ours to restart rebuilding for either would interrupt healthy playback.
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
let ours = AVAudioEngine()
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: stranger)
// Give it the same grace the positive case gets, then require silence.
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
}
func testStopSilencesTheWatcher() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
watcher.stop()
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
}
#if os(macOS)
/// The backstop, against the real HAL: move the system's default output device the thing that
/// happens when AirPods come out of an ear and require that the session hears about it. This
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
watcher.stop()
}
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
XCTAssertTrue(
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
"the session was never told the default output device moved")
}
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
#endif
}
+5 -2
View File
@@ -26,8 +26,11 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
# spends tens of seconds moving the system's output device around, long after the 300 frames the
# round-trip test needs.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
--allow-tofu &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
@@ -61,4 +64,4 @@ cd clients/apple
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
PUNKTFUNK_TEST_FEEDBACK=1 \
swift test --filter LoopbackIntegrationTests
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
+6 -1
View File
@@ -1274,13 +1274,18 @@ async fn session(args: Args) -> Result<()> {
}
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level.
// envelope tail (seq + TTL) arrived, not just the level. `lt`/`rt` are the v3
// impulse-trigger levels: printed beside the envelope because the wire-leg
// check for trigger rumble is exactly "non-zero lt/rt AND the envelope still
// present" — i.e. the trigger tail did not displace the seq/TTL tail.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
lt = u.left_trigger,
rt = u.right_trigger,
envelope = ?u.envelope,
"rumble (0xCA)"
);
+8 -5
View File
@@ -4,6 +4,7 @@ use super::pw_cursor::{composite_cursor, update_cursor_meta, CursorState};
use super::pw_pods::{
build_cursor_meta_param, build_default_format_obj, build_dmabuf_buffers, build_dmabuf_format,
build_hdr_dmabuf_format, build_mappable_buffers, build_shm_only_buffers, serialize_pod,
HDR_FORMAT_ORDER,
};
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{Context, Result};
@@ -1850,13 +1851,15 @@ pub fn pipewire_thread(
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
let format_pods: Vec<Vec<u8>> = if want_hdr {
tracing::info!(
"HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
"HDR capture: offering xBGR_210LE/xRGB_210LE LINEAR dmabufs with MANDATORY \
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
);
vec![
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
]
// ⚠ Order is the whole fix — see the NVIDIA note on `HDR_FORMAT_ORDER`. The first
// compatible consumer pod wins, so this is what a gamescope session actually lands on.
HDR_FORMAT_ORDER
.iter()
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
.collect::<Result<Vec<_>>>()?
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
if prefer_native_nv12 {
+65
View File
@@ -121,6 +121,38 @@ pub(super) fn build_dmabuf_format(
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
/// The two 10-bit PQ formats an HDR session offers, **in negotiation order**. The order is not a
/// style choice — on NVIDIA it is the difference between correct colour and red/blue swapped.
///
/// `xBGR_210LE` (DRM `XBGR2101010`, Vulkan `A2B10G10R10_UNORM_PACK32`) comes FIRST because the
/// first compatible consumer pod wins, and it is the only one gamescope fills correctly on every
/// vendor:
///
/// * `A2R10G10B10_UNORM_PACK32` **linear-tiled storage** is an optional Vulkan feature that
/// NVIDIA does not implement. gamescope's capture textures are mappable, hence linear, so on
/// NVIDIA its composite `imageStore` into that image lands in XBGR order — the bytes come out
/// byte-reversed while the buffer is still LABELLED `XRGB2101010`.
/// * The host believes the label: `xRGB_210LE → PixelFormat::X2Rgb10 →`
/// `NV_ENC_BUFFER_FORMAT_ARGB10`. Every mapping in that chain is individually correct, which is
/// exactly why the bug is invisible from this side — the *content* is what's wrong.
/// * Upstream gamescope hit the same wall and fixed it with `vulkan_get_rgb10_capture_format()`,
/// which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to `XBGR2101010`.
/// That landed AFTER 3.16.25, so the pinned `punktfunk-gamescope` (3.16.25-7-g60561e2 +pfhdr4)
/// predates it and cannot self-correct — hence fixing the preference host-side, where it ships
/// in the host binary with no gamescope rebuild.
///
/// Preferring xBGR costs nothing anywhere else: `A2B10G10R10_UNORM_PACK32` is the universally
/// supported packed-10 format (it is the standard HDR10 swapchain format), it is what upstream
/// falls back to, and `X2Bgr10` has a first-class encoder path (NVENC `ABGR10`, VAAPI
/// `X2BGR10LE`). `xRGB_210LE` stays as the second pod so a producer that somehow offers only it
/// can still negotiate HDR rather than falling off to the SDR downgrade.
///
/// ⚠ The real fix belongs upstream in the patch set: `spa_format_to_drm()` should offer only the
/// format `vulkan_get_rgb10_capture_format()` reports. Until the gamescope pin moves past that
/// commit, THIS ORDER is what keeps NVIDIA HDR sessions correct — do not "tidy" it.
pub(super) const HDR_FORMAT_ORDER: [VideoFormat; 2] =
[VideoFormat::xBGR_210LE, VideoFormat::xRGB_210LE];
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
@@ -596,4 +628,37 @@ mod tests {
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
const { assert!(POOL_MIN <= 2) };
}
/// xBGR_210LE must be offered FIRST, and this is a correctness test, not a style one.
///
/// The first compatible consumer pod wins the negotiation. Leading with `xRGB_210LE` makes an
/// NVIDIA gamescope session land on `XRGB2101010`, whose linear-tiled `A2R10G10B10` storage
/// NVIDIA does not support — gamescope's composite `imageStore` writes XBGR bytes under an
/// XRGB label and the whole stream comes out with red and blue swapped. Every format mapping
/// on the host side is individually correct, so nothing downstream can detect it.
///
/// Field-confirmed 2026-08-09 on the RTX 5070 Ti Bazzite host with 0.26.0. See the
/// [`HDR_FORMAT_ORDER`] docs for the upstream fix this predates.
#[test]
fn hdr_offers_xbgr_before_xrgb() {
assert_eq!(
HDR_FORMAT_ORDER[0],
VideoFormat::xBGR_210LE,
"xBGR_210LE must be offered first — leading with xRGB_210LE swaps red and blue on \
every NVIDIA gamescope HDR session"
);
assert_eq!(
HDR_FORMAT_ORDER[1],
VideoFormat::xRGB_210LE,
"xRGB_210LE stays as the fallback pod so a producer offering only it can still \
negotiate HDR instead of dropping to the SDR downgrade"
);
// Both must still build: the order is a preference, never a removal.
for fmt in HDR_FORMAT_ORDER {
assert!(
!build_hdr_dmabuf_format(fmt, None).unwrap().is_empty(),
"{fmt:?} must still produce a format pod"
);
}
}
}
+3
View File
@@ -276,6 +276,9 @@ impl PadInfo {
GamepadPref::DualSenseEdge => "DualSense Edge",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
// Unreachable from `pref_for_type` today — SDL has no Elite `GamepadType` — but a
// pinned setting can carry it, and an empty label there reads as a plain Xbox pad.
GamepadPref::XboxElite => "Xbox Elite Series 2",
GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
GamepadPref::SteamController2 => "Steam Controller 2",
+31
View File
@@ -791,6 +791,37 @@ pub mod gamepad {
/// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
/// (the wired controller interface — the N4-spike finding).
pub const DEVTYPE_STEAMDECK: u8 = 3;
/// `device_type` = Xbox Wireless Controller (`VID_045E&PID_0B13` HID identity — a Bluetooth
/// Xbox pad, which unlike the wired `045E:028E`/`045E:02EA` ids IS a real HID device).
///
/// This exists because the OTHER Windows Xbox backend, `pf-xusb`, registers only
/// `GUID_DEVINTERFACE_XUSB` and has no HID collection — so Steam, WGI, GameInput, DirectInput
/// and `joy.cpl` cannot enumerate it at all, and only classic `XInputGetState` ever sees it
/// (field 2026-08-09). Routing an Xbox pad through this identity instead puts it on the same
/// HID footing the PlayStation pads have always had.
///
/// ⚠️ Unlike its siblings the Xbox input report is NOT 64 bytes — it is
/// `XBOX_INPUT_REPORT_LEN` (16). The driver serves per-identity report lengths because
/// hidclass sizes its buffer from the descriptor and refuses an over-long source.
pub const DEVTYPE_XBOX: u8 = 4;
/// `device_type` = Xbox One S controller over Bluetooth (`VID_045E&PID_02FD`).
///
/// ⭐ **Shares [`DEVTYPE_XBOX`]'s report descriptor, byte for byte.** All three Xbox identities
/// are the same pad in HID terms — same axes, same trigger pair, same hat, same 15 buttons,
/// same rumble output report — and differ ONLY in VID/PID, product string and INF model line.
/// The descriptor is the report SHAPE; the identity is what the OS keys mappings off. Giving
/// each identity its own hand-written descriptor would triple a debt that has already cost
/// three separate bugs (see the `XBOX_RDESC` provenance block in the driver).
pub const DEVTYPE_XBOX_ONE_S: u8 = 5;
/// `device_type` = Xbox Elite Wireless Controller Series 2 (`VID_045E&PID_0B22`) — the
/// hardware `tools/hid-descriptor-dump` captured on `.173`.
///
/// ⚠️ The four paddles are NOT in this identity's report yet. See [`DEVTYPE_XBOX_ONE_S`] for
/// why the descriptor is shared, and `design/xbox-pad-windows-handoff.md` §4 WP-C for the
/// unresolved tension: once the pad is promoted, `xinputhid` claims the HID collection
/// exclusively, so extra buttons declared here may be invisible to every consumer anyway.
/// That needs measuring before it is built.
pub const DEVTYPE_XBOX_ELITE: u8 = 6;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit).
+25
View File
@@ -260,6 +260,27 @@ pub struct HostConfig {
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
/// lever left on a distro whose `gamescope-session-plus` hardcodes that absolute path and
/// reads `GAMESCOPE_BIN` nowhere (Nobara) — see `pf-vdisplay`'s `gamescope.rs`.
///
/// **Three-valued**, because the mechanism is not free and the default has to be the careful
/// one. A mount namespace in a systemd **user** unit necessarily comes with a **user**
/// namespace, which maps only this uid — so every root-owned path the session inspects reads
/// as `nobody`, and that is what made gamescope's Xwayland refuse `/tmp/.X11-unix` and killed
/// Game Mode outright in 0.26.0-canary.
///
/// * `None` (unset — the default): AUTO. The host reads the box's session script and arms the
/// redirect only where nothing else can reach gamescope. Every other distro gets no mount
/// namespace at all.
/// * `Some(false)` (`=0`): never. The session runs the distro's stock gamescope — no HDR, no
/// in-node cursor, games see gamescope's 60 Hz headless default — degraded, but it starts.
/// * `Some(true)` (`=1`): force. Arm it even where the script looks like it honours
/// `GAMESCOPE_BIN` — for the case that lever is defeated somewhere the host cannot see (a
/// `sessions.d` fragment presetting `GAMESCOPECMD`). It does NOT override the runtime
/// backstop: a session that fails with the redirect armed still disarms it.
pub gamescope_bind: Option<bool>,
/// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope
/// session offers its clients on top of the one it runs at, e.g. `60,90,120`.
///
@@ -391,6 +412,10 @@ impl HostConfig {
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
// Deliberately NOT `unwrap_or`: unset is its own answer here (auto — the host decides
// per box), `=0` is the retreat to a stock-gamescope session, and `=1` is the force
// for a box whose `GAMESCOPE_BIN` is defeated somewhere the host cannot read.
gamescope_bind: env_on("PUNKTFUNK_GAMESCOPE_BIND"),
// Unparseable entries are DROPPED rather than failing the host: this only ever widens a
// menu, and the session's own rate is added back unconditionally, so the worst a typo
// can cost is the extra option the operator wanted — never the session.
@@ -299,7 +299,8 @@ impl PadProto for DsLinuxProto {
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb.hidout,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
@@ -401,7 +402,8 @@ impl PadProto for DsEdgeLinuxProto {
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb.hidout,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
@@ -314,7 +314,8 @@ impl PadProto for Ds4LinuxProto {
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb
.led
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
+9 -4
View File
@@ -705,9 +705,14 @@ impl GamepadManager {
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
}
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
/// Service every pad's FF protocol; `send(index, low, high, left_trigger, right_trigger)` is
/// invoked for each pad whose mixed rumble level changed. Call frequently (games block in
/// `EVIOCSFF` until answered).
///
/// The two trigger levels are always zero here and always will be: evdev's `FF_RUMBLE` effect
/// is `{ u16 strong_magnitude, u16 weak_magnitude }` and has no third field, so impulse-trigger
/// rumble is unreachable through this backend no matter what the client can render.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the uinput node would outlive the controller. The swept
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
@@ -715,7 +720,7 @@ impl GamepadManager {
self.slots.reap();
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
send(i as u16, low, high, 0, 0);
}
}
}
@@ -440,7 +440,8 @@ impl PadProto for SteamProto {
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: Vec::new(),
// Rumble-plane liveness: a `0xEB` rumble command this poll. Steam Input drives this
// pad over hidraw (the same abandonment semantics as the Windows Deck backend), so
@@ -570,7 +571,8 @@ impl PadProto for ScProto {
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: Vec::new(),
// Rumble-plane liveness: the kernel registers no FF device for the classic SC, so
// rumble only ever arrives from a hidraw writer (`0xEB`) — which is exactly the
@@ -369,7 +369,8 @@ impl PadProto for TritonProto {
})
.collect();
PadFeedback {
rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
hidout,
// Rumble-plane liveness: Steam is a hidraw writer here too, so the shared
// abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected).
@@ -173,7 +173,8 @@ impl SwitchProPad {
let _ = self.write_report(&build_usb_ack(cmd));
}
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
fb.rumble = Some(rumble);
// No trigger motors on this protocol — see `PadFeedback::rumble`.
fb.rumble = Some((rumble.0, rumble.1, 0, 0));
if id == 0x30 {
// Player lights ride the subcommand itself; still ack it.
if let Some(&arg) = args.first() {
@@ -185,7 +186,7 @@ impl SwitchProPad {
}
self.answer_subcmd(id, &args);
}
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some((r.0, r.1, 0, 0)),
None => {}
}
}
+185 -1
View File
@@ -16,6 +16,78 @@ const _: () = assert!(MAX_PADS <= 16);
/// quiet.
const SWEEP_GRACE: Duration = Duration::from_millis(300);
/// A create failure whose CAUSE the backend was able to identify, attached to the `anyhow` error
/// it returns (`err.context(PadCreateFault::…)`) so [`PadSlots::ensure`] can print the matching
/// remedy instead of the backend's default one.
///
/// Why this exists. The create-failure line's remedy is a per-backend constant (`PadSlots`'s
/// `hint`, from [`PadSlots::new`]), and on Windows that constant says "install/repair: punktfunk-host.exe
/// driver install --gamepad", because a pad create that fails there has nearly always failed for
/// want of the UMDF driver package. Nearly. On 2026-08-09 a `.173` devtest hit a create that
/// failed for the opposite reason — the drivers were fine and a LIVE SIBLING PROCESS already owned
/// the pad index's OS-level name — and the line told the operator to repair a driver that was
/// working. Worse, the run carried on: the retry could not succeed while the other process held
/// the index, and everything measured afterwards was that other process's pad (a frozen XInput
/// packet count read as a real measurement). A wrong remedy is worse than no remedy, so a backend
/// that can name the cause now says so and the line follows it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PadCreateFault {
/// The OS-level name this pad index needs — on Windows the `Global\pf…-boot-<index>` bootstrap
/// mailbox — is already held by another LIVE process.
///
/// Retrying stays right and is deliberately left alone: the name frees itself the moment the
/// owner releases it (a session ending, a service restart), and that is exactly how the field
/// case recovered. What retrying can never do is *hurry* it, and no driver install affects it
/// at all — which is the whole content of [`Self::hint`].
IndexOwnedElsewhere,
}
impl PadCreateFault {
/// Short tag for the structured `fault` log field — greppable; the prose lives in
/// [`Self::hint`].
pub fn as_str(self) -> &'static str {
match self {
PadCreateFault::IndexOwnedElsewhere => "index-owned-elsewhere",
}
}
/// The remedy this fault gets INSTEAD of the backend's default hint.
pub fn hint(self) -> &'static str {
match self {
PadCreateFault::IndexOwnedElsewhere => {
" — this pad index is already owned by another LIVE process (on a Windows host \
that is the LocalSystem PunktfunkHost service, whose session still holds the \
pad). The drivers are not the problem and reinstalling them will not help: the \
retry succeeds on its own once that process releases the index (end its session, \
or Restart-Service PunktfunkHost), or run against a pad index it does not hold."
}
}
}
}
impl std::fmt::Display for PadCreateFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PadCreateFault::IndexOwnedElsewhere => f.write_str(
"the OS name this pad index needs is already owned by another live process",
),
}
}
}
/// The fault a backend attached to a create error, if any.
///
/// An `anyhow` context downcast, which is what makes this usable from a backend: the fault is
/// found however many further `.context()` layers were wrapped around it on the way up, so a
/// backend can attach it at the exact call that failed and still describe the failure in its own
/// words afterwards. Split out of [`PadSlots::ensure`] so the choice is testable without standing
/// up a tracing subscriber — and so the downcast-through-context behaviour this depends on is
/// pinned by a test rather than assumed (the attaching code is `cfg(windows)` and cannot be
/// compiled, let alone run, on a developer machine).
fn create_fault(err: &anyhow::Error) -> Option<PadCreateFault> {
err.downcast_ref::<PadCreateFault>().copied()
}
/// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct Sweep {
@@ -172,11 +244,24 @@ impl<P> PadSlots<P> {
true
}
Err(e) => {
// Which remedy to print. The backend's default `hint` assumes the failure is the
// one that dominates the field — on Windows an absent or stale driver package —
// and sends the operator to reinstall. For a create that failed because a live
// sibling owns this index that advice is not merely useless, it is a wrong lead
// that costs a debugging session (2026-08-09, `.173`), so a named fault overrides
// it. Anonymous failures keep the previous wording byte for byte.
//
// `index` is new and unconditional: the line used to name the backend and the
// device but never the SLOT, so a multi-pad session's failure could not be told
// from any other pad's.
let fault = create_fault(&e);
tracing::error!(
index = idx,
error = %format!("{e:#}"),
fault = fault.map_or("unclassified", PadCreateFault::as_str),
"virtual {} creation failed — retrying with backoff{}",
self.device,
self.hint
fault.map_or(self.hint, PadCreateFault::hint)
);
self.gate.on_failure(Instant::now());
false
@@ -184,6 +269,18 @@ impl<P> PadSlots<P> {
}
}
/// How many pads this table currently holds.
///
/// The question a bring-up harness has to ask before it believes anything it measures: a
/// create that failed leaves the slot empty and [`Self::ensure`] only logs, so a devtest that
/// pushes frames regardless is measuring whatever OTHER process's pad is answering on that
/// index — which is exactly how a stale pad's frozen packet count was once read as a result
/// (2026-08-09). Not `len` (and so not paired with `is_empty`): it counts LIVE pads, not the
/// fixed [`MAX_PADS`] slots the table always has.
pub fn live(&self) -> usize {
self.pads.iter().flatten().count()
}
/// The live pad at `idx`, if any (out-of-range → `None`).
pub fn get(&self, idx: usize) -> Option<&P> {
self.pads.get(idx).and_then(|s| s.as_ref())
@@ -350,6 +447,93 @@ mod tests {
assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop");
}
/// The mechanism the Windows backend's diagnosis rests on, and the one thing about it that
/// could quietly stop working: [`create_fault`] must find the fault through however many
/// `.context()` layers wrapped it. The real chain is built in `gamepad_raii::create_named`
/// (`cfg(windows)`, so neither compiled nor run here) and has exactly this shape — the OS
/// error at the bottom, the fault, then the human sentence on top — so reproduce it verbatim.
#[test]
fn a_named_fault_survives_the_context_layers_wrapped_around_it() {
let err = anyhow::Error::msg("Zugriff verweigert (0x80070005)")
.context(PadCreateFault::IndexOwnedElsewhere)
.context("bootstrap mailbox Global\\pfds-boot-0 already exists");
assert_eq!(
create_fault(&err),
Some(PadCreateFault::IndexOwnedElsewhere)
);
// …and the operator-facing rendering still carries every layer, newest first, so the
// underlying OS error is never traded away for the diagnosis.
let shown = format!("{err:#}");
assert!(shown.contains("Global\\pfds-boot-0"), "{shown}");
assert!(
shown.contains("already owned by another live process"),
"{shown}"
);
assert!(shown.contains("0x80070005"), "{shown}");
}
#[test]
fn an_unclassified_failure_carries_no_fault() {
// Every other backend failure — a missing driver, a wedged PnP, an EBUSY on /dev/uinput —
// must keep the backend's own hint, so the absence of a fault has to read as absence.
assert_eq!(
create_fault(&anyhow::Error::msg("SwDeviceCreate failed")),
None
);
}
/// THE regression this classification exists for: the contended remedy must not send an
/// operator to reinstall a driver that is working fine, and must name what actually has to
/// happen. Asserted on the text because the text is the whole deliverable.
#[test]
fn the_contended_hint_never_tells_the_operator_to_reinstall_drivers() {
let hint = PadCreateFault::IndexOwnedElsewhere.hint();
assert!(
!hint.contains("driver install"),
"the contended hint must not repeat the driver-repair advice: {hint}"
);
assert!(hint.contains("already owned"), "{hint}");
assert!(hint.contains("Restart-Service"), "{hint}");
}
/// A named fault must not turn the create into a permanent latch — that latch is the exact
/// `broken: bool` behaviour [`PadGate`] was built to remove, and the field case healed by
/// itself precisely because the retry was still running when the owning service restarted.
#[test]
fn a_contended_create_still_backs_off_and_retries_rather_than_latching() {
let mut s = slots();
let contended = || {
Err(anyhow::Error::msg("Zugriff verweigert")
.context(PadCreateFault::IndexOwnedElsewhere))
};
assert!(!s.ensure(0, |_| contended()));
assert_eq!(s.live(), 0);
// Backed off, not latched: once the window elapses the closure runs again. `ensure` reads
// the wall clock, so clear the backoff directly rather than sleeping through it — the
// window's own arithmetic is pinned by `pad_gate`'s tests.
s.gate.on_success();
let mut ran = false;
assert!(s.ensure(0, |i| {
ran = true;
Ok(i as u32)
}));
assert!(ran, "the create was never re-attempted");
assert_eq!(s.live(), 1);
}
#[test]
fn live_counts_built_pads_not_slots() {
let mut s = slots();
assert_eq!(s.live(), 0, "an empty table has no pads, only slots");
assert!(s.ensure(0, |_| Ok(0)));
assert!(s.ensure(4, |_| Ok(4)));
assert_eq!(s.live(), 2);
let t0 = Instant::now();
s.sweep_at(0, t0);
s.sweep_at(0, t0 + SWEEP_GRACE);
assert_eq!(s.live(), 0);
}
#[test]
fn create_failure_arms_the_gate_and_success_heals_it() {
let mut s = slots();
@@ -0,0 +1,386 @@
//! Xbox Wireless Controller HID codec — the byte-exact input report the `pf-gamepad` driver serves
//! under `device_type = 4` ([`pf_driver_proto::gamepad::DEVTYPE_XBOX`]).
//!
//! **Why an Xbox pad speaks HID at all.** The other Windows Xbox backend, `pf-xusb`, registers only
//! `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration,
//! DirectInput, `joy.cpl` and WGI/GameInput cannot see it — only classic `XInputGetState` via
//! xinput1_4's interface walk ever does. A field report (2026-08-09) burned two weeks on a dead
//! controller for exactly that reason, and switching the client to DualSense — a real HID pad
//! through the same UMDF driver — fixed it instantly. This codec puts the Xbox pad on that footing.
//!
//! **The report is the descriptor's mirror image.** `pf-gamepad`'s `XBOX_RDESC` declares, in order:
//! two 16-bit stick pairs (`X`/`Y`, then `Rx`/`Ry`, logical 0..65535), two 16-bit triggers on the
//! Simulation page (`Brake`/`Accelerator`, logical 0..1023), a 4-bit null-state hat plus 4 bits of
//! padding, and 15 buttons plus 1 bit of padding. [`serialize_xbox_state`] writes exactly that, and
//! [`tests`] pins every field position — change one side and the tests fail.
//!
//! ⚠️⚠️ **The button numbering below is the REAL Xbox-Bluetooth layout, gaps included, and that is
//! load-bearing.** We enumerate as a genuine Microsoft `045E:0B13`, and SDL / Steam / Windows all
//! carry built-in mappings keyed off that VID/PID. Renumber these to something "tidier" and every
//! consumer with a stock mapping silently lands each control on the wrong action — the exact class
//! of bug this module exists to end. The reserved slots (3, 6, 9, 10) are Microsoft's; leave them
//! empty.
//!
//! ⚠️ **Never validated against real hardware.** No Windows box was reachable when this was written
//! (`punktfunk-field-windows-pad-dead-0260`), so the layout is from the documented Xbox One S / Series
//! Bluetooth report and has not been diffed against a capture. Do that before shipping: dump a real
//! pad's descriptor + a few reports and compare against `XBOX_RDESC` and the tests here.
use punktfunk_core::input::gamepad as gs;
/// Bytes an Xbox input report occupies on the wire, report id included. Must equal the driver's
/// `XBOX_INPUT_REPORT_LEN` — hidclass sizes its READ_REPORT buffer from the descriptor and the
/// driver's `copy_to_output` refuses a longer source rather than truncating.
pub const XBOX_REPORT_LEN: usize = 16;
/// The report id the descriptor declares for the input report.
const REPORT_ID: u8 = 0x01;
/// Stick centre on the descriptor's 0..65535 axis.
const STICK_CENTRE: u16 = 0x8000;
/// Trigger full scale on the descriptor's 0..1023 (10-bit) axis.
const TRIGGER_MAX: u32 = 1023;
// ---- Button bit positions, LSB-first across report bytes 14..16 ----
//
// HID button N lands on bit (N-1). These are the REAL Xbox-Bluetooth assignments; slots 3, 6, 9
// and 10 are reserved by Microsoft and stay empty (see the module note).
const BIT_A: u8 = 0; // button 1
const BIT_B: u8 = 1; // button 2
const BIT_X: u8 = 3; // button 4
const BIT_Y: u8 = 4; // button 5
const BIT_LB: u8 = 6; // button 7
const BIT_RB: u8 = 7; // button 8
const BIT_VIEW: u8 = 10; // button 11 (Back/Select)
const BIT_MENU: u8 = 11; // button 12 (Start)
const BIT_GUIDE: u8 = 12; // button 13 (Xbox button)
const BIT_LS: u8 = 13; // button 14 (left stick click)
const BIT_RS: u8 = 14; // button 15 (right stick click)
/// One Xbox pad's state, in the wire's own conventions (sticks 32768..32767 with **+y = up**,
/// triggers 0..255, buttons the [`gs`] `BTN_*` bitmask) — converted to the HID report's
/// conventions by [`serialize_xbox_state`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct XboxState {
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
impl XboxState {
/// Build from the wire's per-pad frame fields (`punktfunk_core::input::GamepadFrame`).
#[allow(clippy::too_many_arguments)]
pub fn from_gamepad(
buttons: u32,
left_trigger: u8,
right_trigger: u8,
ls_x: i16,
ls_y: i16,
rs_x: i16,
rs_y: i16,
) -> XboxState {
XboxState {
buttons,
left_trigger,
right_trigger,
ls_x,
ls_y,
rs_x,
rs_y,
}
}
}
/// Wire stick axis (32768..32767) → the descriptor's unsigned 0..65535 X/Rx axis.
fn axis_x(v: i16) -> u16 {
(v as i32 + 32768) as u16
}
/// Wire stick axis → the descriptor's 0..65535 Y/Ry axis, **inverted**.
///
/// The wire follows the XInput/Moonlight convention where **+y is UP**; HID's `Y`/`Ry` grow
/// DOWNWARD. Forwarding the wire value unconverted is how a pad ends up with an inverted look
/// stick that nobody notices until they aim.
///
/// ⚠️ A signed 16-bit range has no exact midpoint, so the inverted axis centres one unit lower
/// than the upright one: `axis_x(0)` is 32768 and `axis_y(0)` is 32767. Both endpoints are exact
/// (full up → 0, full down → 65535), which is what matters; the 1/65536 offset at rest is below
/// any deadzone. Do NOT "fix" it by centring on 32768 — that costs an endpoint instead.
fn axis_y(v: i16) -> u16 {
65535 - axis_x(v)
}
/// Wire trigger (0..255) → the descriptor's 10-bit 0..1023 axis, rounded rather than truncated so
/// a fully-held trigger reads exactly full scale.
fn trigger(v: u8) -> u16 {
((v as u32 * TRIGGER_MAX + 127) / 255) as u16
}
/// The d-pad bits → the descriptor's hat value: `0` is the NULL state (the logical range starts at
/// 1), then 1..8 clockwise from North. Opposing presses cancel, matching a physical hat.
fn hat(buttons: u32) -> u8 {
let up = buttons & gs::BTN_DPAD_UP != 0;
let down = buttons & gs::BTN_DPAD_DOWN != 0;
let left = buttons & gs::BTN_DPAD_LEFT != 0;
let right = buttons & gs::BTN_DPAD_RIGHT != 0;
// Cancel opposing pairs first so up+down reads centred rather than picking one.
let (up, down) = if up && down {
(false, false)
} else {
(up, down)
};
let (left, right) = if left && right {
(false, false)
} else {
(left, right)
};
match (up, right, down, left) {
(true, false, false, false) => 1, // N
(true, true, false, false) => 2, // NE
(false, true, false, false) => 3, // E
(false, true, true, false) => 4, // SE
(false, false, true, false) => 5, // S
(false, false, true, true) => 6, // SW
(false, false, false, true) => 7, // W
(true, false, false, true) => 8, // NW
_ => 0, // nothing held → NULL
}
}
/// The 15 face/shoulder/system buttons packed into the report's last two bytes.
fn button_bits(buttons: u32) -> (u8, u8) {
let mut bits: u16 = 0;
for (mask, bit) in [
(gs::BTN_A, BIT_A),
(gs::BTN_B, BIT_B),
(gs::BTN_X, BIT_X),
(gs::BTN_Y, BIT_Y),
(gs::BTN_LB, BIT_LB),
(gs::BTN_RB, BIT_RB),
(gs::BTN_BACK, BIT_VIEW),
(gs::BTN_START, BIT_MENU),
(gs::BTN_GUIDE, BIT_GUIDE),
(gs::BTN_LS_CLICK, BIT_LS),
(gs::BTN_RS_CLICK, BIT_RS),
] {
if buttons & mask != 0 {
bits |= 1 << bit;
}
}
(bits as u8, (bits >> 8) as u8)
}
/// Serialize one [`XboxState`] into the driver's input report.
pub fn serialize_xbox_state(s: &XboxState) -> [u8; XBOX_REPORT_LEN] {
let mut r = [0u8; XBOX_REPORT_LEN];
r[0] = REPORT_ID;
r[1..3].copy_from_slice(&axis_x(s.ls_x).to_le_bytes());
r[3..5].copy_from_slice(&axis_y(s.ls_y).to_le_bytes());
r[5..7].copy_from_slice(&axis_x(s.rs_x).to_le_bytes());
r[7..9].copy_from_slice(&axis_y(s.rs_y).to_le_bytes());
r[9..11].copy_from_slice(&trigger(s.left_trigger).to_le_bytes());
r[11..13].copy_from_slice(&trigger(s.right_trigger).to_le_bytes());
r[13] = hat(s.buttons); // low nibble; the high nibble is descriptor padding
let (lo, hi) = button_bits(s.buttons);
r[14] = lo;
r[15] = hi;
r
}
/// The at-rest report: sticks centred, triggers released, hat NULL, nothing held. Must agree with
/// the driver's `XBOX_NEUTRAL_REPORT` — [`tests::neutral_matches_a_zeroed_state`] pins that.
pub fn neutral_xbox_report() -> [u8; XBOX_REPORT_LEN] {
serialize_xbox_state(&XboxState::default())
}
#[cfg(test)]
mod tests {
use super::*;
fn le(b: &[u8]) -> u16 {
u16::from_le_bytes([b[0], b[1]])
}
/// The field offsets the driver's `XBOX_RDESC` declares. If this fails, one side moved.
#[test]
fn the_report_matches_the_descriptor_layout() {
let s = XboxState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
let r = serialize_xbox_state(&s);
assert_eq!(r.len(), XBOX_REPORT_LEN, "16 bytes: id + 8 + 4 + 1 + 2");
assert_eq!(r[0], 0x01, "report id");
}
#[test]
fn sticks_span_the_full_unsigned_axis() {
let full = XboxState::from_gamepad(0, 0, 0, i16::MIN, i16::MIN, i16::MAX, i16::MAX);
let r = serialize_xbox_state(&full);
assert_eq!(le(&r[1..3]), 0, "LX at hard left = 0");
assert_eq!(le(&r[5..7]), 65535, "RX at hard right = 65535");
}
/// +y is UP on the wire and DOWN in HID — the conversion has to flip, or aiming is inverted.
#[test]
fn the_y_axes_are_inverted_into_hid_convention() {
let up = XboxState::from_gamepad(0, 0, 0, 0, i16::MAX, 0, i16::MAX);
let r = serialize_xbox_state(&up);
assert_eq!(le(&r[3..5]), 0, "stick fully UP is 0 in HID");
assert_eq!(le(&r[7..9]), 0, "right stick too");
let down = XboxState::from_gamepad(0, 0, 0, 0, i16::MIN, 0, i16::MIN);
let r = serialize_xbox_state(&down);
assert_eq!(le(&r[3..5]), 65535, "stick fully DOWN is full scale");
assert_eq!(le(&r[7..9]), 65535);
}
/// At rest the upright axes sit on `STICK_CENTRE` and the inverted ones one unit below — the
/// unavoidable consequence of mirroring a range with an even number of steps (see `axis_y`).
#[test]
fn a_centred_stick_reads_centred() {
let r = neutral_xbox_report();
assert_eq!(le(&r[1..3]), STICK_CENTRE, "LX");
assert_eq!(le(&r[5..7]), STICK_CENTRE, "RX");
assert_eq!(le(&r[3..5]), STICK_CENTRE - 1, "LY (inverted)");
assert_eq!(le(&r[7..9]), STICK_CENTRE - 1, "RY (inverted)");
}
/// A fully-held trigger must reach exactly full scale — truncating division stops at 1020 and
/// games with a "trigger fully pressed" threshold never fire.
#[test]
fn triggers_scale_to_full_ten_bit_range() {
let none = serialize_xbox_state(&XboxState::default());
assert_eq!(le(&none[9..11]), 0);
assert_eq!(le(&none[11..13]), 0);
let held = XboxState::from_gamepad(0, 255, 255, 0, 0, 0, 0);
let r = serialize_xbox_state(&held);
assert_eq!(le(&r[9..11]), 1023, "LT fully held = full scale");
assert_eq!(le(&r[11..13]), 1023, "RT fully held = full scale");
let half = XboxState::from_gamepad(0, 128, 0, 0, 0, 0, 0);
let r = serialize_xbox_state(&half);
assert_eq!(le(&r[9..11]), 514, "128/255 rounds to 514, not 513");
}
#[test]
fn the_hat_walks_clockwise_from_north() {
let cases = [
(0, 0u8),
(gs::BTN_DPAD_UP, 1),
(gs::BTN_DPAD_UP | gs::BTN_DPAD_RIGHT, 2),
(gs::BTN_DPAD_RIGHT, 3),
(gs::BTN_DPAD_RIGHT | gs::BTN_DPAD_DOWN, 4),
(gs::BTN_DPAD_DOWN, 5),
(gs::BTN_DPAD_DOWN | gs::BTN_DPAD_LEFT, 6),
(gs::BTN_DPAD_LEFT, 7),
(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 8),
];
for (buttons, want) in cases {
let r = serialize_xbox_state(&XboxState::from_gamepad(buttons, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, want, "buttons {buttons:#x}");
}
}
/// Opposing presses cancel to NULL rather than resolving to one direction — a physical hat
/// cannot report both, and a game that sees "up" while the player holds up+down drifts.
#[test]
fn opposing_dpad_presses_cancel() {
let ud = gs::BTN_DPAD_UP | gs::BTN_DPAD_DOWN;
let r = serialize_xbox_state(&XboxState::from_gamepad(ud, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, 0);
let lr = gs::BTN_DPAD_LEFT | gs::BTN_DPAD_RIGHT;
let r = serialize_xbox_state(&XboxState::from_gamepad(lr, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, 0);
}
/// The real Xbox-Bluetooth button numbering, gaps included. SDL/Steam/Windows key their stock
/// mappings off our claimed `045E:0B13`, so these positions are a compatibility contract.
#[test]
fn buttons_land_on_the_real_xbox_bluetooth_positions() {
let cases: [(u32, usize, u8); 11] = [
(gs::BTN_A, 14, 0),
(gs::BTN_B, 14, 1),
(gs::BTN_X, 14, 3),
(gs::BTN_Y, 14, 4),
(gs::BTN_LB, 14, 6),
(gs::BTN_RB, 14, 7),
(gs::BTN_BACK, 15, 2),
(gs::BTN_START, 15, 3),
(gs::BTN_GUIDE, 15, 4),
(gs::BTN_LS_CLICK, 15, 5),
(gs::BTN_RS_CLICK, 15, 6),
];
for (mask, byte, bit) in cases {
let r = serialize_xbox_state(&XboxState::from_gamepad(mask, 0, 0, 0, 0, 0, 0));
assert_eq!(
r[byte] & (1u8 << bit),
1u8 << bit,
"mask {mask:#x} should set byte {byte} bit {bit}"
);
// and nothing else in the button bytes
let shift = bit as u16 + (byte as u16 - 14) * 8;
let others = (r[14] as u16 | (r[15] as u16) << 8) & !(1u16 << shift);
assert_eq!(others, 0, "mask {mask:#x} set a second button bit");
}
}
/// Microsoft's reserved slots (buttons 3, 6, 9, 10) and the descriptor's trailing pad bit must
/// stay clear — a stray bit there reads as a button the real pad does not have.
#[test]
fn reserved_button_slots_stay_empty() {
let all = gs::BTN_A
| gs::BTN_B
| gs::BTN_X
| gs::BTN_Y
| gs::BTN_LB
| gs::BTN_RB
| gs::BTN_BACK
| gs::BTN_START
| gs::BTN_GUIDE
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK;
let r = serialize_xbox_state(&XboxState::from_gamepad(all, 0, 0, 0, 0, 0, 0));
let bits = r[14] as u16 | (r[15] as u16) << 8;
for reserved_bit in [2u8, 5, 8, 9, 15] {
assert_eq!(
bits & (1 << reserved_bit),
0,
"bit {reserved_bit} is reserved/padding and must stay clear"
);
}
}
/// Extended wire buttons the Xbox HID profile has no slot for (touchpad, capture, paddles) must
/// be dropped silently rather than colliding with a real button.
#[test]
fn unmappable_wire_buttons_are_dropped() {
let extra = gs::BTN_TOUCHPAD | gs::BTN_MISC1 | gs::BTN_PADDLE1;
let r = serialize_xbox_state(&XboxState::from_gamepad(extra, 0, 0, 0, 0, 0, 0));
assert_eq!(r[14], 0);
assert_eq!(r[15], 0);
assert_eq!(r[13] & 0x0F, 0);
}
#[test]
fn neutral_matches_a_zeroed_state() {
assert_eq!(
neutral_xbox_report(),
serialize_xbox_state(&XboxState::default())
);
let r = neutral_xbox_report();
assert_eq!(r[13], 0, "hat NULL");
assert_eq!(r[14], 0);
assert_eq!(r[15], 0);
// Mirrors the driver's XBOX_NEUTRAL_REPORT byte for byte — if these drift, a game reads a
// different at-rest pose before the host's first frame lands than after it.
assert_eq!(r[0], 0x01);
assert_eq!([r[1], r[2]], [0x00, 0x80], "LX = 0x8000");
assert_eq!([r[3], r[4]], [0xFF, 0x7F], "LY = 0x7FFF (inverted centre)");
assert_eq!([r[5], r[6]], [0x00, 0x80], "RX = 0x8000");
assert_eq!([r[7], r[8]], [0xFF, 0x7F], "RY = 0x7FFF (inverted centre)");
}
}
+120 -45
View File
@@ -18,13 +18,21 @@ use std::time::{Duration, Instant};
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
#[derive(Default)]
pub struct PadFeedback {
/// `(low, high)` motor levels, if the pass saw a rumble report.
/// `(low, high, left_trigger, right_trigger)` motor levels, if the pass saw a rumble report.
///
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
pub rumble: Option<(u16, u16)>,
///
/// The two trailing fields are the Xbox impulse-trigger motors, which ride the 0xCA plane's
/// v3 tail (design/trigger-rumble-plane.md). **Exactly one backend can ever set them non-zero**
/// — the Windows HID Xbox pad, whose output report `0x03` has fields for them. Every other
/// backend reports `(low, high, 0, 0)` because the packet it parses has nowhere to carry them:
/// XUSB's `SET_STATE` is `rumble_large`/`rumble_small`, evdev's `FF_RUMBLE` is strong/weak,
/// and a DualSense's trigger actuators are *adaptive* (force resistance, on the 0xCD plane)
/// rather than motors. That is a permanent property of those protocols, not a gap to fill.
pub rumble: Option<(u16, u16, u16, u16)>,
pub hidout: Vec<HidOutput>,
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
/// asserted the vibration fields (valid-flag set, including an explicit zero), not merely any
@@ -119,8 +127,11 @@ pub struct UhidManager<B: PadProto> {
slots: PadSlots<B::Pad>,
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
state: Vec<B::State>,
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send
/// it. All FOUR levels, deliberately: dedup on the handle pair alone would swallow a
/// trigger-only change — a racing title's impulse-trigger stream against silent handles — and
/// the pad would never rumble, with nothing logged anywhere.
last_rumble: Vec<(u16, u16, u16, u16)>,
/// Last rich feedback forwarded per pad, so an output report that only changed the rumble
/// doesn't re-send unchanged lightbar/LED/trigger state.
hidout_dedup: Vec<HidoutDedup>,
@@ -254,7 +265,7 @@ impl<B: PadProto> UhidManager<B> {
backend,
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
state,
last_rumble: vec![(0, 0); MAX_PADS],
last_rumble: vec![(0, 0, 0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
last_active: vec![Instant::now(); MAX_PADS],
@@ -263,6 +274,19 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// How many virtual pads this manager has actually BUILT
/// ([`PadSlots::live`](crate::pad_slots::PadSlots::live)).
///
/// For bring-up harnesses, which are the only callers that can act on it: a create failure
/// leaves the slot empty and only logs, so a harness that pushes frames regardless still
/// "works" — it just drives nothing, while whatever OTHER process owns that pad index keeps
/// answering every probe the operator then runs. That is how a stale pad's frozen XInput
/// packet count was once read as a measurement (2026-08-09, `.173`). A session has no use for
/// this: its pads come and go with the client's `active_mask` and zero is a normal state.
pub fn live_pads(&self) -> usize {
self.slots.live()
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
@@ -339,13 +363,14 @@ impl<B: PadProto> UhidManager<B> {
}
/// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
/// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
/// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
/// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
/// handshakes block until answered.
/// back out. `rumble` is invoked `(index, low, high, left_trigger, right_trigger)` only when
/// the motor level *changes* (the universal 0xCA plane — the trigger pair is non-zero only on
/// the Windows HID Xbox pad, see [`PadFeedback::rumble`]); `hidout` is invoked per rich
/// feedback event that isn't an exact repeat of the last-forwarded value (the 0xCD plane).
/// Call frequently — kernel/driver init handshakes block until answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut rumble: impl FnMut(u16, u16, u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
let now = Instant::now();
@@ -369,9 +394,9 @@ impl<B: PadProto> UhidManager<B> {
// the next LED/trigger state re-forwards. WARN through the per-pad rate limiter —
// a storm overflows every poll and the raw line once flooded a whole log export.
self.overflow_warn[i].note(now, B::LABEL, i);
if self.last_rumble[i] != (0, 0) {
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
if self.last_rumble[i] != (0, 0, 0, 0) {
self.last_rumble[i] = (0, 0, 0, 0);
rumble(i as u16, 0, 0, 0, 0);
}
self.hidout_dedup[i] = HidoutDedup::default();
}
@@ -385,9 +410,9 @@ impl<B: PadProto> UhidManager<B> {
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1);
rumble(i as u16, r.0, r.1, r.2, r.3);
}
} else if self.last_rumble[i] != (0, 0)
} else if self.last_rumble[i] != (0, 0, 0, 0)
&& rumble_idle_timeout()
.is_some_and(|t| now.duration_since(self.last_active[i]) >= t)
{
@@ -400,10 +425,12 @@ impl<B: PadProto> UhidManager<B> {
index = i,
prev_low = self.last_rumble[i].0,
prev_high = self.last_rumble[i].1,
prev_lt = self.last_rumble[i].2,
prev_rt = self.last_rumble[i].3,
"rumble: stale residual (game stopped driving the rumble plane) — forcing off"
);
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
self.last_rumble[i] = (0, 0, 0, 0);
rumble(i as u16, 0, 0, 0, 0);
}
for h in fb.hidout {
// Skip rich feedback that repeats the last-forwarded value (a game's output report
@@ -469,7 +496,7 @@ impl<B: PadProto> UhidManager<B> {
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
self.state[idx] = self.backend.neutral();
self.last_rumble[idx] = (0, 0);
self.last_rumble[idx] = (0, 0, 0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.last_active[idx] = Instant::now();
@@ -733,14 +760,14 @@ mod tests {
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
m.pump(|_, _, _| {}, |_| {});
m.pump(|_, _, _, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_some(),
"a tick inside the grace dropped it"
);
// Grace elapsed: the next tick completes the unplug, with no further frame.
m.slots.expire_grace();
m.pump(|_, _, _| {}, |_| {});
m.pump(|_, _, _, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_none(),
"the pump tick never completed the unplug"
@@ -783,7 +810,10 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
out.into_inner()
};
let rumble = |r| PadFeedback {
@@ -792,12 +822,16 @@ mod tests {
rumble_drove: Some(true),
resync: false,
};
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
*m.backend.feedback.borrow_mut() = vec![
rumble((100, 0, 0, 0)),
rumble((100, 0, 0, 0)),
rumble((7, 7, 0, 0)),
];
assert_eq!(collect(&mut m), vec![(0, 100, 0, 0, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
m.slots.expire_grace();
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
@@ -806,8 +840,43 @@ mod tests {
"the pump tick completed the unplug"
);
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7, 0, 0))];
assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]);
}
/// The dedup compares all FOUR levels. Comparing only the handle pair would swallow a
/// trigger-only change — which is the *normal* shape of impulse-trigger content, since racing
/// titles drive the triggers continuously against near-silent handles — and the pad would
/// simply never rumble, with nothing logged and nothing on the wire to look at.
#[test]
fn a_trigger_only_change_is_forwarded_not_deduped_away() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
out.into_inner()
};
let rumble = |r| PadFeedback {
rumble: Some(r),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
};
// Handles silent throughout; only the trigger motors move.
*m.backend.feedback.borrow_mut() = vec![
rumble((0, 0, 0x8000, 0)),
rumble((0, 0, 0x8000, 0)),
rumble((0, 0, 0x8000, 0x4000)),
rumble((0, 0, 0, 0)),
];
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0)]);
assert_eq!(collect(&mut m), vec![], "exact repeat still dedups");
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0x4000)]);
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)], "the stop forwards");
}
#[test]
@@ -816,17 +885,20 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
out.into_inner()
};
// The game latches a non-zero rumble (a fresh report drove the pad).
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)),
rumble: Some((200, 0, 0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]);
// The game stops driving the RUMBLE plane — no output report at all, or (equivalently, the
// confirmed stuck-ON case) a stream of LED/adaptive-trigger reports that never assert the
@@ -845,7 +917,7 @@ mod tests {
// exactly once, then stays off (no repeated zero spam).
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![idle(), idle()];
assert_eq!(collect(&mut m), vec![(0, 0, 0)]); // forced off
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)]); // forced off
assert_eq!(collect(&mut m), vec![]); // already zero — no repeat
}
@@ -855,16 +927,19 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
out.into_inner()
};
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)),
rumble: Some((200, 0, 0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]);
// Even with a stale clock, a poll where the game drove the rumble plane refreshes
// activity, so the held rumble is NOT cut. Backends report that as
@@ -872,7 +947,7 @@ mod tests {
// the manager also honors the bare `rumble_drove: Some(true)` shape defensively.
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0)),
rumble: Some((200, 0, 0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
@@ -906,7 +981,7 @@ mod tests {
}];
let out = RefCell::new(0u32);
m.pump(
|_, _, _| {},
|_, _, _, _, _| {},
|_| {
*out.borrow_mut() += 1;
},
@@ -976,7 +1051,7 @@ mod tests {
let rumbles = RefCell::new(Vec::new());
let hidouts = RefCell::new(0u32);
m.pump(
|i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)),
|i, lo, hi, lt, rt| rumbles.borrow_mut().push((i, lo, hi, lt, rt)),
|_| *hidouts.borrow_mut() += 1,
);
(rumbles.into_inner(), hidouts.into_inner())
@@ -984,12 +1059,12 @@ mod tests {
// Latch a rumble + an LED.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0)),
rumble: Some((100, 0, 0, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
// Overflow poll: no reports survived, resync flagged → forced stop, exactly once.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
@@ -998,22 +1073,22 @@ mod tests {
rumble_drove: Some(false),
resync: true,
}];
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0));
assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0));
// The game re-asserts the SAME rumble + LED state: both must re-forward (the rumble
// because the forced stop reset `last_rumble`, the LED because the dedup was re-armed).
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0)),
rumble: Some((100, 0, 0, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
// A resync with nothing latched forwards no spurious stop.
*m.backend.feedback.borrow_mut() = vec![
PadFeedback {
rumble: Some((0, 0)),
rumble: Some((0, 0, 0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
@@ -1025,7 +1100,7 @@ mod tests {
resync: true,
},
];
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop
assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0)); // the explicit stop
assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent
}
}
@@ -85,7 +85,8 @@ impl PadProto for DsEdgeWinProto {
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb.hidout,
// Rumble-plane liveness, not any-report liveness — see the plain DualSense backend.
rumble_drove: Some(fb.rumble.is_some()),
@@ -686,7 +686,8 @@ impl PadProto for DsWinProto {
// feed the abandoned-rumble force-off's activity clock (the historical unbounded
// stuck-ON path, now doubly closed by the lossless report ring).
rumble_drove: Some(fb.rumble.is_some()),
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb.hidout,
resync: fb.resync,
}
@@ -995,14 +996,26 @@ mod drain_tests {
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
);
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
// The [Models] lines: `%DeviceDesc…%=pfGamepad, <hwid>[, <hwid>…]`.
// The [Models] lines: `%DeviceDesc…%=<InstallSection>, <hwid>[, <hwid>…]`.
//
// ⚠️ Match the install section by PREFIX, not by the exact string `pfGamepad,`. The Xbox
// line installs `pfGamepadXbox` — a section of its own, because that identity additionally
// attaches the `xinputhid` bus filter and the four PlayStation/Deck identities must not get
// it. An exact match silently stopped seeing the Xbox ids the moment that split happened,
// which is precisely the "this test went vacuous" failure the assert below guards against,
// except it failed loudly instead. Keep this tolerant of further per-identity sections.
let declared: Vec<String> = inf
.lines()
.map(str::trim)
.filter(|l| !l.starts_with(';'))
.filter_map(|l| l.split_once("=pfGamepad,"))
.flat_map(|(_, ids)| {
ids.split(',')
.filter_map(|l| l.split_once('='))
.filter(|(_, rhs)| rhs.trim_start().starts_with("pfGamepad"))
.flat_map(|(_, rhs)| {
// `pfGamepad[Suffix], <hwid>[, <hwid>…]` — drop the section name, keep the ids.
// `AddReg=pfGamepadXbox_HW_AddReg` reaches here too and contributes nothing,
// because it has no comma.
rhs.split(',')
.skip(1)
.map(|id| id.trim().to_ascii_lowercase())
.collect::<Vec<_>>()
})
@@ -1018,7 +1031,15 @@ mod drain_tests {
WinDsIdentity::dualsense_edge().hwid,
super::super::dualshock4_windows::DS4_HWID,
super::super::steam_deck_windows::DECK_HWID,
] {
]
.into_iter()
// Every Xbox identity, not just the first — a new one added to the table without its INF
// model line is exactly the "pad exists, never starts, never answers a proof" failure.
.chain(
super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| i.hwid),
) {
let want = hwid.to_ascii_lowercase();
let rooted = format!("root\\{want}");
assert!(
@@ -1032,6 +1053,81 @@ mod drain_tests {
}
}
/// EVERY Xbox identity must install its OWN section, and the PlayStation/Deck identities must
/// not install that one.
///
/// `pfGamepadXbox` attaches Microsoft's `xinputhid` as an upper filter and sets
/// `DevicePropertyFlags=1` (`BusDevice`), which is what makes Windows promote our Xbox pad —
/// it mints the `IG_00` token, registers an XUSB interface, and lets classic XInput and rumble
/// through. Applied to a DualSense, DualShock 4, Edge or Steam Deck it would hand a
/// PlayStation pad to Microsoft's **Xbox** translator, which claims the HID collection
/// exclusively and would take a working pad away from Steam and SDL.
///
/// Merging the two sections back together is a one-line edit that looks like tidying and is
/// not, so assert the split rather than trusting a comment to survive. Both directions matter,
/// and so does the count: a new Xbox identity whose model line was pasted from a PlayStation
/// one installs `pfGamepad`, enumerates perfectly, and is simply never promoted — a silent
/// half-failure that reads on glass as "XInput doesn't see it", the original field symptom.
#[test]
fn only_the_xbox_identity_installs_the_xinputhid_section() {
let inx = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
);
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
let xbox: Vec<String> = super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| i.hwid.to_ascii_lowercase())
.collect();
let mut seen: Vec<&str> = Vec::new();
for line in inf.lines().map(str::trim).filter(|l| !l.starts_with(';')) {
let Some((_, rhs)) = line.split_once('=') else {
continue;
};
let rhs = rhs.trim_start();
let Some((section, ids)) = rhs.split_once(',') else {
continue;
};
if !section.starts_with("pfGamepad") {
continue;
}
let ids: Vec<String> = ids
.split(',')
.map(|i| i.trim().to_ascii_lowercase())
.collect();
// `contains`, not `==`: the model lines carry both the bare id and its `root\` twin.
let matched: Vec<&str> = xbox
.iter()
.filter(|x| ids.iter().any(|i| i.contains(x.as_str())))
.map(String::as_str)
.collect();
if matched.is_empty() {
assert_eq!(
section, "pfGamepad",
"a non-Xbox model line ({ids:?}) installs {section:?}; if that section carries \
the xinputhid filter, this pad is about to be handed to Microsoft's Xbox \
translator"
);
} else {
seen.extend(matched);
assert_ne!(
section, "pfGamepad",
"an Xbox model line ({ids:?}) installs the SHARED section, so either the \
xinputhid filter would be attached to every PlayStation and Deck pad too, or \
this Xbox pad silently never gets promoted"
);
}
}
for want in &xbox {
assert!(
seen.contains(&want.as_str()),
"no [Models] line mentions {want:?} — either the identity has no INF line at all, \
or the parse went vacuous; fix that rather than deleting the assert"
);
}
}
/// The driver reads its HID identity back off the same hardware id — that mapping is what
/// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled
/// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes
@@ -1071,7 +1167,7 @@ mod drain_tests {
.collect();
assert_eq!(
entries.len(),
4,
7,
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
vacuous; fix the parse rather than deleting the assert"
);
@@ -1098,7 +1194,16 @@ mod drain_tests {
super::super::steam_deck_windows::DECK_HWID,
pf_driver_proto::gamepad::DEVTYPE_STEAMDECK,
),
] {
]
.into_iter()
// All three Xbox identities: they share a report descriptor, so a hwid→devtype slip does
// NOT show up as a mangled report the way the Deck's did — it shows up as the wrong PID and
// the wrong product string, i.e. an Elite that Steam maps as a Series X|S pad.
.chain(
super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| (i.hwid, i.devtype)),
) {
let want = hwid.to_ascii_lowercase();
let got = entries.iter().find(|(id, _)| *id == want);
assert_eq!(
@@ -232,7 +232,8 @@ impl PadProto for Ds4WinProto {
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
let fb = pad.service();
PadFeedback {
rumble: fb.rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: fb
.led
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
@@ -53,7 +53,8 @@
use super::channel_proof;
/// Re-exported so a pad backend needs only one `use` to wire up its channel.
pub(super) use super::channel_proof::ProofTransport;
use anyhow::{anyhow, bail, Context, Result};
use crate::pad_slots::PadCreateFault;
use anyhow::{anyhow, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
@@ -67,16 +68,17 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
CloseHandle, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
@@ -171,6 +173,16 @@ impl Shm {
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
///
/// ⚠️ That squat check only ever sees the collisions we are ALLOWED to see. `CreateFileMappingW`
/// opens a pre-existing object with full access, so a caller the incumbent's DACL excludes is
/// refused with `ERROR_ACCESS_DENIED` and never reaches the `ERROR_ALREADY_EXISTS` branch at
/// all — and that is the collision the field actually produces, because this SDDL grants SYSTEM
/// and LocalService only, while the host service runs as LocalSystem and a hand-run devtest
/// runs as an elevated Administrator. So the "another punktfunk-host instance is serving this
/// pad index" diagnosis below was unreachable for the one pairing that happens: on `.173`
/// (2026-08-09) it surfaced as a bare `Zugriff verweigert (0x80070005)` under a line telling the
/// operator to reinstall the drivers. [`classify_named_create_failure`] is what restores it.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
// allocation it owns) lives to the end of this fn, so it outlives every create below.
@@ -183,8 +195,10 @@ impl Shm {
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
let shm = match Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) {
Ok(shm) => shm,
Err(e) => return Err(classify_named_create_failure(name, e)),
};
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
@@ -192,11 +206,16 @@ impl Shm {
}
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
}
bail!(
// Reached only when we COULD open the incumbent (same account — two hosts both as SYSTEM,
// or a LocalService squatter). The cross-account case exits through
// `classify_named_create_failure` above; both carry the same fault, because to everything
// downstream they are the same event: this index is taken.
Err(anyhow!(
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
punktfunk-host instance is serving this pad index, or a local service is squatting the \
name (gamepad DoS attempt?)"
);
)
.context(PadCreateFault::IndexOwnedElsewhere))
}
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
@@ -250,6 +269,76 @@ impl Drop for Shm {
}
}
/// Turn a failed NAMED-section create into an error that names the cause, because
/// `CreateFileMappingW` collapses two OPPOSITE situations into one `ERROR_ACCESS_DENIED`
/// (`0x80070005`, and on a German box the entirely unsearchable "Zugriff verweigert" the field
/// report carried):
///
/// * **the name is TAKEN, by someone whose object we may not open.** Creating over an existing name
/// is really an open, and an open is access-checked against the incumbent's DACL. The mailbox
/// SDDL grants SYSTEM + LocalService only, so the exact pairing that occurs on a dev box — the
/// LocalSystem host service holding pad 0 for a live session while an operator runs
/// `punktfunk-host.exe dualsense-windows-test` from an elevated Administrator console — is
/// refused here rather than reported as the squat it is.
/// * **the name is FREE and we may not create it.** `Global\` names need `SeCreateGlobalPrivilege`,
/// which SYSTEM and services hold and an ordinary (even elevated) user token does not.
///
/// `OpenFileMappingW` separates them, because the object-manager lookup happens BEFORE the access
/// check: an absent name is `ERROR_FILE_NOT_FOUND`, a present one we are not in the DACL of is
/// `ERROR_ACCESS_DENIED`. Everything else keeps the original wording.
///
/// The contended case additionally carries a [`PadCreateFault`], which is what stops the pad
/// manager's failure line from telling the operator to reinstall a driver that is working
/// perfectly (see [`crate::pad_slots::PadCreateFault`]).
fn classify_named_create_failure(name: &HSTRING, e: anyhow::Error) -> anyhow::Error {
let denied = e
.downcast_ref::<windows::core::Error>()
.is_some_and(|w| w.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0));
if !denied {
return e.context(format!("create gamepad bootstrap mailbox {name}"));
}
if named_section_exists(name) {
return e
.context(PadCreateFault::IndexOwnedElsewhere)
.context(format!(
"bootstrap mailbox {name} exists and belongs to a process this one may not open — a \
live session's pad, held by the LocalSystem host service (its mailboxes grant SYSTEM \
+ LocalService only, so an Administrator console sees ACCESS_DENIED, not \
ALREADY_EXISTS). Nothing is wrong with the drivers"
));
}
e.context(format!(
"create gamepad bootstrap mailbox {name}: access denied although the name is FREE — this \
process may not create Global\\ objects at all (that needs SeCreateGlobalPrivilege, which \
SYSTEM and services hold and a user token does not)"
))
}
/// Whether a section with this name exists right now, as seen from THIS process — the
/// disambiguation [`classify_named_create_failure`] runs on. `true` also when the object is there
/// but closed to us, which is the case that matters: ACCESS_DENIED from an OPEN means the name
/// resolved and only the access check failed.
///
/// Deliberately not a security decision — a hostile squatter can make this say either thing. It
/// only ever chooses which sentence to print.
fn named_section_exists(name: &HSTRING) -> bool {
// SAFETY: `name` is a live NUL-terminated UTF-16 string for the duration of the call. Ask for
// the least access there is (`FILE_MAP_READ`): the handle is closed immediately and never
// mapped — we want the lookup's verdict, not the object.
let opened = unsafe { OpenFileMappingW(FILE_MAP_READ.0, false, PCWSTR(name.as_ptr())) };
match opened {
Ok(h) => {
// SAFETY: `h` is the handle just opened here and referenced nowhere else.
unsafe {
let _ = CloseHandle(h);
}
true
}
// ERROR_FILE_NOT_FOUND (and anything else) reads as absent; ACCESS_DENIED is presence.
Err(e) => e.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0),
}
}
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
@@ -296,6 +296,13 @@ impl GamepadManager {
}
}
/// How many virtual pads this manager has actually BUILT — the bring-up harness's
/// "did the create happen?" check; see [`crate::uhid_manager::UhidManager::live_pads`] for why
/// only a harness should ask.
pub fn live_pads(&self) -> usize {
self.slots.live()
}
fn ensure(&mut self, idx: usize) {
if self.slots.ensure(idx, XusbWinPad::open) {
tracing::info!(
@@ -356,7 +363,12 @@ impl GamepadManager {
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
///
/// The two trigger levels `send` also takes are always zero here and always will be: the XUSB
/// `SET_STATE` packet this backend parses carries `rumble_large`/`rumble_small` and nothing
/// else, mirroring `XINPUT_VIBRATION`'s two members. Impulse-trigger rumble is only reachable
/// through the HID-visible Xbox identity (WGI / GameInput), never through the XUSB companion.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the XUSB devnode would outlive the controller.
let swept = self.slots.reap();
@@ -369,7 +381,7 @@ impl GamepadManager {
self.last_active[i] = Instant::now();
if self.last_rumble[i] != (large, small) {
self.last_rumble[i] = (large, small);
send(i as u16, large as u16 * 257, small as u16 * 257);
send(i as u16, large as u16 * 257, small as u16 * 257, 0, 0);
}
} else if self.last_rumble[i] != (0, 0)
&& crate::uhid_manager::rumble_idle_timeout()
@@ -386,7 +398,7 @@ impl GamepadManager {
"rumble: stale residual (game stopped driving the pad) — forcing off"
);
self.last_rumble[i] = (0, 0);
send(i as u16, 0, 0);
send(i as u16, 0, 0, 0, 0);
}
}
}
@@ -231,7 +231,8 @@ impl PadProto for DeckWinProto {
// presence is the rumble-plane activity signal, even at an unchanged level.
let (rumble, resync) = pad.service();
PadFeedback {
rumble,
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
hidout: Vec::new(),
rumble_drove: Some(rumble.is_some()),
resync,
@@ -0,0 +1,463 @@
//! Virtual Xbox pads on Windows via the UMDF HID minidriver — Xbox Wireless (device-type 4),
//! Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to
//! [`super::gamepad_windows`]'s XUSB companion.
//!
//! **Why this exists.** `pf-xusb` registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID
//! collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and WGI/GameInput cannot see
//! the pad at all — only classic `XInputGetState` via xinput1_4's interface walk ever does. A field
//! report (2026-08-09) spent two weeks on a dead controller for exactly that reason; switching the
//! client to DualSense — a real HID pad through this very driver — fixed it in seconds. This
//! backend gives the Xbox pad the same footing, reusing the driver, sealed channel, INF, signing
//! and install path the PlayStation pads already ship on.
//!
//! Transport is identical to the PS/Deck pads: a `SwDeviceCreate` devnode plus the sealed
//! shared-memory channel, with the identity's `device_type` stamped before the magic so the driver
//! resolves it before hidclass asks for descriptors. The codec is
//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte —
//! **one descriptor, all three identities** (see `WinXboxIdentity` below).
//!
//! ⚠️ **Every synthesized USB identity here is a BLUETOOTH Xbox pad on purpose.** The wired ids the
//! rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class
//! XUSB/GIP devices that expose no HID interface on real hardware — a HID child claiming one is a
//! device that has never existed, and Windows' inbox promotion would have nothing to match. The
//! Bluetooth ids (`0B13` / `02FD` / `0B22`) are the Xbox pads that genuinely ARE HID.
//!
//! ⚠️ **No rich plane.** An Xbox pad has no touchpad, no lightbar, no adaptive triggers and no
//! IMU in its HID contract, so `apply_rich` / `clear_rich` / `neutralize_gyro` are deliberately
//! no-ops — same shape as the Linux xpad backend. Motion sent toward this backend is decoded and
//! dropped, which is what `GamepadPref::motion_reaches` already tells clients.
use super::dualsense_windows::{
create_swdevice, publish_input, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO,
OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::gamepad_raii::PadChannel;
use super::xbox_proto::{neutral_xbox_report, serialize_xbox_state, XboxState, XBOX_REPORT_LEN};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::RichInput;
use std::time::Duration;
/// One of the Xbox identities this backend can present. Mirrors `WinDsIdentity`
/// (`super::dualsense_windows`) for the PlayStation family: the whole transport (section layout,
/// report codec, output parse, INF install section) is shared, and only the PnP identity plus the
/// `device_type` stamp differ.
///
/// ⭐ **All three share ONE report descriptor in the driver** — `XBOX_RDESC`. In HID terms they are
/// the same pad: same stick pairs, same trigger pair, same hat, same 15 buttons, same rumble output
/// report. A descriptor is the report SHAPE; the identity is what SDL/Steam/Windows key their stock
/// mappings off, and that travels in the VID/PID below. See the `XBOX_RDESC` provenance block in
/// `packaging/windows/drivers/pf-gamepad/src/lib.rs` for why inventing two more hand-written
/// descriptors would be a net loss.
pub(super) struct WinXboxIdentity {
/// `device_type` stamped into the section — the driver picks its VID/PID and product string
/// off it, before hidclass asks anything.
pub devtype: u8,
/// PnP instance-id prefix — distinct namespaces per identity, so two Xbox models never reuse
/// the same devnode shell.
pub instance_prefix: &'static str,
/// The INF-matched hardware id. Must be one `pf_gamepad.inx` declares, on a model line that
/// installs `pfGamepadXbox` — a package rename must never touch it
/// (`dualsense_windows::tests::hwid_matches_inf` and
/// `only_the_xbox_identity_installs_the_xinputhid_section` enforce both halves).
pub hwid: &'static str,
/// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID
/// child ids (`HID\VID_045E&PID_xxxx`) — the identity SDL/RawInput/WGI read, and the one
/// Windows' own Xbox INFs match when they decide whether to promote a HID gamepad.
pub usb_vid_pid: &'static str,
/// Device Manager description.
pub description: &'static str,
}
impl WinXboxIdentity {
/// Xbox Wireless Controller (Series X|S) over Bluetooth, `045E:0B13` — the default.
///
/// ⭐ Its PID is on Microsoft's `xinputhid.inf` allow-list twice (measured on `.173`,
/// 2026-08-09). That is not what promotes OUR pad — a software devnode matches no allow-list
/// entry, so `pfGamepadXbox`'s `AddReg` writes the two registry values those sections would
/// have written — but it is why this identity, not one of the two below, stays the default.
pub(super) const fn wireless() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX,
instance_prefix: "pf_xbox",
hwid: "pf_xboxwireless",
usb_vid_pid: "VID_045E&PID_0B13",
description: "Punktfunk Virtual Xbox Wireless Controller",
}
}
/// Xbox One S controller over Bluetooth, `045E:02FD`.
///
/// ⚠️ `02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has **no**
/// stage-2 `HID\…&IG_00` model line, unlike `0B13`. Promotion here rides entirely on our own
/// `AddReg`, so it should behave identically; but if a servicing update ever makes promotion
/// depend on Microsoft's list again, this is the identity that loses it first. UNVERIFIED on
/// glass.
pub(super) const fn one_s() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ONE_S,
instance_prefix: "pf_xbox_ones",
hwid: "pf_xboxones",
usb_vid_pid: "VID_045E&PID_02FD",
description: "Punktfunk Virtual Xbox One S Controller",
}
}
/// Xbox Elite Wireless Controller Series 2, `045E:0B22` — the pad
/// `tools/hid-descriptor-dump` captured on `.173`, so the one identity here whose real hardware
/// has been measured directly.
///
/// ⚠️ **No paddles yet.** `BTN_PADDLE1..4` still fold/drop for this identity exactly as for the
/// other two; the Elite is merely the first Xbox pad that *could* carry them natively. Adding
/// them is blocked on a measurement, not on effort — once `xinputhid` promotes the pad it
/// claims the HID collection exclusively, so extra buttons declared in the descriptor may be
/// invisible to every consumer anyway (handoff §3.6). Measure before building.
pub(super) const fn elite() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ELITE,
instance_prefix: "pf_xbox_elite",
hwid: "pf_xboxelite",
usb_vid_pid: "VID_045E&PID_0B22",
description: "Punktfunk Virtual Xbox Elite Wireless Controller Series 2",
}
}
}
/// Every Xbox identity this backend can build, in wire order (`device_type` 4, 5, 6).
///
/// A table rather than three loose constructors because the INF tests sweep it: each entry's
/// `hwid` must appear in `pf_gamepad.inx` **on a `pfGamepadXbox` model line**, and every non-Xbox
/// model line must NOT be on that section. A new identity added here without its INF line fails
/// those tests instead of failing on a user's box.
///
/// `static`, not `const`, on purpose: [`XboxWinProto`] holds a `&'static WinXboxIdentity`, and a
/// `const` is inlined at each use site — `&CONST[i]` would depend on rvalue static promotion to
/// come out `'static` at all.
pub(super) static XBOX_IDENTITIES: [WinXboxIdentity; 3] = [
WinXboxIdentity::wireless(),
WinXboxIdentity::one_s(),
WinXboxIdentity::elite(),
];
/// A single virtual Xbox pad: the `SwDeviceCreate`'d `pf_xbox_<index>` devnode plus the sealed
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
pub struct XboxWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
/// This pad's v2.3 input-seqlock generation — see `publish_input`.
input_gen: u32,
/// Output-plane cursor: ring drain (v2.1+ driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
impl XboxWinPad {
/// Create the sealed channel, stamp `device_type` FIRST + the pad index + the neutral report +
/// the magic LAST, then spawn the devnode under `id`'s Bluetooth Xbox identity.
fn open(index: u8, id: &WinXboxIdentity) -> Result<XboxWinPad> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base();
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. The
// device_type MUST land before the magic — the driver reads it the moment it attaches, and
// a late stamp enumerates the pad with the default DualSense identity (the Deck's bug).
unsafe {
*base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability `2` = "this host drains the v2.2 long ring" (see the DualSense open).
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; XBOX_REPORT_LEN],
neutral_xbox_report(),
);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("{}_{index}", id.instance_prefix);
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
instance: &inst,
// Per-FAMILY tag, like "PFDS" for the whole PlayStation family: the three Xbox
// identities share it because only one of them can ever hold a given pad index (the
// router keeps a live device in its owning manager), so their containers never collide.
container_tag: 0x5046_5842, // "PFXB"
container_index: index,
hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid,
// A Bluetooth pad is not a USB composite device, so there is no interface number to
// synthesize — unlike the Deck, whose Steam promotion gate needs `&MI_02`.
usb_mi: None,
description: id.description,
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
// Bounded eager delivery — the driver must read the `device_type` stamp before hidclass
// asks it for descriptors, or the pad enumerates as a DualSense.
channel.deliver_eager(Duration::from_millis(1500));
Ok(XboxWinPad {
_sw,
channel,
attach: super::gamepad_raii::DriverAttach::new(
id.hwid,
"pf_gamepad.inf", // one driver package serves every identity
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name,
instance_id,
),
input_gen: 0,
drain: OutputDrain::new(),
})
}
/// Serialize `st` and publish it to the section's input slot under the v2.3 seqlock, so a
/// driver read can never land mid-copy.
fn write_state(&mut self, st: &XboxState) {
let r = serialize_xbox_state(st);
// SAFETY: `data_base()` points at a live SHM_SIZE-byte section and `r` is the codec's
// fixed-size report.
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
}
/// Poll the section's output slot for a game's rumble, tick the sealed-channel delivery and
/// feed the driver-attach health watcher.
fn service(&mut self) -> (Option<(u16, u16, u16, u16)>, bool) {
self.channel.pump();
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
let mut rumble = None;
let base = self.channel.data_base();
let resync = self.drain.drain(base, |bytes| {
if let Some(r) = parse_xbox_output(bytes) {
rumble = Some(r); // oldest → newest: the last rumble-carrying report wins
}
});
(rumble, resync)
}
}
/// Parse an Xbox output report into `(low, high, left_trigger, right_trigger)` motor levels on the
/// wire's 0..65535 scale.
///
/// The Bluetooth Xbox rumble report is id `0x03`: `[id, enable, left_trigger, right_trigger,
/// left, right, duration, delay, loop]`, with magnitudes on a **0..100** scale (not 0..255 — a
/// detail that silently costs 60 % of the rumble range if you assume otherwise). The `enable`
/// mask picks which motors the values apply to; bit 2 is the left (low-frequency) motor and bit 3
/// the right (high-frequency) one, matching how the wire's `low`/`high` pair is used elsewhere.
///
/// Bytes 2/3 are the two impulse-trigger motors, which ride the 0xCA plane's v3 tail
/// (design/trigger-rumble-plane.md). This pad is the only backend in the tree that can ever source
/// them: XUSB's `SET_STATE` carries `rumble_large`/`rumble_small` and evdev's `FF_RUMBLE` carries
/// strong/weak, so neither packet has a field to lose. They are scaled by the same 0..100 closure
/// as the handles rather than a copy of it — assuming 0..255 here would read a full-scale `100` as
/// ~39 %, which on a real pad reads as "trigger rumble works but is weirdly weak", the hardest
/// class of bug to attribute.
///
/// ⚠️ Never seen a real report — this shape is from the documented protocol, not a capture.
///
/// ⚠️ **The two TRIGGER `enable` bits are conjecture, not measurement.** Bits 2/3 = left/right
/// handle are known; bit 0 = left trigger and bit 1 = right trigger are inferred from the report's
/// field order (triggers first, handles second) and from nothing else. A live capture (design WP0)
/// settles it. Getting it wrong yields "the triggers buzz when the game asked for the handles",
/// so nothing downstream may treat this assignment as established — and the tests below are
/// deliberately written with mask vectors that hold whichever bits turn out to be right.
fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16, u16, u16)> {
// The driver republishes output reports report-id-prefixed, like the PS backends.
if bytes.len() < 6 || bytes[0] != 0x03 {
return None;
}
let enable = bytes[1];
let scale = |v: u8| -> u16 { (v.min(100) as u32 * 65535 / 100) as u16 };
let gated = |bit: u8, v: u8| if enable & bit != 0 { scale(v) } else { 0 };
Some((
gated(0x04, bytes[4]),
gated(0x08, bytes[5]),
// UNVERIFIED bit assignment — see the second ⚠️ above before trusting either of these.
gated(0x01, bytes[2]),
gated(0x02, bytes[3]),
))
}
/// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table,
/// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads.
///
/// The identity is a field rather than three separate proto types because nothing else about the
/// backend varies: same codec, same output parse, same rumble plane. `Default` is the Xbox Wireless
/// Controller, so `XboxWindowsManager::new()` keeps its previous meaning exactly.
pub struct XboxWinProto {
identity: &'static WinXboxIdentity,
}
impl Default for XboxWinProto {
fn default() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[0],
}
}
}
impl XboxWinProto {
/// The Xbox One S identity (`045E:02FD`) — `UhidManager::with_backend(XboxWinProto::one_s())`.
pub fn one_s() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[1],
}
}
/// The Xbox Elite Series 2 identity (`045E:0B22`).
pub fn elite() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[2],
}
}
}
impl PadProto for XboxWinProto {
type Pad = XboxWinPad;
type State = XboxState;
const LABEL: &'static str = "Xbox Wireless/Windows";
const DEVICE: &'static str = "Xbox Wireless Controller";
const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<XboxWinPad> {
let p = XboxWinPad::open(idx, self.identity)?;
tracing::info!(
index = idx,
identity = self.identity.usb_vid_pid,
description = self.identity.description,
"virtual Xbox pad created (Windows UMDF HID)"
);
Ok(p)
}
fn neutral(&self) -> XboxState {
XboxState::default()
}
/// Every control this pad has arrives in the frame, so a frame fully replaces the state —
/// there are no rich-plane fields to preserve (contrast the Deck's trackpads/motion).
fn merge_frame(&self, _prev: &XboxState, f: &punktfunk_core::input::GamepadFrame) -> XboxState {
XboxState::from_gamepad(
f.buttons,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
)
}
/// No rich plane on an Xbox pad — see the module note.
fn apply_rich(&self, _st: &mut XboxState, _rich: RichInput) {}
/// No motion plane, so there is never stale gyro to neutralize.
fn neutralize_gyro(&self, _st: &mut XboxState) -> bool {
false
}
fn clear_rich(&self, _st: &mut XboxState) {}
fn write_state(&self, pad: &mut XboxWinPad, st: &XboxState) {
pad.write_state(st);
}
/// Motor rumble on the universal 0xCA plane. No rich host→client feedback (no lightbar or
/// adaptive triggers), so `hidout` stays empty — parity with the Linux xpad backend.
fn service(&self, pad: &mut XboxWinPad, _idx: u8) -> PadFeedback {
let (rumble, resync) = pad.service();
PadFeedback {
rumble,
hidout: Vec::new(),
rumble_drove: Some(rumble.is_some()),
resync,
}
}
}
/// All virtual Xbox pads of a Windows session, with the same method surface (via the shared
/// [`UhidManager`]) as the other Windows pad managers.
pub type XboxWindowsManager = UhidManager<XboxWinProto>;
#[cfg(test)]
mod tests {
use super::*;
// Every `enable` vector in this module is chosen so its assertion holds whichever bits the
// TRIGGER actuators turn out to use — the assignment is conjecture (see the ⚠️ on
// `parse_xbox_output`) and a test asserting it would pin a guess as if it were the contract.
// The safe masks: `0xFF` enables everything that exists, `0x00` enables nothing, and
// `0x0C` / `0xF3` split the two MEASURED handle bits from every other bit. No vector below
// names a trigger enable bit.
/// Both handle motors at full scale, with the triggers idle.
#[test]
fn rumble_scales_off_the_zero_to_hundred_protocol_range() {
let full = [0x03, 0x0F, 0, 0, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&full), Some((65535, 65535, 0, 0)));
// Half on the left motor only.
let half = [0x03, 0x04, 0, 0, 50, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&half), Some((32767, 0, 0, 0)));
}
/// The trigger magnitudes are on the SAME 0..100 protocol range as the handles, so a
/// full-scale `100` is `65535` — not `25700`, which is what reading them as 0..255 would give
/// and which reads on a real pad as "trigger rumble works but is weirdly weak". Named for the
/// regression so it cannot be "fixed" the wrong way later.
#[test]
fn trigger_magnitudes_are_not_a_zero_to_255_range() {
let full = [0x03, 0xFF, 100, 100, 0, 0, 0, 0, 1];
assert_eq!(parse_xbox_output(&full), Some((0, 0, 65535, 65535)));
let half = [0x03, 0xFF, 50, 25, 0, 0, 0, 0, 1];
assert_eq!(parse_xbox_output(&half), Some((0, 0, 32767, 16383)));
}
/// A value above the protocol's 0..100 range must clamp, not wrap past full scale — on all
/// four actuators, since the triggers reuse the handles' scale closure.
#[test]
fn out_of_range_magnitudes_clamp() {
let over = [0x03, 0xFF, 255, 255, 255, 255, 0, 0, 1];
assert_eq!(parse_xbox_output(&over), Some((65535, 65535, 65535, 65535)));
}
/// The enable mask gates each motor independently — a report that enables nothing is a stop.
#[test]
fn the_enable_mask_gates_each_motor() {
let none = [0x03, 0x00, 100, 100, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&none), Some((0, 0, 0, 0)));
let right_only = [0x03, 0x08, 0, 0, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&right_only), Some((0, 65535, 0, 0)));
}
/// The case the whole trigger-rumble plane exists for, and the one nothing else in the tree
/// can produce: a racing title driving the impulse triggers hard while the handles stay
/// silent. `0x0C` is the two measured handle bits; `0xF3` is every OTHER bit, so this pair
/// isolates the handles from the triggers without claiming which bits the triggers are.
#[test]
fn a_trigger_only_report_leaves_the_handles_silent() {
let triggers_only = [0x03, 0xF3, 100, 40, 100, 100, 0, 0, 1];
assert_eq!(
parse_xbox_output(&triggers_only),
Some((0, 0, 65535, 26214))
);
let handles_only = [0x03, 0x0C, 100, 100, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&handles_only), Some((65535, 65535, 0, 0)));
}
/// Anything that is not the rumble report — or is truncated — is ignored rather than parsed
/// out of whatever bytes happen to be there.
#[test]
fn non_rumble_reports_are_ignored() {
assert_eq!(parse_xbox_output(&[0x01, 0x0F, 0, 0, 100, 100]), None);
assert_eq!(parse_xbox_output(&[0x03, 0x0F, 0]), None);
assert_eq!(parse_xbox_output(&[]), None);
}
}
+31 -3
View File
@@ -389,13 +389,22 @@ pub mod mouse_windows;
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
/// retry.
#[cfg(any(target_os = "linux", target_os = "windows"))]
///
/// Built on every target, not just the two that have pad backends: it is pure timing arithmetic
/// over `std::time`, and gating it meant its tests — and [`pad_slots`]', which need it — could not
/// run on a developer machine at all. See [`pad_slots`].
#[path = "inject/pad_gate.rs"]
pub mod pad_gate;
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
/// manager used to copy-paste (G12).
#[cfg(any(target_os = "linux", target_os = "windows"))]
///
/// Built on every target for the same reason as [`pad_gate`]: nothing in it touches an OS pad API
/// (the backend supplies the pad type and the `open` closure), so the platform gate bought
/// nothing and cost the ability to run the table's tests off a host box. That matters most for
/// [`pad_slots::PadCreateFault`], whose whole job is to describe a `cfg(windows)` failure that
/// only a Windows box can produce — the classification either has tests that run everywhere, or
/// it has none that anyone runs.
#[path = "inject/pad_slots.rs"]
pub mod pad_slots;
/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports
@@ -474,6 +483,25 @@ pub mod uhid_abi;
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/uhid_manager.rs"]
pub mod uhid_manager;
/// Transport-independent Xbox HID codec — the report the `pf-gamepad` UMDF driver serves under
/// device-types 4, 5 and 6 (Xbox Wireless / One S / Elite Series 2, which share one descriptor and
/// differ only in VID/PID), giving an Xbox pad the HID footing `pf-xusb` never had
/// (Steam / WGI / GameInput / DirectInput cannot see an XUSB-interface-only device).
///
/// Deliberately NOT cfg-gated to linux/windows like its siblings: it is pure byte-packing with no
/// OS surface, so its layout tests compile and run on any host — including the macOS dev machines
/// where the Windows backends cannot be built at all. That is the only automated check this codec
/// has until a Windows box is reachable.
#[path = "inject/proto/xbox_proto.rs"]
pub mod xbox_proto;
/// Windows: virtual Xbox pads via the same UMDF minidriver — Xbox Wireless (device-type 4),
/// Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to
/// [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / DirectInput cannot
/// enumerate at all because it registers only the XUSB device interface. The three identities
/// share one report descriptor and differ only in VID/PID, product string and INF model line.
#[cfg(target_os = "windows")]
#[path = "inject/windows/xbox_windows.rs"]
pub mod xbox_windows;
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub mod gamepad {
@@ -484,7 +512,7 @@ pub mod gamepad {
GamepadManager
}
pub fn handle(&mut self, _ev: &punktfunk_core::input::GamepadEvent) {}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16, u16, u16)) {}
}
}
/// Linux: the "Punktfunk Pen" uinput virtual tablet (design/pen-tablet-input.md §5) — the
+4 -3
View File
@@ -87,9 +87,10 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, resolve_gamescope_route, restore_managed_session,
restore_takeover_now, restore_takeover_on_startup, start_restore_worker,
wants_dedicated_game_session, GamescopeRoute,
apply_input_env, managed_session_available, preflight_takeover_privilege,
resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
GamescopeRoute,
};
#[cfg(target_os = "linux")]
pub use routing::{
File diff suppressed because it is too large Load Diff
@@ -524,6 +524,22 @@ fn parse_patch_level(banner: &str) -> u32 {
.unwrap_or(0)
}
/// The upstream `X.Y.Z` a specific gamescope binary reports, or `None` if it cannot be run/parsed.
///
/// Split from [`check_gamescope_version`] (which only ever probes the RESOLVED binary) because the
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
parse_version(&text)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
+35 -2
View File
@@ -1093,6 +1093,19 @@ fn capability_denial_hint() -> String {
let permitted = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| permitted_caps_from_status(&status));
capability_denial_hint_for(permitted)
}
/// The message half of [`capability_denial_hint`], split from the `/proc/self/status` read so it is
/// testable against a *given* mask instead of whatever the test process happens to hold.
///
/// That distinction is not academic: the first version of this asserted the empty case by calling
/// the real thing and trusting the test process to be uncapped. That holds on a dev box and is
/// false in CI, where the runner container is root with a full permitted set
/// (`CapPrm=0x000001ffffffffff`) — so the hint fired, correctly, and the test failed on a machine
/// where nothing was wrong. A check whose answer depends on the ambient environment tests the
/// environment, not the code.
fn capability_denial_hint_for(permitted: Option<u64>) -> String {
match permitted {
Some(caps) if caps != 0 => format!(
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
@@ -1135,10 +1148,30 @@ mod capability_hint_tests {
/// A capability-free host must not append the hint — the message it decorates is also printed
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
/// send the reader chasing a setcap that was never there. The test process has no capabilities.
/// send the reader chasing a setcap that was never there.
///
/// Driven off an explicit mask rather than the test process's own: see
/// [`capability_denial_hint_for`] for why calling the real reader here fails in CI.
#[test]
fn silent_without_capabilities() {
assert_eq!(capability_denial_hint(), "");
assert_eq!(
capability_denial_hint_for(permitted_caps_from_status(CLEAN)),
""
);
// Absent or unparseable field: also silent, never a panic and never a spurious hint.
assert_eq!(capability_denial_hint_for(None), "");
}
/// ...and the case that matters actually speaks, naming the mask and the repair. Without this
/// the test above passes just as well against a function that returns `""` unconditionally.
#[test]
fn names_the_mask_and_the_repair_when_capped() {
let hint = capability_denial_hint_for(permitted_caps_from_status(CAPPED));
assert!(
hint.contains("0x0000000000800000"),
"names the mask: {hint}"
);
assert!(hint.contains("setcap -r"), "names the repair: {hint}");
}
}
@@ -371,6 +371,19 @@ pub fn restore_takeover_on_startup() {
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
/// Warn ONCE, at startup, when this box will need the managed gamescope takeover but its user is
/// not in the `punktfunk` group the packaged privilege helper gates on — the one takeover
/// prerequisite that fails silently mid-stream instead of at setup time. Gated so a box that will
/// never attempt a takeover stays quiet; see [`gamescope::preflight_takeover_privilege`] for the
/// exact conditions. Call once at `serve` startup, alongside [`restore_takeover_on_startup`].
#[cfg(target_os = "linux")]
pub fn preflight_takeover_privilege() {
gamescope::preflight_takeover_privilege();
}
#[cfg(not(target_os = "linux"))]
pub fn preflight_takeover_privilege() {}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
+1
View File
@@ -204,6 +204,7 @@ include = ["PunktfunkEndReason"]
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
"RUMBLE_V3_LEN" = "PUNKTFUNK_RUMBLE_V3_LEN"
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
+113 -3
View File
@@ -1186,7 +1186,10 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
/// impulse-trigger rumble is unreachable through THIS pad — evdev's `FF_RUMBLE` is two
/// magnitudes and has no third, so a uinput backend can never source it. The Windows HID Xbox
/// backend can, off its output report `0x03`; see
/// [`punktfunk_connection_next_rumble_cmd2`]). Useful for glyph-matching a
/// physical X-Box One/Series controller on the client.
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
@@ -1219,6 +1222,11 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
/// topology and four controller slots. Used by capture clients that own the physical Puck;
/// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10;
/// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity
/// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360
/// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box
/// classes (`DUALSENSEEDGE` is the pad with native back-button slots).
pub const PUNKTFUNK_GAMEPAD_XBOXELITE: u32 = 11;
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
@@ -1344,6 +1352,7 @@ const _: () = {
assert!(
PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32
);
assert!(PUNKTFUNK_GAMEPAD_XBOXELITE == GamepadPref::XboxElite.to_u8() as u32);
// Extended button bits mirror the wire `input::gamepad` constants.
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
@@ -2717,8 +2726,20 @@ pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
/// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
/// every close-drain stop was delivered — silence all actuators on it.
///
/// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime,
/// never both (they consume the same wire plane).
/// **Handle motors only.** A pad also carries two Xbox impulse-trigger levels, which this entry
/// point has no out-params for and never will —
/// [`punktfunk_connection_next_rumble_cmd2`] is the four-motor pull. Staying here is a supported
/// choice, not a deprecation: for a controller with no trigger motors — every pad but an Xbox
/// One/Series/Elite — the two views are identical, and where they differ, "the handles are silent"
/// is exactly the right instruction for the motors this API owns.
///
/// The one observable difference against a trigger-driving host: a rumble that moves only the
/// triggers still produces commands here, carrying `low == high == 0`. They are idempotent stops
/// for the handles; the engine's redundant-stop suppression cannot fold them away, because the
/// command is not silent — some motor on that pad is running.
///
/// An embedder uses EITHER this (or its `2` form) or `next_rumble`/`next_rumble2` for a
/// connection's lifetime, never both (they consume the same wire plane).
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
@@ -2769,6 +2790,95 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
})
}
/// [`punktfunk_connection_next_rumble_cmd`] with the two Xbox impulse-trigger motors: the same
/// command, all four of its levels. `*left_trigger` / `*right_trigger` are on the same
/// `0..=0xFFFF` scale as `low`/`high`, and a stop is all four at zero.
///
/// A NEW symbol rather than a wider signature on the old one, following the
/// `next_rumble` → `next_rumble2` precedent in this file: an exported entry point's parameter list
/// is part of the contract, and silently growing one breaks every out-of-tree embedder at once,
/// with a stack-corruption signature rather than a link error. Old callers keep the old symbol and
/// simply never see the trigger levels.
///
/// **Render the trigger levels only on a pad that actually has trigger motors, and drop them
/// otherwise** — do not fold them into the handles. Impulse-trigger content is continuous
/// (a racing title drives engine RPM and tyre slip into the triggers while the handles stay near
/// silent), so folding it produces a handle motor droning flat-out for the whole race at a level
/// the game never asked for. Query the hardware: SDL's
/// `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`, Apple's `GCDeviceHaptics.supportedLocalities`
/// (`GCHapticsLocalityLeftTrigger`/`…RightTrigger`). A pad without them is the common case and not
/// an error — do not log per command.
///
/// **Nothing has driven these levels non-zero end to end yet, and that is structural, not an
/// oversight.** Exactly one producer can ever source them — the Windows HID Xbox pad's output
/// report `0x03` — because classic XInput's `XINPUT_VIBRATION` has two members and evdev's
/// `FF_RUMBLE` has two, so no other host backend on any OS has the channel. That producer is
/// reachable only through GameInput/WGI, and an xinputhid-promoted Xbox pad is not enumerated by
/// GameInput at all (measured against a real Microsoft Elite, which is equally invisible there
/// while XInput reads it live). So this delivery path is deliberately built ahead of its producer:
/// the wire, the engine and this entry point are exercised only by synthetic levels.
///
/// Same threading, timeout and close semantics as
/// [`punktfunk_connection_next_rumble_cmd`]; the two share one wire plane and one policy engine,
/// so an embedder calls exactly one of them.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
c: *mut PunktfunkConnection,
pad: *mut u16,
low: *mut u16,
high: *mut u16,
left_trigger: *mut u16,
right_trigger: *mut u16,
backstop_ms: *mut u32,
timeout_ms: u32,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
match c
.inner
.next_rumble_command(std::time::Duration::from_millis(timeout_ms as u64))
{
Ok(cmd) => {
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
// checked before it is written; a non-null one is a caller-owned writable slot.
unsafe {
if !pad.is_null() {
*pad = cmd.pad;
}
if !low.is_null() {
*low = cmd.low;
}
if !high.is_null() {
*high = cmd.high;
}
if !left_trigger.is_null() {
*left_trigger = cmd.left_trigger;
}
if !right_trigger.is_null() {
*right_trigger = cmd.right_trigger;
}
if !backstop_ms.is_null() {
*backstop_ms = cmd.backstop_ms;
}
}
PunktfunkStatus::Ok
}
Err(e) => e.status(),
}
})
}
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
/// shared rumble policy engine instead of forking it (typically called at controller attach).
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
+6 -1
View File
@@ -1232,10 +1232,15 @@ impl NativeClient {
/// the engine emits the level on every wire update (renewals re-arm duration-parameterized
/// APIs), an explicit zero at lease expiry / legacy staleness / connection close, and
/// quirk-declared keepalives ([`NativeClient::set_rumble_quirks`]). Apply commands verbatim:
/// `(0, 0)` = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net
/// all-zero = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net
/// duration for APIs that take one. [`PunktfunkError::NoFrame`] on timeout;
/// [`PunktfunkError::Closed`] once the session ended AND every close-drain stop was delivered.
///
/// A command carries FOUR levels: the two handle motors plus the two Xbox impulse-trigger
/// motors ([`RumbleCommand`]). Render the trigger pair only on a pad that has trigger motors
/// (SDL: `has_rumble_triggers()`); dropping them otherwise is the correct degrade, and folding
/// them into a handle is specifically not — see [`RumbleCommand`] for why.
///
/// One puller thread, and one API: an embedder uses EITHER this or
/// `next_rumble`/`next_rumble_ttl` for a connection's lifetime, never both (both consume the
/// same wire plane; the raw queue keeps filling harmlessly while this API is used).
@@ -91,8 +91,24 @@ pub(super) async fn run(
let ttl = u.envelope.map(|e| e.ttl_ms);
// Both consumers are fed; an embedder drains exactly one of them
// (the legacy queue, or the policy engine's command API).
//
// Only the policy engine carries `u.left_trigger`/`u.right_trigger` (the
// v3 impulse-trigger tail). The legacy queue's tuple is the shape two
// frozen C entry points read through fixed out-params
// (`punktfunk_connection_next_rumble`/`_next_rumble2`), so it stays at the
// two handle levels forever: an out-of-tree embedder on those symbols must
// keep behaving exactly as it did. That is the §5 compatibility table's
// "new host, old client" cell, and it is now a per-API property rather
// than a per-client one — the same session can serve both.
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
rumble_feed.wire_update(
u.pad,
u.low,
u.high,
u.left_trigger,
u.right_trigger,
ttl,
);
}
}
}
+268 -60
View File
@@ -22,6 +22,14 @@
//! a per-pad mailbox and commands are generated on demand, so a stalled embedder wakes to ONE
//! current-level command instead of a backlog — and a stop can never be the update that an
//! overflowing queue drops.
//!
//! A pad carries FOUR motor levels ([`Levels`]): the two handles plus the two Xbox impulse-trigger
//! motors off the 0xCA v3 tail (`design/trigger-rumble-plane.md`). They deliberately share one
//! lease, one seq and one policy — they are a single statement of the pad's feedback state at one
//! instant, so the whole apparatus above (expiry, staleness, keepalives, close drain) governs the
//! trigger motors with no second timeline. Every liveness test is therefore against all four
//! levels, not the handles: a trigger-only rumble is the *normal* shape of impulse-trigger
//! content, and a two-field test would silence it on arrival.
use crate::input::MAX_PADS;
use std::sync::{Condvar, Mutex};
@@ -52,18 +60,41 @@ const BACKSTOP_LEGACY_MS: u32 = 2000;
/// header already has ~170 instances of, and one this has no reason to add to.
const MAX_LEASE_MS: u16 = 5_000;
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
/// stalls; platforms with explicit-stop APIs ignore it. Zero commands carry `backstop_ms == 0`.
/// One effective actuator command: four motor levels for one pad at one instant. All-zero means
/// stop now. `backstop_ms` is a safety-net duration for platform APIs that take one (SDL rumble,
/// Android one-shots): the engine emits explicit zeros at every policy stop, so the backstop only
/// matters if the embedder thread itself stalls; platforms with explicit-stop APIs ignore it. Zero
/// commands carry `backstop_ms == 0`.
///
/// `left_trigger`/`right_trigger` are the Xbox impulse-trigger motors off the 0xCA v3 tail
/// (`design/trigger-rumble-plane.md`), on the same `0..=0xFFFF` scale as `low`/`high`. A renderer
/// on a pad without trigger motors ignores them — that is the *normal* case, not an error, and the
/// engine deliberately does not fold them into the handles (folding a racing title's continuous
/// trigger stream onto a handle motor drones flat-out for the whole race; §8 of the design).
///
/// A pre-trigger embedder reading only `(low, high)` stays correct: the four levels are one
/// statement of the pad's state, so a trigger-only rumble reads as "handles silent", which is what
/// its actuator should do.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleCommand {
pub pad: u16,
pub low: u16,
pub high: u16,
pub left_trigger: u16,
pub right_trigger: u16,
pub backstop_ms: u32,
}
/// One pad's four motor levels, in wire order: `(low, high, left_trigger, right_trigger)`. The two
/// handle motors first so the pre-trigger `(low, high)` reading is a literal prefix of this one.
type Levels = (u16, u16, u16, u16);
/// The reserved "this actuator group is silent" value. Every liveness test in the engine is
/// against ALL FOUR levels: a rumble that drives only the impulse triggers — the normal shape of
/// racing-title content, where the handles stay at rest — must read as LIVE, or it would be
/// silenced on arrival by a two-field test that never saw its levels.
const SILENT: Levels = (0, 0, 0, 0);
/// A physical actuator's declared quirks — how a platform parameterizes the shared policy instead
/// of forking it. Defaults (all zero/false) describe a well-behaved actuator.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -96,7 +127,7 @@ pub struct ActuatorQuirks {
#[derive(Clone, Copy)]
struct PadState {
level: (u16, u16),
level: Levels,
/// v2 lease expiry — `None` for a zero level or a legacy pad.
deadline: Option<Instant>,
/// Last v2 TTL (drives the backstop); 0 ⇔ legacy.
@@ -106,23 +137,23 @@ struct PadState {
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
dirty: bool,
next_keepalive: Option<Instant>,
/// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is
/// silent. It replaces a free-running jitter phase because one field answers all three live
/// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop
/// redundant, and would the nudge synthesize the reserved stop.
last_emit: (u16, u16),
/// The exact value last handed to an embedder. [`SILENT`] ⇔ the engine believes this pad's
/// actuators are all silent. It replaces a free-running jitter phase because one field answers
/// all three live questions: would re-sending this be a no-op device write (the dedupe nudge),
/// is a stop redundant, and would the nudge synthesize the reserved stop.
last_emit: Levels,
quirks: ActuatorQuirks,
}
impl PadState {
const NEUTRAL: PadState = PadState {
level: (0, 0),
level: SILENT,
deadline: None,
ttl_ms: 0,
legacy_wire: None,
dirty: false,
next_keepalive: None,
last_emit: (0, 0),
last_emit: SILENT,
quirks: ActuatorQuirks {
keepalive_ms: 0,
min_pulse_ms: 0,
@@ -139,18 +170,22 @@ impl PadState {
b.max(self.quirks.min_pulse_ms as u32)
}
/// Zero the pad's level + timers and produce the stop command.
/// Zero the pad's levels + timers and produce the stop command — all four motors, so a policy
/// stop silences the impulse triggers on the same event as the handles (which is the whole
/// reason they share one lease and one seq).
fn silence(&mut self, pad: u16) -> RumbleCommand {
self.level = (0, 0);
self.level = SILENT;
self.deadline = None;
self.legacy_wire = None;
self.next_keepalive = None;
self.dirty = false;
self.last_emit = (0, 0);
self.last_emit = SILENT;
RumbleCommand {
pad,
low: 0,
high: 0,
left_trigger: 0,
right_trigger: 0,
backstop_ms: 0,
}
}
@@ -166,25 +201,39 @@ impl PadState {
/// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the
/// floor, on an actuator whose quirk declares 40.
///
/// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level
/// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`.
/// The nudge is refused when it would synthesize the reserved all-zero stop. **Re-derived for
/// four levels, not mechanically widened** — the old proof reasoned about exactly two fields.
/// `emit` is only ever reached with `level != SILENT` (every caller in [`RumbleEngine::poll`]
/// guards on it), the nudge touches `low` alone, and it changes `low` by ±1 in the LSB. So the
/// nudged tuple can equal [`SILENT`] only when the three untouched levels are already zero AND
/// `low ^ 1 == 0`, i.e. exactly level `(1, 0, 0, 0)` — the same single case as before, now
/// conditioned on `high`, `left_trigger` and `right_trigger` together instead of `high` alone.
/// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535)
/// and the pad never receives a stop the policy did not order.
///
/// The nudge stays on `low` even for a trigger-only level, where it lifts a resting handle
/// motor from 0 to 1. That is not new behaviour in kind — a `(0, high)` level has always been
/// nudged to `(1, high)` — and one part in 65535 is below any actuator's threshold. Moving it
/// to whichever level is non-zero would make the dedupe phase depend on which motors a
/// particular command happens to drive, which is exactly the free-running-phase failure
/// `last_emit` was introduced to remove.
fn emit(&mut self, pad: u16) -> RumbleCommand {
let (mut low, high) = self.level;
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
let (mut low, high, lt, rt) = self.level;
if self.quirks.dedup_jitter && self.level == self.last_emit {
let alt = low ^ 1;
low = if (alt, high) == (0, 0) {
low = if (alt, high, lt, rt) == SILENT {
low | 0b10
} else {
alt
};
}
self.last_emit = (low, high);
self.last_emit = (low, high, lt, rt);
RumbleCommand {
pad,
low,
high,
left_trigger: lt,
right_trigger: rt,
backstop_ms: self.backstop(),
}
}
@@ -210,18 +259,26 @@ impl RumbleEngine {
/// Fold one seq-gated wire update in. Every update dirties the pad (renewals re-emit so
/// platform duration timers re-arm); a v2 update replaces the lease deadline, a legacy update
/// refreshes the staleness clock.
///
/// `lt`/`rt` are the v3 impulse-trigger levels — zero for a v1/v2 datagram, because on a
/// level-triggered plane an absent field means "off now", never "keep what you had".
// Four levels, a pad index, a clock and a lease: grouping them would move the field list one
// hop from the two call sites (the demux feed and the tests) for nothing.
#[allow(clippy::too_many_arguments)]
pub(crate) fn wire_update(
&mut self,
now: Instant,
pad: u16,
low: u16,
high: u16,
lt: u16,
rt: u16,
ttl_ms: Option<u16>,
) {
let Some(p) = self.pads.get_mut(pad as usize) else {
return;
};
p.level = (low, high);
p.level = (low, high, lt, rt);
p.dirty = true;
match ttl_ms {
Some(t) => {
@@ -229,7 +286,10 @@ impl RumbleEngine {
let t = t.min(MAX_LEASE_MS);
p.ttl_ms = t;
p.legacy_wire = None;
p.deadline = if (low, high) != (0, 0) {
// All four levels decide whether there is a lease to run: a trigger-only rumble
// against silent handles is a LIVE level and must get a deadline, not the
// instantly-expired `None` a two-field test would have handed it.
p.deadline = if p.level != SILENT {
Some(now + Duration::from_millis(t as u64))
} else {
None
@@ -261,7 +321,7 @@ impl RumbleEngine {
for i in 0..MAX_PADS {
let p = &mut self.pads[i];
let pad = i as u16;
if p.level != (0, 0) {
if p.level != SILENT {
// 1) v2 lease expiry — the host stopped renewing (died / stopped caring). This
// firing in the wild is the signature of a host-side bug: worth a log line.
if let Some(d) = p.deadline {
@@ -284,7 +344,7 @@ impl RumbleEngine {
// 3) a wire update to relay (level change or renewal re-arm).
if p.dirty {
p.dirty = false;
if p.level == (0, 0) {
if p.level == SILENT {
// Relay a stop only if the actuator is, as far as the engine knows, still
// buzzing. A zero on an already-silent pad heals nothing and costs every
// embedder a command — Android an unconditional log line plus a binder
@@ -293,8 +353,8 @@ impl RumbleEngine {
// `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends
// zeros for every latched pad for the rest of the session. The burst still
// heals the case it exists for: a LOST first stop leaves the pad buzzing, so
// `last_emit != (0, 0)` and the re-send does emit.
if p.last_emit != (0, 0) {
// `last_emit != SILENT` and the re-send does emit.
if p.last_emit != SILENT {
return (Some(p.silence(pad)), None);
}
continue;
@@ -308,7 +368,7 @@ impl RumbleEngine {
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
// level the policy has ended.
if p.level != (0, 0) && p.quirks.keepalive_ms > 0 {
if p.level != SILENT && p.quirks.keepalive_ms > 0 {
let ka = Duration::from_millis(p.quirks.keepalive_ms as u64);
let due = *p.next_keepalive.get_or_insert(now + ka);
if now >= due {
@@ -325,7 +385,7 @@ impl RumbleEngine {
/// silences every platform by contract instead of by per-client accident.
pub(crate) fn close_drain(&mut self) -> Option<RumbleCommand> {
for i in 0..MAX_PADS {
if self.pads[i].level != (0, 0) {
if self.pads[i].level != SILENT {
return Some(self.pads[i].silence(i as u16));
}
}
@@ -349,9 +409,18 @@ struct SharedState {
pub(crate) struct RumbleFeed(pub(crate) std::sync::Arc<RumbleShared>);
impl RumbleFeed {
pub(crate) fn wire_update(&self, pad: u16, low: u16, high: u16, ttl_ms: Option<u16>) {
pub(crate) fn wire_update(
&self,
pad: u16,
low: u16,
high: u16,
lt: u16,
rt: u16,
ttl_ms: Option<u16>,
) {
let mut g = self.0.inner.lock().unwrap();
g.engine.wire_update(Instant::now(), pad, low, high, ttl_ms);
g.engine
.wire_update(Instant::now(), pad, low, high, lt, rt, ttl_ms);
drop(g);
self.0.cv.notify_all();
}
@@ -425,7 +494,30 @@ mod tests {
dedup_jitter: true,
};
/// Drain the engine the way an embedder does: poll until nothing is due.
/// Feed a HANDLE-ONLY wire update — what every producer but the Windows HID Xbox pad emits
/// (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` have two members and no third), so it
/// is also what the pre-v3 tests below are all about. Trigger cases call `wire4` instead.
fn wire(e: &mut RumbleEngine, t: Instant, pad: u16, low: u16, high: u16, ttl: Option<u16>) {
e.wire_update(t, pad, low, high, 0, 0, ttl);
}
/// Feed a full v3 wire update, all four levels.
#[allow(clippy::too_many_arguments)]
fn wire4(
e: &mut RumbleEngine,
t: Instant,
pad: u16,
low: u16,
high: u16,
lt: u16,
rt: u16,
ttl: Option<u16>,
) {
e.wire_update(t, pad, low, high, lt, rt, ttl);
}
/// Drain the engine the way an embedder does: poll until nothing is due. Handle levels only —
/// `drain4` is the four-level view.
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
let mut out = Vec::new();
while let (Some(c), _) = e.poll(t) {
@@ -434,15 +526,30 @@ mod tests {
out
}
fn drain4(e: &mut RumbleEngine, t: Instant) -> Vec<Levels> {
let mut out = Vec::new();
while let (Some(c), _) = e.poll(t) {
out.push((c.low, c.high, c.left_trigger, c.right_trigger));
}
out
}
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
/// A handle-only expected command — the shape every pre-v3 assertion below is written in.
fn cmd(pad: u16, low: u16, high: u16, backstop_ms: u32) -> RumbleCommand {
cmd4(pad, low, high, 0, 0, backstop_ms)
}
fn cmd4(pad: u16, low: u16, high: u16, lt: u16, rt: u16, backstop_ms: u32) -> RumbleCommand {
RumbleCommand {
pad,
low,
high,
left_trigger: lt,
right_trigger: rt,
backstop_ms,
}
}
@@ -451,7 +558,7 @@ mod tests {
fn v2_level_emits_and_expires_at_the_lease() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 0x4000, 0x8000, Some(400));
wire(&mut e, t0, 0, 0x4000, 0x8000, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 0x4000, 0x8000, 800))); // backstop = 2×ttl
// No renewal: at the deadline the engine self-silences — the host-died safety net.
let (c, wake) = e.poll(t0 + ms(200));
@@ -465,11 +572,11 @@ mod tests {
fn renewal_re_emits_and_extends_the_deadline() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
wire(&mut e, t0, 0, 100, 0, Some(400));
assert!(e.poll(t0).0.is_some());
// A same-level renewal at t+300 re-emits (platform duration timers re-arm) and pushes the
// deadline to t+700 — so t+500 (past the ORIGINAL deadline) still rumbles.
e.wire_update(t0 + ms(300), 0, 100, 0, Some(400));
wire(&mut e, t0 + ms(300), 0, 100, 0, Some(400));
assert_eq!(e.poll(t0 + ms(300)).0, Some(cmd(0, 100, 0, 800)));
assert_eq!(e.poll(t0 + ms(500)).0, None);
assert_eq!(e.poll(t0 + ms(700)).0, Some(cmd(0, 0, 0, 0)));
@@ -479,9 +586,9 @@ mod tests {
fn explicit_stop_is_immediate_and_cancels_the_lease() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 2, 500, 500, Some(400));
wire(&mut e, t0, 2, 500, 500, Some(400));
assert!(e.poll(t0).0.is_some());
e.wire_update(t0 + ms(50), 2, 0, 0, Some(0));
wire(&mut e, t0 + ms(50), 2, 0, 0, Some(0));
assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(2, 0, 0, 0)));
assert_eq!(e.poll(t0 + ms(600)), (None, None)); // no phantom expiry later
}
@@ -490,10 +597,10 @@ mod tests {
fn legacy_host_gets_the_uniform_staleness_bound() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 300, 0, None); // legacy: no TTL
wire(&mut e, t0, 0, 300, 0, None); // legacy: no TTL
assert_eq!(e.poll(t0).0, Some(cmd(0, 300, 0, 2000)));
// The legacy 500 ms refresh keeps it alive…
e.wire_update(t0 + ms(500), 0, 300, 0, None);
wire(&mut e, t0 + ms(500), 0, 300, 0, None);
assert_eq!(e.poll(t0 + ms(500)).0, Some(cmd(0, 300, 0, 2000)));
assert_eq!(e.poll(t0 + ms(1400)).0, None); // 900 ms since last wire — inside the bound
// …and one second of silence cuts it, on every platform alike.
@@ -512,7 +619,7 @@ mod tests {
},
);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
wire(&mut e, t0, 0, 100, 200, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
// Keepalives at the quirk cadence, alternating the low LSB to defeat SDL's dedupe.
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 101, 200, 800)));
@@ -526,7 +633,7 @@ mod tests {
fn quirk_registered_mid_rumble_starts_keepalives() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
wire(&mut e, t0, 0, 100, 0, Some(400));
assert!(e.poll(t0).0.is_some());
e.set_quirks(
0,
@@ -555,7 +662,7 @@ mod tests {
},
);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(100));
wire(&mut e, t0, 0, 100, 0, Some(100));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 0, 5000)));
}
@@ -563,8 +670,8 @@ mod tests {
fn close_drain_silences_every_buzzing_pad_once() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 0, Some(400));
e.wire_update(t0, 3, 0, 900, Some(400));
wire(&mut e, t0, 0, 100, 0, Some(400));
wire(&mut e, t0, 3, 0, 900, Some(400));
let _ = e.poll(t0);
let _ = e.poll(t0);
let a = e.close_drain().unwrap();
@@ -581,7 +688,7 @@ mod tests {
// 20 renewals landed while the embedder was stalled — state, not a queue: exactly one
// command comes out, carrying the latest level.
for k in 0..20u64 {
e.wire_update(t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400));
wire(&mut e, t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400));
}
let t = t0 + ms(20 * 120);
assert_eq!(e.poll(t).0, Some(cmd(0, 119, 0, 800)));
@@ -592,7 +699,7 @@ mod tests {
fn shared_close_delivers_drain_zero_then_closed() {
let shared = std::sync::Arc::new(RumbleShared::new());
let feed = RumbleFeed(shared.clone());
feed.wire_update(1, 100, 0, Some(400));
feed.wire_update(1, 100, 0, 0, 0, Some(400));
assert_eq!(
shared.next_command(ms(100)).unwrap().unwrap(),
cmd(1, 100, 0, 800)
@@ -613,12 +720,12 @@ mod tests {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
wire(&mut e, t0, 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]);
assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]);
// The renewal at the 120 ms default cadence: same level, must still be a distinct write.
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
wire(&mut e, t0 + ms(120), 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
}
@@ -634,7 +741,7 @@ mod tests {
for tick in 0..=360u64 {
let t = t0 + ms(tick);
if tick % 60 == 0 {
e.wire_update(t, 0, 100, 200, Some(400));
wire(&mut e, t, 0, 100, 200, Some(400));
}
for v in drain(&mut e, t) {
assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel");
@@ -657,9 +764,9 @@ mod tests {
fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() {
let mut e = RumbleEngine::new(); // Apple / Android / plain SDL
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
wire(&mut e, t0, 0, 100, 200, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
wire(&mut e, t0 + ms(120), 0, 100, 200, Some(400));
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
}
@@ -670,7 +777,7 @@ mod tests {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(t0, 0, 1, 0, Some(400));
wire(&mut e, t0, 0, 1, 0, Some(400));
assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800)));
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800)));
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800)));
@@ -683,20 +790,20 @@ mod tests {
fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(400));
wire(&mut e, t0, 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0), vec![(100, 200)]);
// First stop reaches the embedder…
e.wire_update(t0 + ms(10), 0, 0, 0, Some(0));
wire(&mut e, t0 + ms(10), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]);
// …and the burst re-sends behind it are now silent.
e.wire_update(t0 + ms(20), 0, 0, 0, Some(0));
e.wire_update(t0 + ms(30), 0, 0, 0, Some(0));
wire(&mut e, t0 + ms(20), 0, 0, 0, Some(0));
wire(&mut e, t0 + ms(30), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new());
// But if the pad is buzzing (the stop that mattered was lost), a re-send still emits.
e.wire_update(t0 + ms(40), 0, 100, 200, Some(400));
wire(&mut e, t0 + ms(40), 0, 100, 200, Some(400));
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
wire(&mut e, t0 + ms(50), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
}
@@ -707,7 +814,7 @@ mod tests {
fn an_overlong_lease_is_clamped_to_the_ceiling() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(u16::MAX));
wire(&mut e, t0, 0, 100, 200, Some(u16::MAX));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000)));
// Silenced at the ceiling, not at the 65 s the sender asked for.
assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none());
@@ -726,11 +833,112 @@ mod tests {
fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
e.wire_update(t0, 0, 100, 200, Some(0));
wire(&mut e, t0, 0, 100, 200, Some(0));
assert_eq!(
e.poll(t0).0,
Some(cmd(0, 0, 0, 0)),
"a zero-length lease must expire immediately, not emit with a legacy backstop"
);
}
/// **The single most likely way to ship trigger rumble broken** (design §5): a rumble that
/// drives ONLY the impulse triggers is the normal shape of the content — racing titles run the
/// triggers continuously against silent handles. Every liveness test in the engine used to be
/// `(low, high) == (0, 0)`; left that way, a trigger-only update is read as a stop, dropped as
/// redundant on a silent pad, and the feature is dead with no error anywhere.
#[test]
fn a_trigger_only_rumble_is_a_live_level_not_a_stop() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
wire4(&mut e, t0, 0, 0, 0, 0x8000, 0, Some(400));
assert_eq!(
e.poll(t0).0,
Some(cmd4(0, 0, 0, 0x8000, 0, 800)),
"a trigger-only level must emit with a live backstop"
);
// It runs on the pad's ONE shared lease, exactly like the handles: no renewal, so the
// whole group silences at the deadline.
assert_eq!(e.poll(t0 + ms(200)), (None, Some(t0 + ms(400))));
assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0)));
assert_eq!(e.poll(t0 + ms(500)), (None, None));
}
/// Backward compatibility for the pre-trigger C entry point
/// (`punktfunk_connection_next_rumble_cmd`, which writes `pad`/`low`/`high`/`backstop_ms` and
/// has no slot for the other two). Its embedder sees the same command, truncated to its first
/// two levels — and that truncation is CORRECT rather than merely tolerable: with no trigger
/// motors to drive, "handles silent" is what its actuator should do. The one visible
/// difference is that trigger traffic now produces commands where before the demux dropped it,
/// so such an embedder sees redundant handle stops while a trigger-only rumble runs. They are
/// idempotent; the redundant-stop suppression cannot apply, because the command is not silent.
#[test]
fn the_old_two_field_view_of_a_trigger_command_is_silent_handles() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
wire4(&mut e, t0, 0, 0x1111, 0, 0x8000, 0x4000, Some(400));
let c = e.poll(t0).0.unwrap();
assert_eq!((c.pad, c.low, c.high, c.backstop_ms), (0, 0x1111, 0, 800));
assert_eq!((c.left_trigger, c.right_trigger), (0x8000, 0x4000));
// Handles released, triggers still driven: the old view reads (0, 0) — a stop for the
// motors it owns — while the new view keeps the triggers alive.
wire4(&mut e, t0 + ms(50), 0, 0, 0, 0x8000, 0x4000, Some(400));
let c = e.poll(t0 + ms(50)).0.unwrap();
assert_eq!((c.low, c.high), (0, 0));
assert_eq!((c.left_trigger, c.right_trigger), (0x8000, 0x4000));
assert_ne!(
c.backstop_ms, 0,
"not a stop command — the pad is still live"
);
}
/// The trigger levels ride the pad's ONE seq/lease/keepalive apparatus, so a Deck-class
/// actuator's re-kicks carry them unchanged — and the dedupe nudge still only ever moves
/// `low`, never a trigger level (which would be a device write the policy did not order).
#[test]
fn keepalives_carry_the_trigger_levels_and_only_nudge_low() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
wire4(&mut e, t0, 0, 100, 200, 300, 400, Some(400));
assert_eq!(drain4(&mut e, t0), vec![(100, 200, 300, 400)]);
assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(101, 200, 300, 400)]);
assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(100, 200, 300, 400)]);
}
/// The four-field re-derivation of the jitter proof (design §8): the reserved stop is now
/// all-four-zero, so the nudge must refuse only at `(1, 0, 0, 0)` — and must NOT refuse at
/// `(1, 0, lt, rt)`, where flipping the LSB is perfectly safe because the triggers keep the
/// command non-silent. A mechanical widening that kept testing `high` alone would get the
/// first case right and the second one wrong in the harmless direction; testing `(alt, high)`
/// against `(0, 0)` would get the first case wrong and send a Deck a stop nobody ordered.
#[test]
fn the_jitter_never_synthesizes_the_four_field_stop_sentinel() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
// (1, 0, 0, 0): the ONE level whose LSB flip is the reserved stop — step up instead.
wire(&mut e, t0, 0, 1, 0, Some(400));
assert_eq!(drain4(&mut e, t0), vec![(1, 0, 0, 0)]);
assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(3, 0, 0, 0)]);
assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(1, 0, 0, 0)]);
// (1, 0, lt, 0): a live trigger level, so the plain LSB flip to 0 is safe and taken.
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
wire4(&mut e, t0, 0, 1, 0, 0x8000, 0, Some(400));
assert_eq!(drain4(&mut e, t0), vec![(1, 0, 0x8000, 0)]);
assert_eq!(drain4(&mut e, t0 + ms(40)), vec![(0, 0, 0x8000, 0)]);
assert_eq!(drain4(&mut e, t0 + ms(80)), vec![(1, 0, 0x8000, 0)]);
}
/// A pad still buzzing on the triggers alone must be silenced by the close drain — the same
/// contract the handles have, and the reason `close_drain` tests all four levels.
#[test]
fn close_drain_silences_a_trigger_only_pad() {
let mut e = RumbleEngine::new();
let t0 = Instant::now();
wire4(&mut e, t0, 2, 0, 0, 0, 0x9000, Some(400));
assert!(e.poll(t0).0.is_some());
assert_eq!(e.close_drain(), Some(cmd(2, 0, 0, 0)));
assert_eq!(e.close_drain(), None);
}
}
+60 -11
View File
@@ -140,8 +140,8 @@ impl CompositorPref {
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older
/// peers simply omit/ignore it (an unknown byte degrades to `Auto`).
/// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`), appended to
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
@@ -151,9 +151,11 @@ pub enum GamepadPref {
Xbox360,
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
DualSense,
/// uinput X-Box One / Series pad the X-Box 360 backend with the One/Series USB identity
/// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger
/// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`).
/// X-Box One / Series pad. On Linux, the X-Box 360 uinput backend with the One/Series USB
/// identity (VID/PID/name), so games show One/Series glyphs XInput-identical otherwise. On
/// Windows it is a distinct HID identity (`045E:02FD`, Bluetooth Xbox One S) through the UMDF
/// minidriver; it used to fold to `Xbox360` there, because the only Windows Xbox backend was
/// the XUSB companion, which presents one fixed 360 identity and cannot vary it.
XboxOne,
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
@@ -186,6 +188,21 @@ pub enum GamepadPref {
/// native seven-interface Puck topology (CDC pair, four controller slots, management HID)
/// rather than relabelling its reports as a wired `1302`.
SteamController2Puck,
/// Xbox Elite Wireless Controller Series 2 (Microsoft `045E:0B22`, Bluetooth) — a Windows-only
/// HID identity through the UMDF minidriver, so glyphs and the Device Manager name read Elite.
///
/// ⚠️ **Glyphs and identity only, today.** The four paddles (`BTN_PADDLE1..4`) still fold or
/// drop exactly as on the other Xbox classes; the Elite is merely the first Xbox identity that
/// *could* carry them natively. Wiring them up is blocked on a measurement, not on effort —
/// once Windows promotes the pad, `xinputhid` claims its HID collection exclusively, so extra
/// buttons declared in the report descriptor may reach no consumer at all
/// (`design/xbox-pad-windows-handoff.md` §3.6). Do not advertise paddle support off this
/// variant until that is measured; `DualSenseEdge` stays the only virtual pad with native
/// back-button slots.
///
/// Folds to `Xbox360` everywhere but Windows: there is no Linux uinput Elite identity
/// (`PadIdentity` has 360 and One S only).
XboxElite,
}
impl GamepadPref {
@@ -211,7 +228,8 @@ impl GamepadPref {
pub const fn has_motion(self) -> bool {
match self {
GamepadPref::Auto => true, // unknown; assume it can, see above
GamepadPref::Xbox360 | GamepadPref::XboxOne => false,
// No Xbox pad has a gyro in its HID contract — Elite Series 2 included.
GamepadPref::Xbox360 | GamepadPref::XboxOne | GamepadPref::XboxElite => false,
GamepadPref::DualSense
| GamepadPref::DualShock4
| GamepadPref::DualSenseEdge
@@ -225,7 +243,7 @@ impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`, `10 = SteamController2Puck`.
/// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`.
pub const fn to_u8(self) -> u8 {
match self {
GamepadPref::Auto => 0,
@@ -239,6 +257,7 @@ impl GamepadPref {
GamepadPref::SwitchPro => 8,
GamepadPref::SteamController2 => 9,
GamepadPref::SteamController2Puck => 10,
GamepadPref::XboxElite => 11,
}
}
@@ -256,6 +275,7 @@ impl GamepadPref {
8 => GamepadPref::SwitchPro,
9 => GamepadPref::SteamController2,
10 => GamepadPref::SteamController2Puck,
11 => GamepadPref::XboxElite,
_ => GamepadPref::Auto,
}
}
@@ -270,6 +290,10 @@ impl GamepadPref {
"xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => {
GamepadPref::XboxOne
}
// "elite" is unambiguous here — the DualSense Edge answers to "edge", never "elite".
"xboxelite" | "xbox-elite" | "elite" | "xboxelite2" | "elite2" => {
GamepadPref::XboxElite
}
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
@@ -289,7 +313,7 @@ impl GamepadPref {
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
/// `"steamcontroller2"`, `"steamcontroller2puck"`).
/// `"steamcontroller2"`, `"steamcontroller2puck"`, `"xboxelite"`).
pub fn as_str(self) -> &'static str {
match self {
GamepadPref::Auto => "auto",
@@ -303,6 +327,7 @@ impl GamepadPref {
GamepadPref::SwitchPro => "switchpro",
GamepadPref::SteamController2 => "steamcontroller2",
GamepadPref::SteamController2Puck => "steamcontroller2puck",
GamepadPref::XboxElite => "xboxelite",
}
}
}
@@ -833,7 +858,11 @@ mod tests {
/// into a host that drops every one.
#[test]
fn only_the_xbox_classes_lack_a_motion_plane() {
for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] {
for p in [
GamepadPref::Xbox360,
GamepadPref::XboxOne,
GamepadPref::XboxElite,
] {
assert!(
!p.has_motion(),
"{} should have no motion plane",
@@ -910,11 +939,12 @@ mod tests {
GamepadPref::SwitchPro,
GamepadPref::SteamController2,
GamepadPref::SteamController2Puck,
GamepadPref::XboxElite,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
// Every wire byte 0..=11 is assigned, distinct, and pinned (forward-compat with peers
// that only know a prefix of the range).
for (v, p) in [
(0, GamepadPref::Auto),
@@ -928,12 +958,13 @@ mod tests {
(8, GamepadPref::SwitchPro),
(9, GamepadPref::SteamController2),
(10, GamepadPref::SteamController2Puck),
(11, GamepadPref::XboxElite),
] {
assert_eq!(p.to_u8(), v);
assert_eq!(GamepadPref::from_u8(v), p);
}
// The next unassigned byte degrades to Auto today; assigning it later must update this.
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
assert_eq!(GamepadPref::from_u8(12), GamepadPref::Auto);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
@@ -964,6 +995,24 @@ mod tests {
Some(GamepadPref::XboxOne)
);
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
// The Elite's aliases, and the one that could plausibly have been stolen: "edge" is the
// DualSense Edge and must stay so — the two are different pads on different vendors.
assert_eq!(
GamepadPref::from_name("Elite"),
Some(GamepadPref::XboxElite)
);
assert_eq!(
GamepadPref::from_name("xbox-elite"),
Some(GamepadPref::XboxElite)
);
assert_eq!(
GamepadPref::from_name("elite2"),
Some(GamepadPref::XboxElite)
);
assert_eq!(
GamepadPref::from_name("edge"),
Some(GamepadPref::DualSenseEdge)
);
assert_eq!(GamepadPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
+12 -1
View File
@@ -145,7 +145,18 @@ pub use stats::Stats;
/// connection was simply lost. Purely a read of state the core already had: no new call is required
/// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same
/// bytes either way, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 17;
/// v18: added `punktfunk_connection_next_rumble_cmd2` — the policy engine's rumble command with the
/// two Xbox impulse-trigger motor levels off the 0xCA v3 tail
/// (`design/trigger-rumble-plane.md`), which the fixed out-params of
/// `punktfunk_connection_next_rumble_cmd` have no room for. A NEW symbol, not a widened one: an
/// exported parameter list is part of the contract, and growing one in place breaks every
/// out-of-tree embedder at once. The old entry point is unchanged in signature AND in the levels
/// it reports — it keeps writing the two handle motors, which is the correct instruction for the
/// actuators it owns, so an embedder that never adopts the new symbol behaves exactly as before.
/// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both
/// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is
/// unchanged.
pub const ABI_VERSION: u32 = 18;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+156 -7
View File
@@ -122,8 +122,9 @@ pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v2`] instead; this is kept
/// for the loopback tests and as the wire an old host still speaks (a new client decodes both via
/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v3`] instead; this is kept
/// for the loopback tests, as the wire an old host still speaks, and as what the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch drops to (a new client decodes every form via
/// [`decode_rumble_envelope`]).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7];
@@ -141,6 +142,12 @@ pub const RUMBLE_V1_LEN: usize = 7;
/// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
/// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
pub const RUMBLE_V2_LEN: usize = 10;
/// Wire length of a v3 (envelope + impulse-trigger motors) rumble datagram — the v2 form plus a
/// `[u16 left_trigger LE][u16 right_trigger LE]` tail (see [`encode_rumble_datagram_v3`]). Second
/// use of the same append-extension the v2 tail introduced, and for the same reason: every reader
/// on this plane gates with `>=`, so a 14-byte datagram satisfies the v1 predicate (level only),
/// the v2 predicate (level + envelope) and this one, and each peer takes the prefix it knows.
pub const RUMBLE_V3_LEN: usize = 14;
/// Rumble envelope datagram (v2), host → client:
/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE]`.
@@ -163,6 +170,41 @@ pub fn encode_rumble_datagram_v2(pad: u16, low: u16, high: u16, seq: u8, ttl_ms:
b
}
/// Rumble envelope datagram with the impulse-trigger motors (v3), host → client:
/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE][u16 lt LE][u16 rt LE]`.
///
/// The [`encode_rumble_datagram_v2`] envelope with the Xbox trigger motors appended, on the same
/// `0..=0xFFFF` scale as `low`/`high` (design/trigger-rumble-plane.md §4).
///
/// **The four levels share ONE `seq` and ONE `ttl_ms`, deliberately.** They are a single statement
/// of the pad's feedback state at one instant; a second sequence space would let a reordered
/// datagram apply the handles from moment *t* and the triggers from *t1*, a glitch nothing else
/// in the system can currently produce. Sharing also means the whole v2 apparatus — the renewal
/// cadence, the post-stop burst, the client's wrapping half-space `seq` gate, the receiver-side
/// lease clamp — governs the trigger motors with no new code, so a trigger rumble whose host dies
/// self-silences on the same lease as the handles.
///
/// Exactly one backend can ever source non-zero trigger levels: the Windows HID Xbox pad, whose
/// output report `0x03` carries them. Classic XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE`
/// have two members and no third, so every other producer passes `lt = rt = 0` — for those this is
/// a v2 datagram with four zero bytes on the end, which is exactly what the length tolerance is
/// for.
pub fn encode_rumble_datagram_v3(
pad: u16,
low: u16,
high: u16,
seq: u8,
ttl_ms: u16,
lt: u16,
rt: u16,
) -> [u8; RUMBLE_V3_LEN] {
let mut b = [0u8; RUMBLE_V3_LEN];
b[..RUMBLE_V2_LEN].copy_from_slice(&encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms));
b[10..12].copy_from_slice(&lt.to_le_bytes());
b[12..14].copy_from_slice(&rt.to_le_bytes());
b
}
/// The self-termination tail of a v2 rumble envelope (see [`encode_rumble_datagram_v2`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleEnvelope {
@@ -174,17 +216,28 @@ pub struct RumbleEnvelope {
/// A decoded rumble update. `envelope` is `None` for a legacy 7-byte datagram (an old host, which
/// has no seq/ttl — the client applies its own staleness policy), `Some` for a v2 envelope.
///
/// `left_trigger`/`right_trigger` are the Xbox impulse-trigger motors from a v3 datagram, on the
/// same `0..=0xFFFF` scale as `low`/`high`, and they are **plain fields, not `Option`** even though
/// only a v3 datagram carries them. A v1/v2 datagram decodes to `left_trigger = right_trigger = 0`.
/// The temptation is to mirror `envelope` so a consumer could tell "old host" from "new host,
/// triggers idle", but `Option` invites "absent → keep the previous value", and on a
/// level-triggered plane that is the stuck-rumble bug in a new costume: `0xCA` means *these are the
/// levels now*, so an absent field is zero. (`envelope` is genuinely optional because its absence
/// selects a different *policy*, not a different level.)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleUpdate {
pub pad: u16,
pub low: u16,
pub high: u16,
pub left_trigger: u16,
pub right_trigger: u16,
pub envelope: Option<RumbleEnvelope>,
}
/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) a v2 envelope tail.
/// `None` on bad tag/length. Kept for callers that only need the level (the probe, the loopback
/// assertions); clients that honor TTL use [`decode_rumble_envelope`].
/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) the v2 envelope and v3
/// trigger tails. `None` on bad tag/length. Kept for callers that only need the handle level (the
/// probe, the loopback assertions); clients that honor TTL use [`decode_rumble_envelope`].
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
@@ -193,10 +246,15 @@ pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
Some((u16at(1), u16at(3), u16at(5)))
}
/// Parse a rumble datagram → [`RumbleUpdate`], detecting the v2 envelope tail by length. A
/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a 7..RUMBLE_V2_LEN buffer is a legacy level
/// Parse a rumble datagram → [`RumbleUpdate`], detecting each appended tail by length. A
/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a `>= RUMBLE_V3_LEN` buffer additionally
/// carries the two impulse-trigger levels; a 7..RUMBLE_V2_LEN buffer is a legacy level
/// (`envelope: None`) — the same tolerance as an old client would apply, so a torn/short tail
/// degrades to a level rather than dropping. `None` on bad tag/length.
///
/// The one decoder for all three forms: v3 is not a separate wire, it is the same wire with more
/// of it present. Absent trigger bytes read as zero rather than "unchanged" — see
/// [`RumbleUpdate`] for why that is not negotiable on a level-triggered plane.
pub fn decode_rumble_envelope(b: &[u8]) -> Option<RumbleUpdate> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
@@ -206,10 +264,13 @@ pub fn decode_rumble_envelope(b: &[u8]) -> Option<RumbleUpdate> {
seq: b[7],
ttl_ms: u16::from_le_bytes([b[8], b[9]]),
});
let triggers = b.len() >= RUMBLE_V3_LEN;
Some(RumbleUpdate {
pad: u16at(1),
low: u16at(3),
high: u16at(5),
left_trigger: if triggers { u16at(10) } else { 0 },
right_trigger: if triggers { u16at(12) } else { 0 },
envelope,
})
}
@@ -1196,6 +1257,8 @@ mod tests {
pad: 2,
low: 0x4000,
high: 0x8000,
left_trigger: 0,
right_trigger: 0,
envelope: Some(RumbleEnvelope {
seq: 7,
ttl_ms: 400
@@ -1215,6 +1278,8 @@ mod tests {
pad: 3,
low: 0x1111,
high: 0x2222,
left_trigger: 0,
right_trigger: 0,
envelope: None,
})
);
@@ -1237,6 +1302,90 @@ mod tests {
assert!(decode_rumble_envelope(&wrong_tag).is_none());
}
/// v3 (design/trigger-rumble-plane.md §4) is the v2 envelope with the two impulse-trigger
/// levels appended, and the prefix discipline the 0xCF plane uses three times over holds here
/// too: the first 10 bytes must be byte-identical to what v2 would have produced, or the
/// envelope a v2-era client reads is displaced and every TTL/seq guarantee on this plane
/// silently changes meaning.
#[test]
fn rumble_v3_roundtrips_and_keeps_the_v2_envelope_in_place() {
let v2 = encode_rumble_datagram_v2(2, 0x4000, 0x8000, 7, 400);
let v3 = encode_rumble_datagram_v3(2, 0x4000, 0x8000, 7, 400, 0x1234, 0xFFFF);
assert_eq!(v3.len(), RUMBLE_V3_LEN);
assert_eq!(&v3[..RUMBLE_V2_LEN], &v2[..], "v2 is a strict prefix of v3");
// The exact tail layout, LE, pinned as bytes: an endianness slip here reads a 0x1234
// trigger as 0x3412 and is invisible in a round-trip that uses the same encoder both ways.
assert_eq!(&v3[10..14], &[0x34, 0x12, 0xFF, 0xFF]);
assert_eq!(
decode_rumble_envelope(&v3),
Some(RumbleUpdate {
pad: 2,
low: 0x4000,
high: 0x8000,
left_trigger: 0x1234,
right_trigger: 0xFFFF,
envelope: Some(RumbleEnvelope {
seq: 7,
ttl_ms: 400
}),
})
);
// A trigger-only rumble (racing titles drive the triggers hard and the handles not at all)
// is expressible and survives the trip with the handles at rest.
let trig_only = encode_rumble_datagram_v3(0, 0, 0, 3, 400, 0x8000, 0);
let u = decode_rumble_envelope(&trig_only).unwrap();
assert_eq!((u.low, u.high), (0, 0));
assert_eq!((u.left_trigger, u.right_trigger), (0x8000, 0));
assert_eq!(u.envelope.unwrap().ttl_ms, 400);
}
/// Cross-version tolerance, both directions — the compatibility table in
/// design/trigger-rumble-plane.md §5, as code.
#[test]
fn rumble_v3_and_v2_parse_each_others_datagrams() {
let v3 = encode_rumble_datagram_v3(1, 0x1111, 0x2222, 9, 250, 0xAAAA, 0xBBBB);
// NEW host → OLD client: the v2-era readers see exactly what they saw before. The level
// decoder ignores both tails; the envelope decoder reads the same seq/ttl off bytes 7..10.
assert_eq!(decode_rumble_datagram(&v3), Some((1, 0x1111, 0x2222)));
assert_eq!(
decode_rumble_envelope(&v3).unwrap().envelope,
Some(RumbleEnvelope {
seq: 9,
ttl_ms: 250
})
);
// OLD host → NEW client: v1 and v2 decode with the triggers SILENT, not "unchanged".
for (form, d) in [
("v1", encode_rumble_datagram(1, 0x1111, 0x2222).to_vec()),
(
"v2",
encode_rumble_datagram_v2(1, 0x1111, 0x2222, 9, 250).to_vec(),
),
] {
let u = decode_rumble_envelope(&d).unwrap();
assert_eq!(
(u.left_trigger, u.right_trigger),
(0, 0),
"{form} must decode to idle triggers"
);
assert_eq!((u.pad, u.low, u.high), (1, 0x1111, 0x2222));
}
// A torn trigger tail (11..14 bytes — the host never emits these, a truncating middlebox
// might) degrades to the v2 decode rather than reading half a level: a 13-byte buffer must
// not surface `rt` from one byte of it.
let v2 = decode_rumble_envelope(&encode_rumble_datagram_v2(1, 0x1111, 0x2222, 9, 250));
for n in RUMBLE_V2_LEN..RUMBLE_V3_LEN {
assert_eq!(
decode_rumble_envelope(&v3[..n]),
v2,
"partial trigger tail ({n} B) must degrade to the v2 decode"
);
}
}
#[test]
fn rumble_envelope_seq_gate_drops_reordered_stale_start() {
use crate::input::GamepadSnapshot;
+5
View File
@@ -199,6 +199,11 @@ pub(crate) mod audio_probe;
#[cfg(target_os = "windows")]
#[path = "audio/windows/minted.rs"]
pub(crate) mod minted;
// The uninstall sweep over every audio devnode the two providers above (and the probe) mint —
// pub(crate) for `driver uninstall --audio`, the installer's [UninstallRun] leg.
#[cfg(target_os = "windows")]
#[path = "audio/windows/devnode_cleanup.rs"]
pub(crate) mod devnode_cleanup;
#[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap;
@@ -406,6 +406,34 @@ fn recover_orphaned_default() {
});
}
/// [`recover_orphaned_default`]'s uninstall-time twin: same "put the operator's device back if
/// the default is still parked on ours" rule, minus the `Once` gate (the uninstaller is a fresh
/// process that runs it exactly once) — and it always drops the marker file, because there is no
/// next host run to consume it.
///
/// Why the uninstaller needs this at all: the devnode sweep that follows deletes the endpoint the
/// default may still point at. Windows would then re-pick something on its own, but it re-picks by
/// its OWN ranking, not the device the operator had before we parked it. Restoring first means
/// uninstalling gives the box back exactly the default it came with.
///
/// Returns whether a device was actually put back — the caller only logs it.
pub(crate) fn unpark_default_for_uninstall() -> bool {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return false;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return false;
};
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
if default_render_id().as_deref() != Some(set) {
return false;
}
set_default_endpoint(prev).is_ok()
}
/// Make `id` the default playback device for the duration of the desktop-audio capture,
/// remembering the operator's current default (in memory + the crash marker) the FIRST time so
/// [`restore_default_playback`] can put it back. Nothing is remembered when `id` already is the
@@ -42,8 +42,10 @@ use windows::Win32::System::Registry::{
};
/// Marker value in a probe devnode's `Device Parameters` key — how `cleanup` finds what this
/// devtest minted (and nothing else).
const PROBE_MARKER: &str = "PunktfunkAudioProbe";
/// devtest minted (and nothing else). pub(crate): the uninstall sweep
/// ([`devnode_cleanup`](super::devnode_cleanup)) sweeps this family too, so a devtest run on an
/// operator's box cannot outlive the product.
pub(crate) const PROBE_MARKER: &str = "PunktfunkAudioProbe";
/// DeviceDesc for probe devnodes (visible in Device Manager until the INF install renames it).
const PROBE_DESC: &str = "Punktfunk Audio Probe";
/// How long to wait for audiosrv to register a minted endpoint.
@@ -0,0 +1,217 @@
//! Uninstall-time removal of every audio device punktfunk minted on this box — the
//! `punktfunk-host driver uninstall --audio` leg the installer's Inno `[UninstallRun]` calls.
//!
//! The field report this exists for: uninstalling punktfunk left "Punktfunk Speakers",
//! "Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints sitting in Windows'
//! Sound settings forever. They are not files and no uninstaller deletes them by walking a
//! payload list — they are DEVNODES this host created at runtime, and they persist exactly
//! because they are designed to ([`minted`](super::minted) and
//! [`pad_endpoint`](super::pad_endpoint) both re-resolve their devnodes across host restarts
//! rather than re-minting them). Persistent across restarts must not mean permanent.
//!
//! What gets swept: every MEDIA-class devnode carrying one of the three durable owner markers
//! this product writes into `Device Parameters`, whatever minted it —
//!
//! * [`pad_endpoint::PAD_INDEX_VALUE`](super::pad_endpoint::PAD_INDEX_VALUE) — the per-pad
//! DualSense speaker endpoints,
//! * [`minted::ROLE_MARKER`](super::minted::ROLE_MARKER) — the Speakers/Microphone substrate,
//! * [`audio_probe::PROBE_MARKER`](super::audio_probe::PROBE_MARKER) — devtest leftovers, so a
//! probe run on an operator's box cannot outlive the product either.
//!
//! Marker-matched, never name-matched: our devnodes are instances of VALVE's streaming-audio
//! drivers and are name-identical to Steam's own (the same reason the wiring plan works by
//! recorded id). Steam's devnodes, its driver packages, and a VB-CABLE from the era when we
//! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk
//! removes what punktfunk created, and nothing else.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Removed {
pub devnodes: usize,
pub devnode_failures: usize,
pub endpoint_records: usize,
}
/// Restore the default playback device if we left it parked, then remove every audio devnode
/// this product minted, newest registry record and all.
///
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
/// over a virtual speaker.
pub(crate) fn purge() -> Result<Removed> {
// FIRST, before the sweep deletes the endpoint the default may still point at. A host that
// died mid-stream leaves the box's default playback parked on our loopback sink; Windows
// would re-pick on its own once the device vanishes, but by its own ranking rather than by
// what the operator had. Putting it back is the difference between "the box works again"
// and "the box works again, on the device it started with".
if audio_control::unpark_default_for_uninstall() {
println!("restored the default playback device this host had parked");
}
let mut out = Removed::default();
for inst in owned_devnodes()? {
// Resolve the endpoint records BEFORE the devnode goes. An endpoint's MMDevices key is
// tied to us only through its `{1}.<instance id>` devnode link — once the devnode is
// removed, nothing left in the store says the record was ever ours, and a sweep that
// guessed by NAME is exactly the mistake this module refuses to make.
let records: Vec<(&str, String)> = [
(pe::MMDEV_RENDER_PATH, pe::find_endpoint_for_devnode(&inst)),
(
pe::MMDEV_CAPTURE_PATH,
pe::find_capture_endpoint_for_devnode(&inst),
),
]
.into_iter()
.filter_map(|(path, found)| Some((path, found.ok().flatten()?)))
.collect();
if !remove_devnode(&inst) {
out.devnode_failures += 1;
// The device is still there, so its record still belongs to a live endpoint.
continue;
}
out.devnodes += 1;
for (path, endpoint) in records {
if delete_endpoint_record(path, &endpoint) {
out.endpoint_records += 1;
}
}
}
Ok(out)
}
/// Every MEDIA-class devnode carrying one of [`OWNER_MARKERS`]. Enumerated WITHOUT `DIGCF_PRESENT`
/// (that is what [`pe::media_class_devs`] gives us), so a phantom left by a crashed host is swept
/// too — the same "ghost in Device Manager forever" complaint the pad and vdisplay legs fixed.
fn owned_devnodes() -> Result<Vec<String>> {
let set = pe::media_class_devs()?;
let mut out = Vec::new();
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
break; // ERROR_NO_MORE_ITEMS
}
let Some(inst) = pe::instance_id(&set, &did) else {
continue;
};
if !is_removable_instance(&inst) {
continue;
}
if OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
}
}
Ok(out)
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
/// class, which always yields `ROOT\MEDIA\NNNN`. Nothing else can be ours — so if a marker name
/// we own ever collides with a value some vendor writes under a REAL sound card's `Device
/// Parameters`, this guard is what stops an uninstall from taking the user's hardware with it.
fn is_removable_instance(instance_id: &str) -> bool {
instance_id.to_ascii_uppercase().starts_with("ROOT\\")
}
/// `pnputil /remove-device` — the same teardown `audio-probe cleanup` and the driver legs use.
/// Called by absolute path: an uninstaller must not depend on the invoking shell's `%PATH%`.
fn remove_devnode(instance_id: &str) -> bool {
let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into());
match std::process::Command::new(format!(r"{windir}\System32\pnputil.exe"))
.args(["/remove-device", instance_id])
.output()
{
Ok(o) if o.status.success() => {
println!("removed audio devnode {instance_id}");
true
}
Ok(o) => {
eprintln!(
"warning: pnputil could not remove {instance_id} (status {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr).trim()
);
false
}
Err(e) => {
eprintln!("warning: could not run pnputil for {instance_id}: {e}");
false
}
}
}
/// Delete one endpoint's MMDevices record — the `{guid}` subkey holding its name, its stamped
/// formats and its per-endpoint volume/settings.
///
/// BEST-EFFORT ON PURPOSE, and quiet when it fails. These keys are owned by SYSTEM and grant
/// Administrators read only (the same ACL that forces the stamping path through
/// `grant_system_full_control`), while the uninstaller runs elevated but as a USER — so on a
/// stock box this is denied and the record stays. What stays is inert: with the devnode gone the
/// endpoint is NOTPRESENT, which Sound settings surface only behind "Show Disconnected Devices",
/// and nothing re-animates it without a devnode to link to. Buying that last cosmetic scrap would
/// mean an uninstaller seizing ownership of SYSTEM-owned registry keys — a worse thing to ship
/// than the leftover. The DEVICE, which is what the field report was about, is gone either way.
fn delete_endpoint_record(reg_path: &str, endpoint_id: &str) -> bool {
use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_ALL_ACCESS};
use winreg::RegKey;
let Ok(guid) = pe::endpoint_guid_part(endpoint_id) else {
return false;
};
let Ok(store) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(reg_path, KEY_ALL_ACCESS)
else {
return false;
};
store.delete_subkey_all(guid).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_root_enumerated_devnodes_are_ours() {
assert!(is_removable_instance(r"ROOT\MEDIA\0003"));
// PnP casing is not guaranteed.
assert!(is_removable_instance(r"root\media\0004"));
// A real sound card, however it got a marker-shaped value written under it.
assert!(!is_removable_instance(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0900\4&1c4a4e5&0&0001"
));
assert!(!is_removable_instance(r"USB\VID_046D&PID_0A38\ABCDEF"));
// Not a prefix match on the string "ROOT" appearing anywhere.
assert!(!is_removable_instance(r"SWD\ROOT\MEDIA\0003"));
}
#[test]
fn every_minted_family_is_swept() {
// The sweep is only as complete as this list — a new minted-devnode family that forgets
// to register here would ship the same leak again.
assert!(OWNER_MARKERS.contains(&"PunktfunkPadIndex"));
assert!(OWNER_MARKERS.contains(&"PunktfunkAudioRole"));
assert!(OWNER_MARKERS.contains(&"PunktfunkAudioProbe"));
}
}
@@ -32,8 +32,9 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
/// Durable role marker in a minted devnode's `Device Parameters` key.
const ROLE_MARKER: &str = "PunktfunkAudioRole";
/// Durable role marker in a minted devnode's `Device Parameters` key. pub(crate): the uninstall
/// sweep ([`devnode_cleanup`](super::devnode_cleanup)) matches devnodes on it.
pub(crate) const ROLE_MARKER: &str = "PunktfunkAudioRole";
/// How long to wait for audiosrv to register a freshly minted endpoint.
const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
/// Minimum spacing between provisioning retries once the startup attempt failed
@@ -87,13 +87,15 @@ const DEVNODE_DESC: &str = "Punktfunk Pad Audio";
/// The multi-instancing Steam Remote Play render driver we ride on.
const SSS_HWID: &str = "ROOT\\SteamStreamingSpeakers";
/// Registry value under the devnode's `Device Parameters` key persisting which pad slot the
/// devnode serves (REG_DWORD).
const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex";
/// devnode serves (REG_DWORD). pub(crate): the uninstall sweep
/// ([`devnode_cleanup`](super::devnode_cleanup)) matches devnodes on it.
pub(crate) const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex";
/// The endpoint store for render endpoints (each subkey = one endpoint GUID).
const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render";
pub(crate) const MMDEV_RENDER_PATH: &str =
r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render";
/// The capture-direction sibling of [`MMDEV_RENDER_PATH`] — where a paired device's microphone
/// half registers (the `audio-probe` devtest's S3 lookup).
const MMDEV_CAPTURE_PATH: &str =
pub(crate) const MMDEV_CAPTURE_PATH: &str =
r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Capture";
/// WASAPI endpoint-id prefix for render endpoints (`{0.0.0.00000000}.{guid}`).
const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}.";
@@ -355,8 +357,9 @@ fn reg_registry_value(v: &StampValue) -> winreg::RegValue<'static> {
}
/// The per-endpoint GUID portion of a WASAPI endpoint id (`{0.0.0.00000000}.{guid}` →
/// `{guid}`) — the endpoint's MMDevices registry key name.
fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> {
/// `{guid}`) — the endpoint's MMDevices registry key name. pub(crate): the uninstall sweep
/// deletes those keys by name.
pub(crate) fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> {
endpoint_id
.rfind('{')
.map(|i| &endpoint_id[i..])
+87 -12
View File
@@ -270,8 +270,10 @@ pub fn switchpro_test(args: &[String]) -> Result<()> {
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
// `lt`/`rt` are structurally always zero here — a Switch Pro has no trigger motors —
// but this harness reads the shared `PadFeedback`, so it prints all four levels.
if let Some((low, high, lt, rt)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high} lt={lt} rt={rt}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
@@ -364,6 +366,16 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
.unwrap_or(0);
let ds4 = args.iter().any(|a| a == "--ds4");
let xbox = args.iter().any(|a| a == "--xbox");
// `--xboxhid` drives the HID Xbox backend (device-type 4) instead of `--xbox`'s XUSB companion.
let xboxhid = args.iter().any(|a| a == "--xboxhid");
// The other two HID Xbox identities (device-types 5 and 6). Same backend, same report
// descriptor — only VID/PID, product string and hardware id differ — so these legs exist for
// exactly one question each: does Windows PROMOTE that PID the way it promotes `0B13`?
// `02FD` in particular has no stage-2 `HID\…&IG_00` line in Microsoft's `xinputhid.inf`, so
// it is the one worth watching. Check for the `IG_00` token, the XUSB interface, an XInput
// slot and rumble, exactly as the `--xboxhid` run did.
let xboxones = args.iter().any(|a| a == "--xboxones");
let xboxelite = args.iter().any(|a| a == "--xboxelite");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
@@ -386,6 +398,23 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
capabilities: 0,
audio_caps: 0,
});
// 🛑 Never announce a pad that was not built. The arrival above only ASKS for one; a
// failed create logs an ERROR and leaves the slot empty, and this harness would then
// cheerfully print "virtual X up" and stream frames into nothing for `secs` seconds.
// Every probe the operator runs next (joy.cpl, XInputGetState, a WGI enumeration) still
// finds a device on this index — the one the OTHER process owns — so the run produces a
// plausible, wrong measurement instead of a failure. That happened on `.173`
// (2026-08-09): the host service held pad 0, the create was denied, and a frozen XInput
// packet count off the incumbent pad was read as a result. A harness that cannot build
// its own device has nothing to measure, so stop.
if mgr.live_pads() == 0 {
anyhow::bail!(
"no virtual {} was created at index {idx} — see the ERROR above for the \
cause. NOT measuring: any device answering on this index belongs to another \
process (a live session's pad), and reading it would look like a result.",
$label
);
}
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
@@ -395,7 +424,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
|pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"),
|pad, lo, hi, lt, rt| println!(
" rumble from game: pad={pad} low={lo} high={hi} lt={lt} rt={rt}"
),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
@@ -406,17 +437,28 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
} else {
0
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
// 🛑 Sweep EVERY analogue axis, each on its own phase, and ramp both triggers.
//
// This used to drive LS-X alone and leave the other five at zero, which makes
// the harness unable to tell "this axis is not mapped" from "nothing is driving
// it" — the two look identical in any consumer. That is exactly how a DEAD
// RIGHT STICK survived every bench measurement of the Windows HID Xbox pad and
// was found only on glass (2026-08-09): `XInputGetState` read `RX [0..0]` and it
// was written off as "the devtest doesn't move it", which was true and useless.
// Distinct phases mean one run tells you which axes arrive AND that they are not
// crosstalking onto each other's bytes.
let phase = |off: i32| ((((i + off) % 64) - 32) * 1024) as i16;
let trig = ((i % 32) * 8).clamp(0, 255) as u8;
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
left_trigger: trig,
right_trigger: 255 - trig,
ls_x: phase(0),
ls_y: phase(16),
rs_x: phase(32),
rs_y: phase(48),
}));
}
std::thread::sleep(Duration::from_millis(15));
@@ -433,6 +475,13 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
capabilities: 0,
audio_caps: 0,
});
// Same guard as the `drive!` macro's — see the long note there.
if mgr.live_pads() == 0 {
anyhow::bail!(
"no virtual Xbox 360 (XUSB) was created at index {idx} — see the ERROR above. NOT \
measuring: a device answering on this index belongs to another process."
);
}
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
an XInput game or xinputtest.exe."
@@ -440,8 +489,10 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(secs);
let mut t = 0i32;
while Instant::now() < deadline {
mgr.pump_rumble(|pad, lo, hi| {
println!(" rumble from game: pad={pad} low={lo} high={hi}")
// `lt`/`rt` are structurally always zero on XUSB (see `pump_rumble`); printed so
// the harness output is comparable line-for-line with the HID Xbox backend's.
mgr.pump_rumble(|pad, lo, hi, lt, rt| {
println!(" rumble from game: pad={pad} low={lo} high={hi} lt={lt} rt={rt}")
});
t += 1;
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
@@ -463,6 +514,30 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
}));
std::thread::sleep(Duration::from_millis(15));
}
} else if xboxhid {
// The HID Xbox pad (device-type 4) — the SHIPPING SwDeviceCreate identity, not a devgen
// node. That distinction is the whole point of this leg: a devgen devnode carries no USB
// hardware ids, so its HID child comes up `HID\VID_045E&UP:0001_U:0005` with no PID token,
// and the question this exists to answer — does Windows promote our pad to an Xbox-profile
// device (an `IG_` token, XInput, WGI `Gamepad`) — turns on exactly that PID being present.
drive!(
crate::inject::xbox_windows::XboxWindowsManager::new(),
"Xbox Wireless Controller (HID)"
);
} else if xboxones {
drive!(
crate::inject::xbox_windows::XboxWindowsManager::with_backend(
crate::inject::xbox_windows::XboxWinProto::one_s()
),
"Xbox One S Controller (HID, 045E:02FD)"
);
} else if xboxelite {
drive!(
crate::inject::xbox_windows::XboxWindowsManager::with_backend(
crate::inject::xbox_windows::XboxWinProto::elite()
),
"Xbox Elite Wireless Controller Series 2 (HID, 045E:0B22)"
);
} else if ds4 {
drive!(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
@@ -255,7 +255,13 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
hdr_sent = true;
}
}
pads.pump_rumble(|index, low, high| {
// The GameStream leg carries the handle motors only: Moonlight's
// trigger-rumble message (`ConnListenerRumbleTriggers`) is a separate
// control-stream id we have not read out of moonlight-common-c yet, and
// `low`/`high` here are already what `rumble_plaintext` (0x010B) encodes.
// The uinput backend cannot source triggers anyway (evdev `FF_RUMBLE` has
// two fields), so nothing is dropped today.
pads.pump_rumble(|index, low, high, _lt, _rt| {
let pt = super::gamepad::rumble_plaintext(index, low, high);
out.push(encrypt_control(&key, &scheme, host_seq, &pt));
host_seq = host_seq.wrapping_add(1);
@@ -269,7 +275,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
}
} else {
// No client/scheme yet: still answer FF uploads so games don't block.
pads.pump_rumble(|_, _, _| {});
pads.pump_rumble(|_, _, _, _, _| {});
}
// ENet needs frequent servicing for handshake/keepalive/retransmit.
std::thread::sleep(Duration::from_millis(2));
+26 -4
View File
@@ -374,6 +374,12 @@ pub(crate) async fn serve(
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup();
// …and check the takeover's one un-automatable prerequisite BEFORE a stream needs it: on a box
// that will use the takeover, the host's user must be in the `punktfunk` group the packaged
// privilege helper gates on. Missing membership fails nothing — the takeover degrades to
// mirroring the box's own session — so without this it surfaces only as a black screen on
// every connect. No-op off Linux and on any box the takeover can't apply to.
crate::vdisplay::preflight_takeover_privilege();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
@@ -1452,9 +1458,14 @@ async fn serve_session(
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
{
use punktfunk_core::quic::HidOutput;
// v2 envelope (seq 0, 400 ms TTL) so the loopback/probe assertion covers the self-
// terminating tail, not just the level.
let d = punktfunk_core::quic::encode_rumble_datagram_v2(0, 0x4000, 0x8000, 0, 400);
// v3 envelope (seq 0, 400 ms TTL, both impulse-trigger motors asserted) so the
// loopback/probe assertion covers the self-terminating tail AND the trigger tail behind
// it, not just the level. The trigger levels are deliberately DIFFERENT from each other
// and from the handles: a decoder that reads the wrong offset produces a plausible-looking
// number rather than a zero, so identical values would hide the mistake.
let d = punktfunk_core::quic::encode_rumble_datagram_v3(
0, 0x4000, 0x8000, 0, 400, 0x2000, 0x6000,
);
let _ = conn.send_datagram(d.to_vec().into());
for h in [
HidOutput::Led {
@@ -1819,9 +1830,20 @@ async fn serve_session(
.await
.is_err()
{
// Name what is still held, not just that a thread was let go. The input thread OWNS this
// session's virtual gamepads (`input_thread`'s `Pads`, dropped only when that fn returns),
// and on Windows each one holds a `SwDeviceCreate` devnode plus the `Global\pf…-boot-<idx>`
// bootstrap mailbox for its pad index. Detaching therefore leaves the pads plugged in and
// the index taken: the next session — or a bring-up run beside this host — is denied that
// index until this thread finally returns, and *that* failure surfaces somewhere else
// entirely (see `pf_inject::pad_slots::PadCreateFault::IndexOwnedElsewhere`). An operator
// reading only the later error has no way back to this line unless it says so here.
tracing::warn!(
grace_s = SIDE_THREAD_JOIN_GRACE.as_secs(),
"audio/input threads did not exit after the connection closed — detaching them"
"audio/input threads did not exit after the connection closed — detaching them. This \
session's virtual gamepads are STILL HELD by the detached input thread (devnode + \
pad-index mailbox on Windows), so a pad create on the same index will be refused as \
already-owned until it returns"
);
}
// The capture (and our gamescope session's VirtualOutput) are gone by here. If this was the
+104 -9
View File
@@ -41,7 +41,7 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
cfg!(target_os = "linux"),
cfg!(target_os = "windows"),
);
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
degrade_xbox_identity(degrade_steam_on_conflict(degrade_if_no_uhid(chosen)))
}
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
@@ -49,9 +49,18 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
///
/// `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.
/// to X-Box 360 (never an error: a session without rich pads still streams).
///
/// The X-Box identities are now distinct on BOTH platforms: a uinput identity on Linux (360 /
/// One S), and a UMDF HID identity on Windows (360 → `045E:0B13`, One → `045E:02FD`, Elite →
/// `045E:0B22`). The Windows fold of One/Series into the 360 pad is gone with the reason for it —
/// it existed because the only Windows X-Box backend was the XUSB companion, which presents one
/// fixed 360 identity and cannot vary it. The Elite has no Linux identity (`PadIdentity` stops at
/// One S), so it folds there.
///
/// ⚠️ **This is compile-time only.** `PUNKTFUNK_XBOX_BACKEND=xusb` puts Windows back on the
/// companion at RUNTIME, which un-varies the identity again — that is [`degrade_xbox_identity`]'s
/// job, not this function's.
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref {
let want = match pref {
GamepadPref::Auto => env
@@ -63,9 +72,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// 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,
// One/Series: a real, distinct uinput identity on Linux, and — since the HID X-Box backend
// became the default — a distinct UMDF HID identity (`045E:02FD`) on Windows too.
GamepadPref::XboxOne if linux || windows => GamepadPref::XboxOne,
// Elite Series 2: Windows-only (UMDF device-type 6, `045E:0B22`). There is no Linux uinput
// Elite identity to fold onto, so it takes the `_` arm and lands on the 360 pad there.
GamepadPref::XboxElite if windows => GamepadPref::XboxElite,
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
// are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
@@ -221,6 +233,75 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// Runtime degrade for the two non-default Windows X-Box identities (One S / Elite Series 2): with
/// `PUNKTFUNK_XBOX_BACKEND=xusb` the session runs the XUSB companion, which presents ONE fixed
/// X-Box 360 identity and has no way to vary VID/PID — so the pad a player gets is a 360 pad no
/// matter what was asked for. Fold here so the `Welcome` echo says so.
///
/// This is a runtime check and [`pick_gamepad`] is a compile-time one, which is exactly the split
/// [`degrade_if_no_uhid`] already draws. Without it, asking for an Elite under the escape hatch
/// resolves to `xboxelite`, echoes `xboxelite`, and builds a 360 pad — the class of silent lie
/// `pad_motion_reaches` and the fold-logging in [`resolve_gamepad`] exist to prevent.
///
/// A no-op on every non-Windows host: `XboxElite` never survives `pick_gamepad` there, and
/// `XboxOne` is a genuine uinput identity on Linux.
#[cfg(target_os = "windows")]
fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
if matches!(chosen, GamepadPref::XboxOne | GamepadPref::XboxElite) && !windows_xbox_hid() {
tracing::warn!(
wanted = chosen.as_str(),
"PUNKTFUNK_XBOX_BACKEND=xusb selects the XUSB companion, which has one fixed X-Box 360 \
identity falling back to the 360 pad"
);
return GamepadPref::Xbox360;
}
chosen
}
#[cfg(not(target_os = "windows"))]
fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// Whether an Xbox-family pad should be built as a real **HID** device
/// ([`crate::inject::xbox_windows`]) instead of the **XUSB** companion
/// ([`crate::inject::gamepad`]). Windows only. **HID is the default**; set
/// `PUNKTFUNK_XBOX_BACKEND=xusb` to go back to the companion.
///
/// **Why HID is now the default.** The XUSB companion registers only `GUID_DEVINTERFACE_XUSB` and
/// exposes no HID collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and
/// WGI/GameInput cannot see it at all — only classic `XInputGetState` via xinput1_4's interface walk
/// does. That is what left a reporter with a dead controller for two weeks (2026-08-09) until they
/// switched the client to DualSense, a real HID pad.
///
/// This was an opt-in knob for exactly one reason: the HID pad could not reach classic XInput, so
/// defaulting to it would have traded a known-working path for an unproven one. **That objection is
/// gone.** `pf_gamepad.inx`'s `pfGamepadXbox` section now attaches the `xinputhid` bus filter
/// (`UpperFilters` + `DevicePropertyFlags=1`), and with it the HID pad is promoted exactly like real
/// hardware: measured on `.173` 2026-08-09 it gains the `IG_00` token and an XUSB interface, classic
/// XInput reads it live (full stick range and buttons), `XInputSetState` rumble round-trips, and it
/// keeps everything the XUSB companion never had — Steam, SDL, RawInput, DirectInput, `joy.cpl`.
/// ⇒ the HID backend is now a **superset** of the XUSB one, which is the condition the old comment
/// set for flipping.
///
/// ⚠️ `xusb` stays as an escape hatch because the promotion depends on Microsoft's inbox
/// `xinputhid.inf` and its hardware-id allow-list. If a Windows servicing update changes that, or a
/// box has a third-party filter on the stack, one env var restores the previous behaviour without a
/// reinstall.
///
/// 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.
#[cfg(target_os = "windows")]
pub(super) 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
// silently landing on the OLD path is the worse failure: it is invisible, and it is the
// path with no HID collection.
_ => true,
}
}
/// 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 {
@@ -239,6 +320,9 @@ pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
// 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);
// The XUSB escape hatch can only present a 360 identity, so the One S / Elite wishes fold when
// `PUNKTFUNK_XBOX_BACKEND=xusb` is set.
let chosen = degrade_xbox_identity(chosen);
match pref {
GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the
@@ -335,10 +419,21 @@ mod tests {
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.
// X-Box One: a distinct uinput identity on Linux AND a distinct UMDF HID identity
// (`045E:02FD`) on Windows. The old Windows fold to Xbox360 is deliberately gone — it
// existed only because the XUSB companion has one fixed 360 identity, and the HID backend
// is the default now. `degrade_xbox_identity` puts the fold back when the escape hatch
// `PUNKTFUNK_XBOX_BACKEND=xusb` is set; that is a runtime check this pure one can't make.
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);
assert_eq!(pick_gamepad(XboxOne, None, false, true), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false, false), Xbox360);
// X-Box Elite Series 2: Windows-only (UMDF device-type 6). No Linux uinput Elite identity
// exists, so it folds to the 360 pad there rather than pretending.
assert_eq!(pick_gamepad(XboxElite, None, false, true), XboxElite);
assert_eq!(pick_gamepad(Auto, Some("elite"), false, true), XboxElite);
assert_eq!(pick_gamepad(XboxElite, None, true, false), Xbox360);
assert_eq!(pick_gamepad(XboxElite, None, false, false), 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.
+215 -55
View File
@@ -123,6 +123,21 @@ struct Pads {
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
/// The HID-visible Xbox pad ([`crate::inject::xbox_windows`]) — used INSTEAD of `xbox360`'s
/// XUSB companion when [`super::gamepad::windows_xbox_hid`] says so. Never both at once: two
/// devices for one wire pad is the "the game sees two controllers" bug.
///
/// Three managers because the HID backend now has three IDENTITIES (Xbox Wireless `045E:0B13`,
/// Xbox One S `045E:02FD`, Elite Series 2 `045E:0B22`) and a manager is bound to one at
/// construction. They are otherwise the same backend — same codec, same report descriptor,
/// same rumble plane — so the split is purely so a mixed session can present, say, a Series
/// pad on slot 0 and an Elite on slot 1.
#[cfg(target_os = "windows")]
xbox_hid: Option<crate::inject::xbox_windows::XboxWindowsManager>,
#[cfg(target_os = "windows")]
xbox_one_hid: Option<crate::inject::xbox_windows::XboxWindowsManager>,
#[cfg(target_os = "windows")]
xbox_elite_hid: Option<crate::inject::xbox_windows::XboxWindowsManager>,
#[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
@@ -165,6 +180,12 @@ impl Pads {
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
xbox_hid: None,
#[cfg(target_os = "windows")]
xbox_one_hid: None,
#[cfg(target_os = "windows")]
xbox_elite_hid: None,
#[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None,
@@ -291,6 +312,38 @@ impl Pads {
.steamdeck_win
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
.handle(ev),
// The Xbox pads, as real HID devices rather than the XUSB companion. This is now the
// DEFAULT (see `windows_xbox_hid`; `PUNKTFUNK_XBOX_BACKEND=xusb` reverts it). It is no
// longer a trade: with the `xinputhid` bus filter the INF attaches, the HID pad keeps
// classic XInput AND gains everything XUSB never had — Steam, SDL, RawInput,
// DirectInput, `joy.cpl`, WGI — plus rumble, which the XUSB path could not source.
//
// Three arms, one per identity. The `windows_xbox_hid()` guard stays on each: with the
// escape hatch set, `degrade_xbox_identity` has already folded One/Elite to Xbox360, so
// only Xbox360 can reach here and it must fall through to the XUSB companion below.
#[cfg(target_os = "windows")]
GamepadPref::Xbox360 if super::gamepad::windows_xbox_hid() => self
.xbox_hid
.get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => self
.xbox_one_hid
.get_or_insert_with(|| {
crate::inject::xbox_windows::XboxWindowsManager::with_backend(
crate::inject::xbox_windows::XboxWinProto::one_s(),
)
})
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::XboxElite if super::gamepad::windows_xbox_hid() => self
.xbox_elite_hid
.get_or_insert_with(|| {
crate::inject::xbox_windows::XboxWindowsManager::with_backend(
crate::inject::xbox_windows::XboxWinProto::elite(),
)
})
.handle(ev),
_ => self
.xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
@@ -408,12 +461,18 @@ impl Pads {
}
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
/// force-feedback on the universal plane (every backend, tagged with its own pad index) as
/// `(pad, low, high, left_trigger, right_trigger)`; `hidout` carries rich feedback (lightbar /
/// player LEDs / adaptive triggers) for the UHID/UMDF pads. The `&mut` closure re-borrows
/// satisfy `FnMut` for each backend.
///
/// Only the Windows HID Xbox backends (`xbox_hid` and its two identity siblings) can ever
/// report non-zero trigger levels — no
/// other backend's source packet has a field for them (see `PadFeedback::rumble`), so they pass
/// zeros and the v3 datagram they produce is a v2 datagram with a zero tail.
fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut rumble: impl FnMut(u16, u16, u16, u16, u16),
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
if let Some(m) = &mut self.xbox360 {
@@ -451,6 +510,19 @@ impl Pads {
}
#[cfg(target_os = "windows")]
{
// All three HID Xbox identities. Rumble only — an Xbox pad has no rich-feedback plane
// (no lightbar / adaptive triggers), same as its XUSB sibling above. Missing one of
// these is silent: the pad works and simply never rumbles.
for m in [
&mut self.xbox_hid,
&mut self.xbox_one_hid,
&mut self.xbox_elite_hid,
]
.into_iter()
.flatten()
{
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout);
}
@@ -731,26 +803,58 @@ const RUMBLE_STOP_BURST: u8 = 2;
/// life of the connection because the client gates on it with a wrapping half-space compare and
/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is
/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`].
fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) {
*state = (0, 0);
fn clear_pad_feedback(state: &mut RumbleLevels, seen: &mut bool, stop_burst: &mut u8) {
*state = (0, 0, 0, 0);
*seen = false;
*stop_burst = 0;
}
/// One pad's four motor levels as the 0xCA plane orders them:
/// `(low, high, left_trigger, right_trigger)`, all `0..=0xFFFF`. Kept as one value rather than four
/// parallel arrays because they are a single statement of the pad's feedback state at one instant —
/// the same reason they share one `seq` and one TTL on the wire.
type RumbleLevels = (u16, u16, u16, u16);
/// Is this pad's feedback fully silent? **All four** motors, and that is the whole point of it
/// being a named predicate rather than an inline comparison repeated at each site.
///
/// Every "is this pad quiet?" decision in the rumble path routes through here: whether to log the
/// silent→active transition, whether to arm the post-stop burst, and — the one that decides
/// whether the feature works at all — whether the envelope gets a live TTL or the `0` that means
/// *stop*. Written as a two-field test, a trigger-only rumble (the normal shape of
/// impulse-trigger content: racing titles drive the triggers continuously while the handles stay
/// near-silent) is stamped `ttl = 0`, the client reads an already-expired lease and silences on
/// arrival, and nothing anywhere logs an error. See
/// [`tests::a_trigger_only_rumble_gets_a_live_ttl`].
fn rumble_silent(lv: RumbleLevels) -> bool {
lv == (0, 0, 0, 0)
}
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
/// v3 form (`[level][seq][ttl_ms][trigger levels]`, the default) or the legacy v1 level datagram
/// (the `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
///
/// v3 goes out **unconditionally** while the envelope is on — not "only when a trigger level is
/// non-zero". A wire form that depends on history is how you get a bug that reproduces only after a
/// specific sequence of events, and the four extra bytes cost nothing: a client that predates v3
/// reads the 10-byte prefix and ignores them.
///
/// ⚠️ The bisect hatch drops to v1, which takes trigger rumble down with it (v1 has no tail at
/// all). That is correct for a hatch whose job is to reproduce the pre-envelope wire, but it means
/// "trigger rumble stopped working" is an expected symptom of setting it — do not bisect a trigger
/// bug into this hatch and conclude the hatch fixed it.
fn send_rumble(
conn: &quinn::Connection,
envelope_on: bool,
pad: u16,
low: u16,
high: u16,
lv: RumbleLevels,
seq: u8,
ttl_ms: u16,
) {
let (low, high, lt, rt) = lv;
let d: Vec<u8> = if envelope_on {
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
punktfunk_core::quic::encode_rumble_datagram_v3(pad, low, high, seq, ttl_ms, lt, rt)
.to_vec()
} else {
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
};
@@ -767,11 +871,14 @@ fn send_rumble(
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
/// across sessions.
///
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
/// Rumble is emitted as self-terminating 0xCA v3 envelopes
/// (`[level][seq][ttl_ms][trigger levels]`): the host owns the timeline, renewing an active level
/// every ~`RUMBLE_TTL_MS × 3/10` ms and letting an abandoned one expire client-side, so "stuck
/// rumble" is inexpressible on the wire (see `punktfunk-planning/design/rumble-envelope-plan.md`
/// and `design/trigger-rumble-plane.md`). The four motors share one `seq` and one TTL, so the
/// trigger pair inherits the whole envelope apparatus unchanged.
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to legacy v1 level datagrams + the flat 500 ms refresh
/// (bisect hatch — which drops trigger rumble with it, see [`send_rumble`]).
pub(super) fn input_thread(
rx: std::sync::mpsc::Receiver<ClientInput>,
conn: quinn::Connection,
@@ -792,14 +899,20 @@ pub(super) fn input_thread(
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
// Rumble self-terminating envelopes (0xCA v3). Each non-zero level is authorized for
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
//
// `rumble_state` holds ALL FOUR levels (see `RumbleLevels`), and every "is this pad silent?"
// test below is an all-four-zero test for one specific reason: a trigger-only rumble — the
// normal shape of impulse-trigger content, since racing titles drive the triggers continuously
// against near-silent handles — would otherwise be stamped `ttl = 0`, which the client reads as
// an instantly-expired lease. That is trigger rumble that never plays, with no error anywhere.
let mut rumble_state = [(0u16, 0u16, 0u16, 0u16); MAX_WIRE_PADS];
let mut rumble_seen = [false; MAX_WIRE_PADS];
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
@@ -1014,43 +1127,50 @@ pub(super) fn input_thread(
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
// plane; rich/raw HID feedback → 0xCD.
pads.pump(
|pad, low, high| {
|pad, low, high, lt, rt| {
let lv: RumbleLevels = (low, high, lt, rt);
let silent = rumble_silent(lv);
let idx = pad as usize;
if idx < MAX_WIRE_PADS {
let prev = rumble_state[idx];
// Log the silent→active transition (once per buzz) so a live test can tell
// "host never gets rumble from the game" apart from "client doesn't render it".
if prev == (0, 0) && (low != 0 || high != 0) {
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
// It carries `lt`/`rt` because it is the attribution line for exactly the
// trigger case too — without them a "triggers never buzzed" report cannot be
// split into "the host never saw them" and "the client never rendered them".
if rumble_silent(prev) && !silent {
tracing::debug!(
pad,
low,
high,
lt,
rt,
"rumble: forwarding to client (0xCA)"
);
}
rumble_state[idx] = (low, high);
rumble_state[idx] = lv;
rumble_seen[idx] = true;
// Bump the reorder counter on every change, then arm the stop burst on a
// transition to zero (so a lost stop still reaches a legacy client) and clear
// it when the game re-asserts a non-zero level.
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
if (low, high) == (0, 0) {
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
if silent {
rumble_stop_burst[idx] = if !rumble_silent(prev) {
RUMBLE_STOP_BURST
} else {
0
};
} else {
rumble_stop_burst[idx] = 0;
}
let ttl = if (low, high) == (0, 0) {
0
} else {
rumble_ttl_ms
};
send_rumble(
&conn,
rumble_envelope_on,
pad,
low,
high,
rumble_seq[idx],
ttl,
);
// A pad with ANY of its four motors asserted gets a live lease. Testing only
// `(low, high)` here would stamp a trigger-only rumble `ttl = 0` — an
// already-expired lease the client silences on arrival.
let ttl = if silent { 0 } else { rumble_ttl_ms };
send_rumble(&conn, rumble_envelope_on, pad, lv, rumble_seq[idx], ttl);
} else {
// Out-of-range pad (a backend never produces these) — forward without gating.
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
send_rumble(&conn, rumble_envelope_on, pad, lv, 0, rumble_ttl_ms);
}
},
|h| {
@@ -1070,27 +1190,21 @@ pub(super) fn input_thread(
if !rumble_seen[i] {
continue;
}
let (low, high) = rumble_state[i];
if (low, high) != (0, 0) {
let lv = rumble_state[i];
if !rumble_silent(lv) {
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(
&conn,
true,
i as u16,
low,
high,
rumble_seq[i],
rumble_ttl_ms,
);
send_rumble(&conn, true, i as u16, lv, rumble_seq[i], rumble_ttl_ms);
} else if rumble_stop_burst[i] > 0 {
rumble_stop_burst[i] -= 1;
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
send_rumble(&conn, true, i as u16, (0, 0, 0, 0), rumble_seq[i], 0);
}
}
} else {
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
for (i, &(low, high)) in rumble_state.iter().enumerate() {
// Legacy: re-send the current level of every seen pad every 500 ms (v1). The
// trigger levels are dropped here by construction — v1 has no tail (see
// `send_rumble`).
for (i, &(low, high, _, _)) in rumble_state.iter().enumerate() {
if rumble_seen[i] {
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
let _ = conn.send_datagram(d.to_vec().into());
@@ -1259,11 +1373,12 @@ mod tests {
assert_eq!(gate, Some(100));
// The pad is unplugged mid-buzz: the lease is cleared, the counter is not.
let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST);
let (mut state, mut seen, mut burst) =
((0x1234, 0x5678, 0x9ABC, 0xDEF0), true, RUMBLE_STOP_BURST);
clear_pad_feedback(&mut state, &mut seen, &mut burst);
assert_eq!(
(state, seen, burst),
((0, 0), false, 0),
((0, 0, 0, 0), false, 0),
"lease not cleared"
);
@@ -1305,4 +1420,49 @@ mod tests {
assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
/// The single most likely way to ship trigger rumble broken (design/trigger-rumble-plane.md
/// §5): a rumble that drives ONLY the impulse triggers must still get a live lease.
///
/// The pre-existing silence test was `(low, high) == (0, 0)`, and a trigger-only level passes
/// it. Stamped `ttl = 0`, the envelope reaches the client as an already-expired lease, which
/// it silences on arrival — trigger rumble that never plays, with no error on either side.
/// Drives the real predicate and the real encoder/decoder pair, so it fails if either moves.
#[test]
fn a_trigger_only_rumble_gets_a_live_ttl() {
use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v3};
// What a racing title's impulse-trigger stream looks like: handles at rest throughout.
let trigger_only: RumbleLevels = (0, 0, 0x8000, 0);
assert!(
!rumble_silent(trigger_only),
"a trigger-only level was read as silence — the ttl=0 trap"
);
let ttl = if rumble_silent(trigger_only) {
0
} else {
RUMBLE_TTL_MS
};
let d = encode_rumble_datagram_v3(0, 0, 0, 1, ttl, trigger_only.2, trigger_only.3);
let u = decode_rumble_envelope(&d).expect("v3 envelope decodes");
assert_eq!(
u.envelope.expect("v3 carries the v2 tail").ttl_ms,
RUMBLE_TTL_MS,
"trigger-only rumble was stamped with a dead lease"
);
assert_eq!((u.left_trigger, u.right_trigger), (0x8000, 0));
assert_eq!((u.low, u.high), (0, 0), "handles stay at rest");
// The reserved stop is still expressible, and is still the ONLY thing that gets ttl = 0.
assert!(rumble_silent((0, 0, 0, 0)));
for lv in [
(1, 0, 0, 0),
(0, 1, 0, 0),
(0, 0, 1, 0),
(0, 0, 0, 1),
(0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF),
] {
assert!(!rumble_silent(lv), "{lv:?} must not read as a stop");
}
}
}
+50 -9
View File
@@ -46,14 +46,14 @@ fn run_capture(cmd: &str, args: &[&str]) -> String {
.unwrap_or_default()
}
// ── `driver install [--gamepad] --dir <stage>` / `driver uninstall [--gamepad]` ────────────────
// ── `driver install [--gamepad] --dir <stage>` / `driver uninstall [--gamepad|--audio]` ────────
pub fn driver_main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("install") => driver_install(&args[1..]),
Some("uninstall") => driver_uninstall(&args[1..]),
_ => bail!(
"usage: punktfunk-host driver install --dir <stage> [--gamepad]\n\
\x20 punktfunk-host driver uninstall [--gamepad]"
\x20 punktfunk-host driver uninstall [--gamepad|--audio]"
),
}
}
@@ -409,16 +409,25 @@ fn remove_pad_devnodes() {
}
}
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// ── `driver uninstall [--gamepad|--audio]` ──────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
// package, or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we
// never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side
// (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf
// CONTENT for our driver names, then passed to pnputil by file name.
// virtual devices survived an uninstall. Removes the pf-vdisplay device node(s) + driver package,
// or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped), or (--audio) the audio devnodes
// the HOST mints at runtime — the same complaint one layer up, since those are created by the
// running host rather than by any driver payload the installer laid down. Locale-safe by
// construction: we never parse pnputil's localized LABELS — devices are matched on the
// un-localized VALUE side (instance IDs / device IDs / registry markers), and driver packages are
// found by scanning %WINDIR%\INF\oem*.inf CONTENT for our driver names, then passed to pnputil by
// file name.
fn driver_uninstall(args: &[String]) -> Result<()> {
// The audio leg touches no driver package and no certificate — it removes devnodes the host
// minted on Valve's drivers — so it returns before the cert purge below rather than making
// that purge run a third time per uninstall.
if flag_present(args, "--audio") {
return uninstall_audio_devices();
}
let gamepad = flag_present(args, "--gamepad");
let (what, res) = if gamepad {
("gamepad", uninstall_gamepad())
@@ -437,6 +446,38 @@ fn driver_uninstall(args: &[String]) -> Result<()> {
Ok(())
}
/// Remove the "Punktfunk Speakers"/"Punktfunk Microphone" endpoints and the per-pad DualSense
/// speaker endpoints the running host minted — the audio half of the surviving-virtual-device
/// complaint. Must run AFTER `service uninstall`: a live host re-mints them on its next wiring
/// pass, which would make this sweep look like it did nothing.
///
/// Never removes Steam's streaming-audio DRIVERS. Ours are extra devnodes riding on drivers that
/// belong to Steam and that the user's own Remote Play still needs; the sweep is marker-matched
/// (see `audio::devnode_cleanup`) precisely so it can tell the two apart.
fn uninstall_audio_devices() -> Result<()> {
match crate::audio::devnode_cleanup::purge() {
Ok(r) if r.devnodes == 0 && r.devnode_failures == 0 => {
println!("no punktfunk audio devices to remove")
}
Ok(r) => {
println!(
"removed {} punktfunk audio device(s), {} endpoint record(s)",
r.devnodes, r.endpoint_records
);
if r.devnode_failures > 0 {
eprintln!(
"warning: {} punktfunk audio device(s) could not be removed — they can be \
deleted from Device Manager (View Show hidden devices)",
r.devnode_failures
);
}
}
// Best-effort like every other leg: an enumeration that fails must not fail the uninstall.
Err(e) => eprintln!("warning: audio device cleanup: {e:#}"),
}
Ok(())
}
fn uninstall_pf_vdisplay() -> Result<()> {
// 1. Remove the ROOT device node(s) the installer created via nefconc (leaving them would keep
// a ghost "punktfunk virtual display" in Device Manager forever — the exact complaint).
+9 -5
View File
@@ -57,17 +57,21 @@ sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see b
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
it), or this box autologins into Steam **Gaming Mode** and you want the host to take that session
over at the client's resolution:
```sh
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
```
That is a second group on purpose. It grants write access to the usbip `attach` file, which
materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is
routinely told to join. Join it only on a machine you trust. Without it, everything else still
works and the pad simply arrives as an ordinary Xbox 360 controller.
routinely told to join. Join it only on a machine you trust. On a plain desktop host, everything
else still works without it and the pad simply arrives as an ordinary Xbox 360 controller; on a
Gaming Mode box the takeover silently degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
+10 -6
View File
@@ -126,17 +126,21 @@ ujust add-user-to-input-group
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
permission, not a client problem.)
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
`punktfunk``usermod` is fine here, because unlike `input` this group is ours and the sysext
creates it on merge:
Then join `punktfunk``usermod` is fine here, because unlike `input` this group is ours and the
sysext creates it on merge:
```sh
sudo usermod -aG punktfunk "$USER" # then log out and back in
```
It is a separate group on purpose: it gates the usbip `attach` file, which can materialise
arbitrary emulated USB hardware, so it is not folded into the group everyone is told to join for
gamepads. Skip it and the pad arrives as an ordinary Xbox 360 controller instead.
This box **is** a Gaming Mode box, so that group is not optional in practice: it authorizes the
helper the host uses to stop the display manager when it takes the Gaming Mode session over at your
client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller**
(paddles, trackpads, gyro) attaches through. It is a separate group on purpose — writing that file
can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller, and the
takeover degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
## Configure
+9 -5
View File
@@ -93,17 +93,21 @@ sudo dnf install punktfunk
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts
it), or this box autologins into Steam **Gaming Mode** (Nobara and friends) and you want the host
to take that session over at the client's resolution:
```sh
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply)
```
That is a second group on purpose: it grants write access to the usbip `attach` file, which
materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is
routinely told to join. Join it only on a machine you trust. Skip it and the pad simply arrives as
an ordinary Xbox 360 controller.
routinely told to join. Join it only on a machine you trust. Skip it on a plain desktop host and
the pad simply arrives as an ordinary Xbox 360 controller; skip it on a Gaming Mode box and the
takeover silently degrades to mirroring the box's own screen — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
Updates later are just `sudo dnf upgrade punktfunk`, followed by
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
+38 -12
View File
@@ -40,16 +40,37 @@ the [Bazzite template](/docs/bazzite) ships with **attach** chosen instead.
### Nobara and other autologin display managers
The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that
depends on the display manager driving the autologin:
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that
session is a display-manager autologin, it has to stop the **display manager** too, for the length
of the stream. That is a privileged operation, and the privilege is granted to one group.
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
itself for the length of the stream and restart it afterwards, which needs privilege. The
packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`, or
`/usr/lib/punktfunk/pf-dm-helper` from the Arch package) behind its own polkit action
(`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper
> runs for members of that group and for nobody else, so this one command is what authorizes it:
>
> ```sh
> sudo usermod -aG punktfunk "$USER" # then log out and back in
> ```
>
> Your package created the group at install time and put **nobody** in it, on purpose: it also
> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present
> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust.
> Skip it and nothing fails loudly. Every takeover degrades to mirroring the box's own session
> (below), which on a box whose panel is off reads as a black screen on every connect. The host
> checks this at startup on any box that will need the takeover and says so in its log; the
> symptom side is [Game Mode: black screen on
> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
How the takeover gets that privilege depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the
grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole
stream, which churns logind sessions and can starve the game.
- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin
start-limit-kills the display manager), so the host stops the display manager itself and
restarts it afterwards. The packages ship that privilege: a root helper
(`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch
package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically
when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the
`display-manager.service` symlink points at, the same class of local-seat operation these
distros already authorize for their own session switcher (Nobara's `os-session-select`).
@@ -71,8 +92,11 @@ depends on the display manager driving the autologin:
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
what happened; check the host log for "managed takeover unavailable"). If the display-manager
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
what happened; check the host log for "managed takeover unavailable"). That log line now quotes
the privileged path's own reason for refusing, so read it before changing anything: by far the
most common one is `not in the 'punktfunk' group`, which the group command above fixes and
neither a reinstall nor a polkit rule does. If the display-manager restart ever loses its
privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback.
**Lingering is required here**, and the host turns it on for you the first time it takes the box
@@ -81,7 +105,9 @@ depends on the display manager driving the autologin:
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
ask; `loginctl disable-linger "$USER"` reverts it.
ask; `loginctl disable-linger "$USER"` reverts it. (A host with no login session of its own turns
lingering on through the *same* helper, so a missing group grant surfaces here first — the log
says "enabling lingering failed" and then quotes the same reason.)
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
+10 -5
View File
@@ -156,11 +156,16 @@ you; on NixOS the module does steps 1 and 2, and [NixOS](#nixos) above has the u
command differs per distro — see your guide (`usermod -aG input "$USER"`, or `ujust
add-user-to-input-group` on Bazzite).
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
`punktfunk`: `sudo usermod -aG punktfunk "$USER"`. Your package created that group at install
time; it gates the usbip nodes that pad attaches through, and it is separate from `input` on
purpose, because writing them can present arbitrary emulated USB hardware. Join it only on a
machine you trust — skipping it costs you nothing but that one pad type.
Also join `punktfunk``sudo usermod -aG punktfunk "$USER"`, then log out and back in — if
**either** of these is true: you want the **virtual Steam Deck controller** (paddles,
trackpads, gyro), or this box autologins into Steam **Gaming Mode** and you want the host to
take that session over at your client's resolution
([gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers)). Your package created
that group at install time and left it empty. It gates the usbip nodes the pad attaches through
*and* the helper that stops the display manager for a takeover, and it is separate from `input`
on purpose, because writing those nodes can present arbitrary emulated USB hardware — so join it
only on a machine you trust. On a plain desktop host that streams no Gaming Mode, skipping it
costs you nothing but that one pad type.
2. Put your `host.env` in place, then start the host. Every Linux package ships a systemd **user**
unit, so you don't run the host by hand — but that unit reads `~/.config/punktfunk/host.env` and
won't start until the file exists. Each package ships a template to copy; your distro and desktop
@@ -133,7 +133,11 @@ disable, and the session unit differ per compositor, so each is documented on it
- GNOME: [GNOME → Headless session](/docs/gnome#headless-session).
- KDE Plasma: [KDE → Headless session](/docs/kde#headless-session).
- Steam / gamescope: [gamescope](/docs/gamescope) — the host launches its own session per client, so
there's no separate session unit.
there's no separate session unit. A headless box that autologins into **Gaming Mode** needs one
more thing: your user in the `punktfunk` group (`sudo usermod -aG punktfunk "$USER"`, then log
out and back in). Without it the host cannot stop the display manager to take that session over,
so every connect quietly mirrors the box's own screen — which, headless, is a black one. See
[gamescope → autologin display managers](/docs/gamescope#nobara-and-other-autologin-display-managers).
Once a session comes up at boot, enable the host user service (section A) and reboot. The host comes up
on that session.
+42
View File
@@ -198,6 +198,44 @@ Current hosts detect the display-manager flavor and never mask the session unit
[gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full
managed takeover on these boxes (without it the host mirrors Game Mode instead).
## Game Mode: black screen on connect, or the stream is stuck at the box's resolution
You connect to a box that autologins into Steam **Gaming Mode** and get a black picture every time
— or a picture at the box's own resolution instead of the one your client asked for, with the box's
monitor still lit. Nothing errors: the client connects, the host logs no failure, no unit is failed.
The managed takeover is being refused and the host is falling back to mirroring the box's own
session. On a box whose panel is off (a headless appliance, a TV that's been switched away) there
is nothing to mirror, so the fallback is a black screen. Almost always the cause is **group
membership**: the takeover stops the display manager through a root helper, and that helper serves
members of the `punktfunk` group only.
```sh
id -nG | tr ' ' '\n' | grep -x punktfunk # are you in it?
journalctl --user -u punktfunk-host | grep -iE "punktfunk. group|takeover unavailable"
```
The host also checks at startup on any box that will need the takeover, so a fresh
`systemctl --user restart punktfunk-host` puts the answer at the top of the log. The fix is one
command and a fresh login:
```sh
sudo usermod -aG punktfunk "$USER" # then log out and back in
```
> **Read the reason the log quotes before doing anything else.** The takeover has three other ways
> to be refused — no packaged helper (a tarball or source install), no polkit on the box, and
> polkit denying the action — and the host now prints which one it hit, verbatim from the
> privileged path. Hosts up to 0.27.0 printed a fixed guess instead ("reinstall the punktfunk
> package, or install the display-manager polkit rule from the docs"), and on the group case both
> of those suggestions were dead ends: neither adds anyone to a group.
Two things this is *not*: it isn't the [pad group problem](#the-pad-works-but-arrives-as-an-xbox-360-controller-instead-of-a-steam-deck)
(same group, different symptom), and it isn't lingering — though a host with no login session of
its own enables lingering through the same helper, so an unjoined user often sees "enabling
lingering failed" first. Both are covered in
[gamescope → autologin display managers](/docs/gamescope#nobara-and-other-autologin-display-managers).
## Session fails right after editing host.env
- Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact
@@ -275,6 +313,10 @@ the reliable way to get one.
Joining the group is optional, and there is a real reason it is not automatic: writing that
`attach` file materialises an arbitrary emulated USB device. Skip it on a machine you share.
It is not only the pad, though: the same group authorizes the helper that stops the display manager
for a managed **Gaming Mode** takeover, so on a box that autologins into Game Mode, skipping it also
costs you [the takeover](#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution).
## Copy and paste between host and client does nothing
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
+8 -5
View File
@@ -111,17 +111,20 @@ re-login so the new group membership takes effect:
sudo usermod -aG input "$USER" # re-login to apply
```
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
`punktfunk`. That pad reaches games as a real USB device over usbip — which is what makes Steam
Input adopt it — and the group gating those nodes is deliberately separate from `input`, because
Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller**
(paddles, trackpads, gyro), or this box autologins into Steam **Gaming Mode** and you want the host
to take that session over at the client's resolution. That pad reaches games as a real USB device
over usbip — which is what makes Steam Input adopt it — and the same group authorizes the helper
that stops the display manager for a takeover. It is deliberately separate from `input`, because
writing the usbip `attach` file can materialise arbitrary emulated USB hardware:
```sh
sudo usermod -aG punktfunk "$USER" # re-login to apply
```
Join it only on a machine you trust. Skip it and everything else still works; the pad just arrives
as an ordinary Xbox 360 controller.
Join it only on a machine you trust. On a plain desktop host, skipping it costs you nothing but
that one pad type; on a Gaming Mode box the takeover silently degrades to mirroring the box's own
screen — see [gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
## 4. Check it installed
+27 -1
View File
@@ -484,6 +484,29 @@ Pull these on your feedback thread (or poll with `timeout_ms = 0`). Same
Amplitudes 0..0xFFFF; `(0,0)` = stop. `ttl_ms` is a host-supplied self-terminating lease — render
the level for that long unless renewed; `PUNKTFUNK_RUMBLE_NO_TTL` means fall back to your own
staleness timeout. (The v1 `_next_rumble` drops the TTL — prefer v2.)
- **Rumble, policy-engine form** — `punktfunk_connection_next_rumble_cmd(c, &pad, &low, &high,
&backstop_ms, timeout)` hands you **effective commands** instead of raw wire state: the core owns
lease expiry, legacy-host staleness and close-drain zeros, so you apply what you are told and keep
no staleness policy of your own. `backstop_ms` is a safety net for APIs that take a duration
(ignored by explicit-stop APIs; `0` on stops). Pick **one** rumble API per connection — they
consume the same plane.
- **Rumble with trigger motors** (ABI ≥ 18) — `punktfunk_connection_next_rumble_cmd2(c, &pad, &low,
&high, &left_trigger, &right_trigger, &backstop_ms, timeout)` is the same command with the two
Xbox impulse-trigger levels, on the same 0..0xFFFF scale; a stop is all four at zero. It is a
**new symbol, not a wider `_cmd`**`_cmd` keeps its signature and its two-handle view forever,
so existing embedders need no change. Render the trigger pair only on a pad that has trigger
motors (Windows: `IGameInputDevice::SetRumbleState`'s `leftTrigger`/`rightTrigger`, or WGI's
`GamepadVibration`; SDL: `SDL_RumbleGamepadTriggers` gated on
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`; Apple: `GCHapticsLocalityLeftTrigger` /
`…RightTrigger`) and **drop them otherwise — never fold them into a handle motor**: impulse-trigger
content is continuous (a racing title drives it off engine RPM and tyre slip while the handles stay
near silent), so folding drones a handle flat-out for the whole race at a level the game never
asked for. A pad without trigger motors is the common case, not an error; do not log per command.
Note that on a trigger-driving host a `_cmd` caller now sees commands carrying `low == high == 0`
while only the triggers run — correct (its motors *should* be silent) and idempotent.
Nothing sources non-zero trigger levels end to end yet: only the Windows HID Xbox pad has the
channel at all (XInput's `XINPUT_VIBRATION` and evdev's `FF_RUMBLE` each have exactly two
members), and it is reachable only through GameInput/WGI.
- **DualSense HID output**`punktfunk_connection_next_hidout(c, &out, timeout)`. `out.kind` selects
lightbar RGB / player LEDs / adaptive-trigger effect / trackpad haptic. Replay on a real DualSense
via the platform's controller API. Only a DualSense-backend session emits these.
@@ -629,7 +652,10 @@ shared-mode render. Request 6/8 channels at connect for surround.
and emit `GAMEPAD_BUTTON`/`GAMEPAD_AXIS` events. Because a real Xbox pad drives this, connect with
`PUNKTFUNK_GAMEPAD_XBOXONE` for matching glyphs. Rumble comes **back** from the host — feed
`punktfunk_connection_next_rumble2` into `IGameInputDevice::SetRumbleState` (map `low`
low-frequency, `high`→high-frequency motors).
low-frequency, `high`→high-frequency motors). `GameInputRumbleParams` has two more members,
`leftTrigger`/`rightTrigger`, and this is the one platform API that can drive them: use
`punktfunk_connection_next_rumble_cmd2` (ABI ≥ 18) instead and fill all four. The host can only
ever source non-zero trigger levels from its Windows HID Xbox pad, so expect zeros elsewhere.
**Skeleton (C++):**
+2 -1
View File
@@ -34,7 +34,7 @@ Most people need to do nothing. Check this list if any of it applies to you.
- **Audio catches jitter before you can hear it.** The rule that decides how much sound to keep buffered only ever learned from failures you could already hear: it waited for three audible dropouts before deepening the buffer, and it re-tested a shallower one every few quiet seconds, paying for a wrong guess with a click, forever. It now reads the near-misses nobody hears, backs off after a probe that fails, and refills in one go rather than limping. Simulating ten minutes of a Wi-Fi power-saving pattern went from roughly two thousand audible events to nine. Update the client.
- **A gamescope session says so when its refresh rate has been lost.** If something in the session's own configuration drops the setting that carries it, the stream still runs and still looks right while the game underneath is capped to 60 — which is exactly the kind of fault that costs a week to find. It is now one line in the log.
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 1518 ms at 95% game load, with the stream's frame rate collapsing along with it. The switch that asks the graphics driver for priority had never actually been applied on Linux; it is now, and the package grants the host the permission that switch needs. Alongside it, the encoder can now work on two frames at once and the capture path asks your desktop for enough buffers to keep up, where before it took whatever it was given and never even expressed a preference.
- **The low-latency wavelet codec got a serious round of work on Linux hosts.** It encodes on the same graphics cores your game is using, so under heavy load it was being crowded out — the encode step measured around 2 ms idle and 1518 ms at 95% game load, with the stream's frame rate collapsing along with it. The encoder can now work on two frames at once, and the capture path asks your desktop for enough buffers to keep up instead of taking whatever it was handed without ever expressing a preference. The switch that asks your graphics card to put that work ahead of the game's had also never been applied on Linux, and now is — though it stays dormant on an ordinary install, because switching it on requires a system privilege that turns out to stop KDE recognising the host at all. There is more on that below.
- **Jumbo frames can now be proven rather than hoped for.** The whole path was dead code: the discovery that was supposed to find a larger packet size could never settle above the ordinary limit, so the setting that grows mid-stream was unreachable on every path that has ever existed. A network that genuinely carries big packets is now detected, and a wavelet session starts at the large size instead of never getting there — around six times fewer packets per frame. Still opt-in, on both ends.
- **The configuration documentation caught up with 0.25**, including the jumbo-frame option and several other settings that had shipped with nothing written about them.
@@ -42,6 +42,7 @@ Most people need to do nothing. Check this list if any of it applies to you.
- **Bluetooth headphones got no game audio on iPhone and iPad.** With the microphone on — which is the default — the app was forcing output to the phone's own speaker, and that override outranks a Bluetooth headset. Wired headphones beat it, which is why plugging in a cable made it look correct. Turning the microphone off was the accidental workaround people found. Audio now goes to whatever you have connected, and dropping a headset mid-stream no longer lands on the earpiece. Update the client.
- **On a Steam Deck, the Steam menu and the Quick Access Menu also moved the game.** Both are driven by the same physical controller the client is forwarding, so opening either one played the game behind it at the same time — a second, invisible player picking things up and walking into walls while you browsed. Steam masks a normal game here and cannot mask this one, because the client deliberately forwards your real controller rather than Steam's stand-in, which has no gyro, trackpads or paddles. The stream now stops forwarding while an overlay has the controller, and hands it back without the button that dismissed the menu firing in the game. Update the client.
- **Streaming a KDE desktop keeps working.** An interim build of this release gave the host an extra system privilege, so that the wavelet encoder could ask your graphics card for priority. On KDE the side effect was total: KDE decides whether it trusts a program by looking up which file it is running from, the system refuses that lookup for any program holding a privilege, and so KDE stopped recognising the host at all — desktop streaming failed outright, complaining about a missing screen-capture interface, and it survived a clean reinstall of both host and client. Reported from CachyOS on both NVIDIA and AMD. No Linux package grants that privilege any more, on any of the five ways we ship, and upgrading strips it from a machine that already has it. If a host somehow holds one anyway, the error now names it and gives you the command that undoes it, instead of blaming a missing desktop file.
- **One capture timeout could slow a Linux host down for good.** Two very different problems shared a single switch: a graphics driver that genuinely cannot handle what your desktop produces, and a desktop that simply happened to be restarting. The second was being treated as permanently as the first, so a single moment of bad timing put that host on the slow capture path for every session until the process was restarted — including sessions against a completely different desktop that had never failed at anything, and with nothing at all in the log. The two now have the lifetimes they should, and a capture that works credits the budget back.
- **A wavelet-codec session could quietly fall back to slow capture and log nothing whatsoever.** The warning was asking a host-wide question about a per-session decision, so a degraded host and a healthy one produced identical logs while one of them touched every pixel on the processor.
- **"Full chroma" could cost a Steam Deck its codec.** The client advertised the feature on the strength of the setting alone, with nothing checking whether the device could decode it — and no AMD hardware can. The host grants it on HEVC only, so a Deck with the switch on lost HEVC entirely and reconnected on H.264. It looked intermittent because it is a per-profile setting: a "Work" profile lost HEVC where "Game" kept it, on the same machine and the same host. The client now asks the graphics driver the same question the decoder will, so the advertisement and what actually works cannot disagree.
+91 -4
View File
@@ -83,7 +83,18 @@
// connection was simply lost. Purely a read of state the core already had: no new call is required
// of an embedder, a client that never calls it is unchanged, and the host sends exactly the same
// bytes either way, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 17
// v18: added `punktfunk_connection_next_rumble_cmd2` — the policy engine's rumble command with the
// two Xbox impulse-trigger motor levels off the 0xCA v3 tail
// (`design/trigger-rumble-plane.md`), which the fixed out-params of
// `punktfunk_connection_next_rumble_cmd` have no room for. A NEW symbol, not a widened one: an
// exported parameter list is part of the contract, and growing one in place breaks every
// out-of-tree embedder at once. The old entry point is unchanged in signature AND in the levels
// it reports — it keeps writing the two handle motors, which is the correct instruction for the
// actuators it owns, so an embedder that never adopts the new symbol behaves exactly as before.
// Additive and client-local: the v3 tail has been on the wire (and length-tolerant in both
// decoders) since it landed, and the host sends the same bytes either way, so [`WIRE_VERSION`] is
// unchanged.
#define PUNKTFUNK_ABI_VERSION 18
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -195,7 +206,10 @@
// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
// impulse-trigger rumble is unreachable through THIS pad — evdev's `FF_RUMBLE` is two
// magnitudes and has no third, so a uinput backend can never source it. The Windows HID Xbox
// backend can, off its output report `0x03`; see
// [`punktfunk_connection_next_rumble_cmd2`]). Useful for glyph-matching a
// physical X-Box One/Series controller on the client.
#define PUNKTFUNK_GAMEPAD_XBOXONE 3
@@ -236,6 +250,12 @@
// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`.
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK 10
// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity
// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360
// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box
// classes (`DUALSENSEEDGE` is the pad with native back-button slots).
#define PUNKTFUNK_GAMEPAD_XBOXELITE 11
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
@@ -1196,6 +1216,15 @@
#define PUNKTFUNK_RUMBLE_V2_LEN 10
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v3 (envelope + impulse-trigger motors) rumble datagram — the v2 form plus a
// `[u16 left_trigger LE][u16 right_trigger LE]` tail (see [`encode_rumble_datagram_v3`]). Second
// use of the same append-extension the v2 tail introduced, and for the same reason: every reader
// on this plane gates with `>=`, so a 14-byte datagram satisfies the v1 predicate (level only),
// the v2 predicate (level + envelope) and this one, and each peer takes the prefix it knows.
#define PUNKTFUNK_RUMBLE_V3_LEN 14
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
@@ -2759,8 +2788,20 @@ PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c,
// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
// every close-drain stop was delivered — silence all actuators on it.
//
// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime,
// never both (they consume the same wire plane).
// **Handle motors only.** A pad also carries two Xbox impulse-trigger levels, which this entry
// point has no out-params for and never will —
// [`punktfunk_connection_next_rumble_cmd2`] is the four-motor pull. Staying here is a supported
// choice, not a deprecation: for a controller with no trigger motors — every pad but an Xbox
// One/Series/Elite — the two views are identical, and where they differ, "the handles are silent"
// is exactly the right instruction for the motors this API owns.
//
// The one observable difference against a trigger-driving host: a rumble that moves only the
// triggers still produces commands here, carrying `low == high == 0`. They are idempotent stops
// for the handles; the engine's redundant-stop suppression cannot fold them away, because the
// command is not silent — some motor on that pad is running.
//
// An embedder uses EITHER this (or its `2` form) or `next_rumble`/`next_rumble2` for a
// connection's lifetime, never both (they consume the same wire plane).
//
// # Safety
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
@@ -2773,6 +2814,52 @@ PunktfunkStatus punktfunk_connection_next_rumble_cmd(PunktfunkConnection *c,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`punktfunk_connection_next_rumble_cmd`] with the two Xbox impulse-trigger motors: the same
// command, all four of its levels. `*left_trigger` / `*right_trigger` are on the same
// `0..=0xFFFF` scale as `low`/`high`, and a stop is all four at zero.
//
// A NEW symbol rather than a wider signature on the old one, following the
// `next_rumble` → `next_rumble2` precedent in this file: an exported entry point's parameter list
// is part of the contract, and silently growing one breaks every out-of-tree embedder at once,
// with a stack-corruption signature rather than a link error. Old callers keep the old symbol and
// simply never see the trigger levels.
//
// **Render the trigger levels only on a pad that actually has trigger motors, and drop them
// otherwise** — do not fold them into the handles. Impulse-trigger content is continuous
// (a racing title drives engine RPM and tyre slip into the triggers while the handles stay near
// silent), so folding it produces a handle motor droning flat-out for the whole race at a level
// the game never asked for. Query the hardware: SDL's
// `SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN`, Apple's `GCDeviceHaptics.supportedLocalities`
// (`GCHapticsLocalityLeftTrigger`/`…RightTrigger`). A pad without them is the common case and not
// an error — do not log per command.
//
// **Nothing has driven these levels non-zero end to end yet, and that is structural, not an
// oversight.** Exactly one producer can ever source them — the Windows HID Xbox pad's output
// report `0x03` — because classic XInput's `XINPUT_VIBRATION` has two members and evdev's
// `FF_RUMBLE` has two, so no other host backend on any OS has the channel. That producer is
// reachable only through GameInput/WGI, and an xinputhid-promoted Xbox pad is not enumerated by
// GameInput at all (measured against a real Microsoft Elite, which is equally invisible there
// while XInput reads it live). So this delivery path is deliberately built ahead of its producer:
// the wire, the engine and this entry point are exercised only by synthetic levels.
//
// Same threading, timeout and close semantics as
// [`punktfunk_connection_next_rumble_cmd`]; the two share one wire plane and one policy engine,
// so an embedder calls exactly one of them.
//
// # Safety
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
// thread pulls rumble — it may run concurrently with the video/audio pullers.
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(PunktfunkConnection *c,
uint16_t *pad,
uint16_t *low,
uint16_t *high,
uint16_t *left_trigger,
uint16_t *right_trigger,
uint32_t *backstop_ms,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
// shared rumble policy engine instead of forking it (typically called at controller attach).
+9 -4
View File
@@ -8,7 +8,10 @@ _ensure_punktfunk_group() {
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on
# purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only
# kernel primitive and must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
# (security-review 2026-08-05 M-4). It is ALSO the group pf-dm-helper authorizes on (its polkit
# action must stay allow_any, so membership is the real gate), i.e. what a managed gamescope
# takeover needs to stop the display manager. Creating the group is necessary and NOT sufficient
# for either use: membership is.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
@@ -62,9 +65,11 @@ post_install() {
punktfunk-host installed.
1. Add yourself to the 'input' group for virtual gamepads:
sudo usermod -aG input "$USER" # then re-login
Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk':
sudo usermod -aG punktfunk "$USER"
That group can emulate arbitrary USB devices — join it only on a machine you trust.
ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope), or you want the
virtual Steam Deck pad (usbip):
sudo usermod -aG punktfunk "$USER" # then log out and back in
It authorizes stopping the display manager for a managed gamescope session, and the pad's
usbip nodes. It can emulate arbitrary USB devices — join it only on a machine you trust.
2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck):
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
+31 -2
View File
@@ -55,7 +55,9 @@ sy5uhYGZD6lMJ4uZAQC7W81H2gHlTDTA2Nq35HKW9IOU+Ll2c9fqa7fAIKf9Bg==
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
echo " punktfunk-sysext update [--from-file X.raw] | reapply | status | remove"
echo " reapply: re-run the host-state steps a sysext image cannot carry (groups, /etc"
echo " mirrors, udev, sysctl, modules) without reinstalling the image."
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
@@ -174,6 +176,17 @@ post_merge() {
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Creating the group is necessary but NOT sufficient, and the difference is invisible until a
# stream fails: `pf-dm-helper` gates on MEMBERSHIP, so a host whose user never joined gets
# "stopping the display manager needs privilege" on every managed takeover — sddm's autologin
# Relogin loop then churns logind sessions for the whole stream. Joining stays opt-in (writing
# vhci `attach` materialises an arbitrary emulated USB device), so say so instead of doing it.
local _pf_user="${SUDO_USER:-}"
if [ -n "$_pf_user" ] && ! id -nG "$_pf_user" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then
echo "!! $_pf_user is not in the 'punktfunk' group — the managed gamescope takeover cannot stop"
echo "!! the display manager, and the virtual Steam Deck pad cannot attach. To opt in:"
echo "!! sudo usermod -aG punktfunk $_pf_user"
fi
modprobe vhci-hcd 2>/dev/null || :
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
# the input-group ownership even when the module's original add event predated the reloaded rule.
@@ -265,7 +278,22 @@ cmd_update() {
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
echo "already on $cur (channel $(channel)) — nothing to do."
# NOT "nothing to do": re-run post_merge. Every step in it is idempotent, and skipping it here
# is how host state silently rots one release behind the image.
#
# The trap, field-proven on a Bazzite host that took 0.25.0 -> 0.26.0 (2026-08-09): an upgrade
# is driven by the script from the OLD image — this file is replaced by the very
# `systemd-sysext refresh` that runs mid-upgrade — so a post_merge step ADDED in the new
# release is executed by nobody. The old script doesn't have it, and the new script never gets
# a turn, because from then on `update` matches this branch and returns. The step is then
# permanently unreachable on exactly the installs that need it.
#
# That cost the `punktfunk` group (added to post_merge in 0.26.0): it was never created, so
# `pf-dm-helper` refused every caller — it gates on membership — and every managed gamescope
# takeover fell back to "stopping the display manager needs privilege", leaving sddm's autologin
# Relogin loop churning for the whole stream.
echo "already on $cur (channel $(channel)) — re-applying host state."
post_merge
return
fi
echo "updating: ${cur:-<none>} -> $ver"
@@ -311,6 +339,7 @@ cmd_remove() {
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
reapply) shift; need_root; post_merge ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
+11 -3
View File
@@ -292,7 +292,10 @@ if [ "$1" = "configure" ]; then
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input':
# writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel
# primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
# (security-review 2026-08-05 M-4). It is ALSO the group pf-dm-helper authorizes on (its
# polkit action must stay allow_any, so membership is the real gate), i.e. what a managed
# gamescope takeover needs to stop the display manager. Creating the group is necessary and
# NOT sufficient for either use: membership is.
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted here.
#
@@ -318,8 +321,13 @@ if [ "$1" = "configure" ]; then
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:"
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\""
echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust."
# Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck
# pad, so they correctly skipped this group — and then every managed gamescope takeover
# degraded silently, because pf-dm-helper (which stops the display manager) gates on membership.
echo "ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope) or you want the"
echo "virtual Steam Deck pad: sudo usermod -aG punktfunk \"\$USER\" # then log out and back in"
echo " — it authorizes stopping the display manager for a managed gamescope session, and the"
echo " pad's usbip nodes; it can emulate arbitrary USB devices, so join it only on a box you trust."
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
+1 -1
View File
@@ -272,7 +272,7 @@ modules:
- type: git
url: https://github.com/ValveSoftware/gamescope.git
# KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD.
commit: 8c676c399c761e4540587f61004c957993d12fea
commit: 5fb8dce4a09d0a68d097b9faf9513782106bc843
# Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope
# submodules, so nothing else populates them and they need explicit sources.
#
+8 -2
View File
@@ -18,11 +18,17 @@ pkgname=punktfunk-gamescope
# The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md);
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr4"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
pkgrel=2
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
# `vulkan_get_rgb10_capture_format()` — the XBGR2101010 fallback for devices with no linear-tiled
# A2R10G10B10 storage (every NVIDIA). That fixes the NV12/P010 capture intermediate and AVIF
# screenshots, which are upstream's paths, not ours. Patch 0001 additionally now offers
# `xBGR_210LE` BEFORE `xRGB_210LE` so a third-party consumer cannot pick the one NVIDIA fills
# byte-reversed. Still no capability the host probes for, so `.pfhdrN` stays at 4.
pkgrel=3
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
url="https://git.unom.io/unom/punktfunk"
+11 -4
View File
@@ -97,14 +97,21 @@ distro's `gamescope`.
## Building
Pinned upstream: `8c676c39` (master, 2026-07-27 — tags through 3.16.25). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp` and
`src/meson.build` only.
Pinned upstream: `5fb8dce4` (master, 2026-08-03 — `3.16.25-11-g5fb8dce`). The patches apply
cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp`,
`src/rendervulkan.cpp`, `src/rendervulkan.hpp` and `src/meson.build` only.
The bump from `8c676c39` is deliberate: it brings upstream's `vulkan_get_rgb10_capture_format()`
(`ff6b924`), which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to
`DRM_FORMAT_XBGR2101010` on devices that cannot do linear-tiled `A2R10G10B10` — i.e. every
NVIDIA. That covers the paths that are upstream's rather than ours: the RGB intermediate
`paint_pipewire()` acquires when the stream is YCbCr, and AVIF screenshots. Our own 10-bit RGB
node is covered by patch `0001`, which offers `xBGR_210LE` first for the same reason.
```sh
git clone https://github.com/ValveSoftware/gamescope.git
cd gamescope
git checkout 8c676c39
git checkout 5fb8dce4
git submodule update --init --recursive # or let meson fetch the subprojects
git am /path/to/punktfunk/packaging/gamescope/patches/*.patch
@@ -25,7 +25,7 @@ set -euo pipefail
# The pinned upstream. Bump together with the patches (they are `git am`-able and rebase cheaply —
# two files, mirroring code that already exists in-tree; see README.md).
GAMESCOPE_REV="8c676c399c761e4540587f61004c957993d12fea"
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
@@ -32,12 +32,12 @@ follow what the app happens to render.
Works on the headless backend as well as a real connector: no HDR display is
involved anywhere in the LUT set.
---
src/pipewire.cpp | 114 +++++++++++++++++++++++++++++++++----------
src/pipewire.cpp | 123 ++++++++++++++++++++++++++++++++++---------
src/steamcompmgr.cpp | 21 ++++++--
2 files changed, 106 insertions(+), 29 deletions(-)
2 files changed, 115 insertions(+), 29 deletions(-)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index 76b3ea8..6b56b01 100644
index 76b3ea8..c84b19c 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -18,6 +18,40 @@
@@ -159,7 +159,7 @@ index 76b3ea8..6b56b01 100644
params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame));
// for (auto& param : params)
@@ -166,6 +209,14 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
@@ -166,6 +209,23 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params);
build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params);
@@ -168,13 +168,22 @@ index 76b3ea8..6b56b01 100644
+ // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that
+ // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry
+ // above — ever reaches these.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+ //
+ // xBGR_210LE FIRST, and that order is correctness, not style. A consumer takes the first pod
+ // it can use, and xBGR is the only one every vendor fills correctly: capture textures are
+ // mappable, hence linear-tiled, and linear STORAGE for A2R10G10B10 is an optional Vulkan
+ // feature NVIDIA does not implement — there the composite's `imageStore` lands in XBGR order,
+ // so a consumer that took xRGB_210LE gets a buffer LABELLED XRGB2101010 and FILLED as XBGR,
+ // i.e. red and blue swapped, with every format mapping on both ends individually correct.
+ // A2B10G10R10 is the universally supported packed-10 format, so leading with it costs nothing
+ // on AMD or Intel. xRGB_210LE stays as the second pod for a consumer that only speaks it.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+#endif
return params;
}
@@ -288,7 +339,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
@@ -288,7 +348,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) {
pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight);
@@ -183,7 +192,7 @@ index 76b3ea8..6b56b01 100644
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size());
@@ -412,6 +463,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
@@ -412,6 +472,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
state->video_info.size.width, state->video_info.size.height,
s_nRequestedWidth, s_nRequestedHeight,
state->video_info.format, state->shm_stride, shm_size, state->dmabuf);
@@ -196,7 +205,7 @@ index 76b3ea8..6b56b01 100644
}
static void randname(char *buf)
@@ -450,6 +507,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
@@ -450,6 +516,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
switch (spa_format)
{
case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12;
@@ -208,7 +217,7 @@ index 76b3ea8..6b56b01 100644
default:
case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888;
}
@@ -715,7 +777,7 @@ bool init_pipewire(void)
@@ -715,7 +786,7 @@ bool init_pipewire(void)
s_nOutputHeight = g_nOutputHeight;
calculate_capture_size();
@@ -218,7 +227,7 @@ index 76b3ea8..6b56b01 100644
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index ff9ae1f..01b2abf 100644
index ecb3808..83c751b 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2335,17 +2335,32 @@ static void paint_pipewire()
@@ -58,7 +58,7 @@ index 9fc54f0..1eb35b3 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 01b2abf..5c65420 100644
index 83c751b..a8a816f 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2316,6 +2316,13 @@ static void update_touch_scaling( const struct FrameInfo_t *frameInfo )
@@ -146,9 +146,9 @@ index 01b2abf..5c65420 100644
+ }
+
gamescope::Rc<CVulkanTexture> pRGBTexture = s_pPipewireBuffer->texture->isYcbcr()
? vulkan_acquire_capture_texture( uWidth, uHeight, false, DRM_FORMAT_XRGB2101010 )
? vulkan_acquire_capture_texture( uWidth, uHeight, false, vulkan_get_rgb10_capture_format() )
: gamescope::Rc<CVulkanTexture>{ s_pPipewireBuffer->texture };
@@ -8397,6 +8452,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8404,6 +8459,12 @@ steamcompmgr_main(int argc, char **argv)
g_FadeOutDuration = atoi(optarg);
} else if (strcmp(opt_name, "force-windows-fullscreen") == 0) {
bForceWindowsFullscreen = true;
@@ -62,7 +62,7 @@ index 2c4fb50..b406caf 100644
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 5c65420..0d293c6 100644
index a8a816f..64e1a8c 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
@@ -127,7 +127,7 @@ index 5c65420..0d293c6 100644
// The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the
// PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play
// consumer that draws its own would end up with two — so a consumer that has no cursor of
@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv)
@@ -8464,6 +8497,12 @@ steamcompmgr_main(int argc, char **argv)
cv_pipewire_composite_cursor = true;
#else
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
@@ -1,4 +1,4 @@
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
Date: Sat, 8 Aug 2026 19:17:25 +0200
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
@@ -49,7 +49,7 @@ Both are needed: pinning only the device relocated the fault into
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
index 5c2dd11..8cd5ca2 100644
index 3f7ea90..0382a4b 100644
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
@@ -102,10 +102,10 @@ index 5c2dd11..8cd5ca2 100644
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
{
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
index b6749d4..a9335c4 100644
index c49b95f..ee98b38 100644
--- a/src/rendervulkan.hpp
+++ b/src/rendervulkan.hpp
@@ -564,7 +564,7 @@ enum ShaderType {
@@ -565,7 +565,7 @@ enum ShaderType {
SHADER_TYPE_COUNT
};
@@ -114,12 +114,9 @@ index b6749d4..a9335c4 100644
struct SamplerState
{
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
// Whether the driver implements VK_EXT_physical_device_drm
bool vulkan_has_drm_props();
@@ -1010,4 +1010,4 @@ bool vulkan_has_drm_props();
bool vulkan_has_drm_modifiers_for_features(VkFormat format, VkFormatFeatureFlags features);
-extern CVulkanDevice g_device;
+extern CVulkanDevice &g_device;
--
2.55.0
+8
View File
@@ -20,6 +20,14 @@
# been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in
# `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails
# loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR.
#
# ⚠️ Kept deliberately free of any dependency on the pinned rev. The pin moved past upstream's
# `vulkan_get_rgb10_capture_format()` (`ff6b924`, after 3.16.25) to fix red/blue on NVIDIA, and it
# would have been natural to have patch `0001` call it — that is what the host-side note in
# `crates/pf-capture/src/linux/pw_pods.rs` proposes. It does NOT, precisely so this derivation
# keeps building against a nixpkgs that still pins 3.16.25, where that symbol does not exist and
# the failure would be an opaque C++ error rather than a patch conflict. Patch `0001` gets the
# same outcome version-independently by offering `xBGR_210LE` ahead of `xRGB_210LE`.
{
lib,
gamescope,
+10 -2
View File
@@ -595,6 +595,9 @@ getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-upd
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing
# 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must
# not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4).
# It is ALSO the group `pf-dm-helper` authorizes on (the polkit action must stay `allow_any`, so
# membership is the real gate) — so it is what a managed gamescope takeover needs to stop the
# display manager. Creating it is necessary and NOT sufficient for either use: membership is.
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
# Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort).
udevadm control --reload-rules 2>/dev/null || :
@@ -603,8 +606,13 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || :
# it takes effect on the next boot into the layered deployment).
sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER"
echo " that group can emulate arbitrary USB devices; join it only on a machine you trust."
# Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck pad, so
# they correctly skipped this group — and then every managed gamescope takeover degraded silently,
# because pf-dm-helper (which stops the display manager for the stream) gates on THIS membership.
echo "ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope) or you want the"
echo "virtual Steam Deck pad: sudo usermod -aG punktfunk \$USER # then log out and back in"
echo " it authorizes stopping the display manager for a managed gamescope session, and the"
echo " pad's usbip nodes; it can emulate arbitrary USB devices, so join it only on a box you trust."
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
+19 -3
View File
@@ -17,8 +17,24 @@
# can't supply a passphrase non-interactively here.
#
# Usage (in rpm.yml, after build-rpm.sh): RPM_GPG_PRIVATE_KEY=... bash packaging/rpm/sign-rpms.sh
#
# Takes the RPMs to sign as arguments, defaulting to `dist/*.rpm`. The argument form exists because
# punktfunk-gamescope is built LATER in the job than the host RPMs — it is a ~10-minute C++ build
# behind its own cache — so it misses the main signing pass entirely and needs a second one just
# for itself. It shipped unsigned into a `gpgcheck=1` repo that way, which made
# `dnf install punktfunk-gamescope` fail with "The package is not signed" for every Fedora/Nobara
# user: the package was in the channel and still uninstallable.
set -euo pipefail
# Default target, and a real glob rather than a literal when nothing matched.
if [ "$#" -gt 0 ]; then
RPMS=("$@")
else
shopt -s nullglob
RPMS=(dist/*.rpm)
fi
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs to sign" >&2; exit 1; }
if [ -z "${RPM_GPG_PRIVATE_KEY:-}" ]; then
case "${GITHUB_REF:-}" in
refs/tags/v*)
@@ -47,11 +63,11 @@ KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}
# correctly. (A custom __gpg_sign_cmd passed via --define reached gpg with those filename macros
# UNEXPANDED -> "No such file or directory".) Just point rpm at our key; the GNUPGHOME above
# (passphrase-less key + loopback) lets gpg sign headless.
for rpm in dist/*.rpm; do
for rpm in "${RPMS[@]}"; do
rpmsign --define "_gpg_name $KEYID" --addsign "$rpm"
done
# Verify locally so a bad signature fails the build before publishing.
rpm --import <(gpg --export --armor "$KEYID")
rpmkeys --checksig dist/*.rpm
echo "signed + verified $(find dist -name '*.rpm' | wc -l) RPM(s) with key $KEYID"
rpmkeys --checksig "${RPMS[@]}"
echo "signed + verified ${#RPMS[@]} RPM(s) with key $KEYID"
@@ -1,7 +1,8 @@
;/*++
; punktfunk virtual gamepads — UMDF2 HID minidriver INF.
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why
; the package is called pf_gamepad and not pf_dualsense (it never was one identity).
; One package, seven hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck, and three
; Xbox pads (Wireless / One S / Elite Series 2) — which is why the package is called pf_gamepad and
; not pf_dualsense (it never was one identity).
;
; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`,
; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host
@@ -34,10 +35,12 @@ pf_gamepad.dll=1
[pf.NT$ARCH$.10.0...22000]
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the
; host stamps into shared memory.
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` /
; `pf_steamdeck` / `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` for the host's other virtual
; pads — ONE driver binds all of them and serves the matching HID identity per the device_type byte
; the host stamps into shared memory. TWO install sections, though: the PlayStation/Deck ids share
; `pfGamepad`, and the three Xbox ids install `pfGamepadXbox`, which additionally attaches the
; `xinputhid` bus filter (see the ⚠️ below the Deck line).
;
; Each id carries its OWN description: Device Manager reads this string, and a single shared
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
@@ -47,6 +50,19 @@ pf_gamepad.dll=1
%DeviceDescDS4%=pfGamepad, pf_dualshock4
%DeviceDescEdge%=pfGamepad, pf_dualsenseedge
%DeviceDescDeck%=pfGamepad, pf_steamdeck
; ⚠️ The Xbox lines install their OWN section, `pfGamepadXbox`, and must keep doing so. Every other
; identity shares `pfGamepad`; the Xbox ones additionally attach the `xinputhid` bus filter, and
; putting that on a DualSense / DualShock 4 / Edge / Steam Deck would hand a PlayStation pad to
; Microsoft's Xbox translator. The two sections are otherwise identical — keep them in step.
; (`only_the_xbox_identity_installs_the_xinputhid_section`, in pf-inject, asserts both directions.)
;
; The three Xbox identities differ ONLY in hardware id, Device Manager description and the VID/PID
; + product string the driver serves off the resulting device_type — they share one report
; descriptor and one install section, because in HID terms they are the same pad. See the
; `XBOX_RDESC` header in src/lib.rs for why that sharing is deliberate.
%DeviceDescXbox%=pfGamepadXbox, root\pf_xboxwireless, pf_xboxwireless
%DeviceDescXboxOneS%=pfGamepadXbox, root\pf_xboxones, pf_xboxones
%DeviceDescXboxElite%=pfGamepadXbox, root\pf_xboxelite, pf_xboxelite
[pfGamepad.NT]
CopyFiles=UMDriverCopy
@@ -82,6 +98,77 @@ UmdfFsContextUsePolicy=CanUseFsContext2
; across multiple simultaneous controllers (multi-pad).
UmdfHostProcessSharing=ProcessSharingDisabled
; ---------------------------------------------------------------------------------------------
; The Xbox identity: `pfGamepad` plus the two registry values that make Windows PROMOTE the pad.
;
; Measured on .173, 2026-08-09. Without these, our HID Xbox pad is invisible to classic XInput and
; to WGI `Gamepad`, and gets no rumble — the exact field symptom that started this work. With them
; the HID child gains the `IG_00` token, an XUSB interface appears, XInput reads it (full stick
; range and buttons) and `XInputSetState` rumble arrives back as HID output report 0x03.
;
; ⭐ Both values come straight out of Microsoft's own `xinputhid.inf`, which promotes Xbox pads by
; an explicit hardware-id ALLOW-LIST (its own comment: "we can not use a Compatability ID … and so
; rely on individual hardware IDs"). A software-enumerated devnode can never match those ids, so we
; write what the matching install sections would have written. `045E:0B13`, the PID this identity
; claims, is on that allow-list — twice.
;
; 🛑 THE PAIRING IS LOAD-BEARING AND THE TWO VALUES GO IN DIFFERENT KEYS. An A/B on the live box:
; removing `DevicePropertyFlags` alone reverts ALL of it — no `IG_00`, no XUSB interface, no XInput,
; no WGI entry — while `UpperFilters` alone is completely inert. `DevicePropertyFlags = 1` is
; `BusDevice` in `xinputhid.h`, which Microsoft's comment glosses as "a focused bus filter driver
; for the IG_ problem". It is not a description of the device; it is the switch that tells the
; filter what job to do. An earlier session installed the filter WITHOUT it, measured a device that
; produced nothing, and concluded the filter was broken and must never ship. It was not broken; it
; had never been switched on.
;
; ⚠️ Both go on THIS node — the parent/transport devnode — not on the HID child. That is where a
; real Xbox pad carries them: the Elite's Bluetooth transport node has `DevicePropertyFlags=1` and
; the filter, while its HID child has plain `input.inf` and neither.
[pfGamepadXbox.NT]
CopyFiles=UMDriverCopy
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT
; HKR in a DDInstall section is the SOFTWARE (driver) key — Control\Class\{...}\<NNNN>.
AddReg=pfGamepadXbox_SW_AddReg
[pfGamepadXbox.NT.hw]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.hw
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.hw
; HKR in a .HW section is the HARDWARE (device) key — Enum\<instance>.
AddReg=pfGamepadXbox_HW_AddReg
[pfGamepadXbox.NT.Services]
Include=MsHidUmdf.inf
Needs=MsHidUmdf.NT.Services
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Services
[pfGamepadXbox.NT.Filters]
Include=WUDFRD.inf
Needs=WUDFRD_LowerFilter.NT.Filters
[pfGamepadXbox.NT.Wdf]
UmdfService="pf_gamepad", pf_gamepad_Install
UmdfServiceOrder=pf_gamepad
UmdfKernelModeClientPolicy=AllowKernelModeClients
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
UmdfMethodNeitherAction=Copy
UmdfFsContextUsePolicy=CanUseFsContext2
UmdfHostProcessSharing=ProcessSharingDisabled
[pfGamepadXbox_SW_AddReg]
; 1 = BusDevice. See the block above — this is the half that actually does the work.
HKR,,"DevicePropertyFlags",0x00010001,1
[pfGamepadXbox_HW_AddReg]
; 0x00010008 = REG_MULTI_SZ | APPEND, matching xinputhid.inf: append rather than replace, so we
; never clobber a filter someone else put on the stack.
HKR,,"UpperFilters",0x00010008,"xinputhid"
[pf_gamepad_Install]
UmdfLibraryVersion=$UMDFVERSION$
ServiceBinary="%13%\pf_gamepad.dll"
@@ -100,3 +187,10 @@ DeviceDesc ="Punktfunk Virtual DualSense"
DeviceDescDS4 ="Punktfunk Virtual DualShock 4"
DeviceDescEdge ="Punktfunk Virtual DualSense Edge"
DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller"
DeviceDescXbox ="Punktfunk Virtual Xbox Wireless Controller"
; ⚠️ This one deliberately does NOT match the product string the driver serves for device_type 5.
; A real Xbox One S pad reports "Xbox Wireless Controller" over Bluetooth, exactly like the Series
; X|S pad above — the PID is the only thing that separates them on the wire. Device Manager,
; however, has to let a human tell our two virtual pads apart, and this string is ours to choose.
DeviceDescXboxOneS ="Punktfunk Virtual Xbox One S Controller"
DeviceDescXboxElite="Punktfunk Virtual Xbox Elite Wireless Controller Series 2"
+390 -15
View File
@@ -1,7 +1,10 @@
// punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver.
//
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
// (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the
// (VID 054C / PID 0CE6), DualShock 4 (device_type=1), DualSense Edge (device_type=2), Steam Deck
// (device_type=3), Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13), Xbox One S
// (device_type=5, 045E / 02FD) or Xbox Elite Wireless Controller Series 2 (device_type=6,
// 045E / 0B22) using the
// report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
//
@@ -72,6 +75,55 @@ const DS_EDGE_PID: u16 = 0x0DF2;
const DECK_VID: u16 = 0x28DE;
const DECK_PID: u16 = 0x1205;
// ---- Xbox identities (device_type = 4 Wireless / 5 One S / 6 Elite Series 2) ----
//
// WHY THIS EXISTS (field 2026-08-09, `punktfunk-field-windows-pad-dead-0260`): the OTHER Windows
// Xbox backend — `pf-xusb` — registers ONLY `GUID_DEVINTERFACE_XUSB` and has no HID collection at
// all, so it is invisible to Steam's hidapi enumeration, to DirectInput, to `joy.cpl`, and to
// WGI/GameInput. Only classic `XInputGetState` via xinput1_4's interface walk ever sees it. A
// reporter spent two weeks on a dead controller for exactly that reason, and switching the client
// to DualSense — a REAL HID pad through this driver — fixed it instantly. This identity gives the
// Xbox pad the same HID footing the PlayStation ones have always had.
//
// ⚠️⚠️ **The VID/PID is a BLUETOOTH Xbox controller on purpose.** The wired ids the rest of the
// tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class XUSB/GIP devices —
// they expose NO HID interface on real hardware, so a HID child claiming one is a device that has
// never existed and inbox promotion has nothing to match. The Xbox pads that genuinely ARE HID are
// the Bluetooth ones, which Windows binds through HIDCLASS.
const XBOX_VID: u16 = 0x045E;
/// Xbox Wireless Controller (Series X|S), Bluetooth — `device_type = 4`, the default Xbox identity.
/// Chosen over the Xbox One S BT id `0x02FD` because the host's OS floor is Windows 11 22H2, where
/// this is the current-generation identity (so glyphs read "Xbox Series") and SDL's mapping
/// database covers it.
///
/// ⭐ It is also the PID Microsoft's own `xinputhid.inf` allow-lists **twice** (once as a
/// `BTHLEDevice` stage-1 id, once as a plain `HID\…&IG_00` stage-2 id) — measured off `.173`,
/// 2026-08-09. That is not what promotes OUR pad (a software devnode matches no allow-list entry;
/// `pfGamepadXbox`'s `AddReg` writes what the matching sections would have written), but it is why
/// this stays the default of the three.
const XBOX_PID: u16 = 0x0B13;
/// Xbox One S controller over Bluetooth — `device_type = 5`.
///
/// ⚠️ **`02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has NO
/// stage-2 `HID\…&IG_00` model line.** That killed it as a "try another PID" lever for the
/// promotion work (handoff §4 B1). It does not block it as an IDENTITY, because our promotion
/// comes from the INF's own `AddReg` rather than from matching Microsoft's list — but if a future
/// Windows servicing update makes promotion depend on the allow-list again, this identity is the
/// one that loses it first. Worth re-measuring on glass before recommending it to anyone.
const XBOX_PID_ONE_S: u16 = 0x02FD;
/// Xbox Elite Wireless Controller Series 2 — `device_type = 6`. This is the pad
/// `tools/hid-descriptor-dump` captured on `.173` (`BTHLE\DEV_686CE647F191`, `REV_0521`), so it is
/// the one identity here whose real hardware we have measured directly.
const XBOX_PID_ELITE2: u16 = 0x0B22;
/// bcdDevice for every Xbox identity.
///
/// Deliberately ONE value rather than per-identity: the real Elite reports `REV_0521` (measured on
/// `.173`) but `create_swdevice` synthesizes the devnode's USB ids with a hardcoded `&REV_0100`
/// regardless, and SDL folds the version into its joystick GUID — so a version that disagrees with
/// the devnode buys nothing and risks missing a stock mapping. Revisit only with a measurement
/// that shows a consumer keying on it.
const XBOX_VER: u16 = 0x0407;
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
#[rustfmt::skip]
@@ -241,6 +293,229 @@ static DECK_RDESC: [u8; 38] = [
0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
];
// ---- Xbox assets (served when the host stamps device_type = 4, 5 or 6) ----
//
// ⭐⭐ **ONE DESCRIPTOR SERVES ALL THREE XBOX IDENTITIES, DELIBERATELY.** Xbox Wireless (4),
// Xbox One S (5) and Xbox Elite Series 2 (6) differ ONLY in VID/PID, product string and INF model
// line — in HID terms they are the same pad: same two 16-bit stick pairs, same trigger pair, same
// hat, same 15 buttons, same rumble output report. A report descriptor is the report SHAPE, not
// the identity; the identity is what SDL/Steam/Windows key their stock mappings off, and that
// travels in `hid_attrs`.
//
// This is load-bearing, not laziness. The ⚠️ block below is the record of what ONE hand-written
// descriptor has already cost: three separate bugs (no Feature report ⇒ the sealed channel never
// opened and the pad served neutral forever; no OUTPUT item ⇒ no rumble of any kind and dead
// host-side code; a layout that provably disagrees with the captured hardware). Two more
// hand-written descriptors would multiply that debt by three for no measured gain, and each would
// need its own capture, its own `wReportLength`, its own `xbox_proto` layout tests and its own
// on-glass verification. When a Linux-hidraw capture settles the real layout (handoff §3.3), it
// lands here ONCE and all three identities get it.
//
// A standards-clean Game Pad collection matching the Bluetooth Xbox layout: two 16-bit stick pairs,
// two 10-bit triggers on the Simulation page, a null-state hat, and 15 buttons. Report `0x01`,
// [`XBOX_INPUT_REPORT_LEN`] bytes on the wire including the id. `inject/proto/xbox_proto.rs` packs
// the matching bytes host-side; `xbox_proto`'s tests pin the two together.
//
// ⚠️⚠️⚠️ **PROVENANCE: this descriptor is CONSTRUCTED, not captured — unlike every sibling here
// (`DUALSENSE_RDESC` verbatim from inputtino, `DS4_RDESC` verbatim from `inject/dualshock4.rs`,
// `DECK_RDESC` captured off a real `28DE:1205`). It has never been compared against a real pad.**
// That matters more than usual: we claim a REAL Microsoft VID/PID, and SDL / Steam / Windows keep
// built-in mappings keyed off that VID/PID. If a consumer applies its stock `045E:0B13` mapping to a
// report laid out differently from the real device, every control silently lands on the wrong
// action — the same class of bug this whole change exists to kill.
//
// ⭐ **2026-08-09 — THE CAPTURE NOW EXISTS AND THIS BLOB DISAGREES WITH IT.** A real Xbox Elite
// Series 2 (`045E:0B22`, Bluetooth LE) was captured on `.173` with `tools/hid-descriptor-dump`; the
// dump, its provenance and the DualSense control that validates the tool are in
// `tools/hid-descriptor-dump/captures/`. Re-take it any time with `--vid 045E --pid 0B22`, and
// decode THIS array through the same decoder — no hardware needed — with:
//
// hid-descriptor-dump --rust-source packaging/windows/drivers/pf-gamepad/src/lib.rs \
// --symbol XBOX_RDESC
//
// Four differences, and the ORDER one is the dangerous one:
// * the real pad's game-controller report is **UNNUMBERED** (15 bytes of fields, no report id);
// this one declares Report ID 1;
// * it carries **ONE combined 16-bit `Z`** trigger axis at byte 8, not two Simulation-page axes;
// * it declares **16 buttons at byte 10, BEFORE the hat** — this one puts 15 buttons AFTER it;
// * neither has an OUTPUT collection, so the rumble gap is real on both.
//
// 🛑 **Do NOT simply paste the capture over this array.** Two blockers, recorded in
// `design/xbox-pad-windows-handoff.md` §3.3: (1) it is unverified whether Windows' view equals the
// pad's NATIVE report map — `xinputhid` filters that pad and the captured shape is the legacy
// DirectInput view, so cross-check on Linux hidraw first; (2) **the real descriptor has no Feature
// report, and we cannot ship without one** — `0x85` is the sealed channel's proof transport, and
// report ids are all-or-nothing, so declaring it forces a numbered input report the real pad does
// not have. Matching the hardware byte for byte and keeping the sealed channel as it stands are
// mutually exclusive; that needs a decision, not a paste. Whatever lands, re-run `xbox_proto`'s
// layout tests — they pin these offsets on the host side.
//
// ⚠️ The trailing vendor-defined Feature report `0x85` is NOT cosmetic and must not be trimmed as
// "unused": it is the CHANNEL PROOF transport (`ProofTransport::HidFeatureReport`). The captured
// PlayStation descriptors already declared `0x85`, which is why the proof "costs no descriptor
// change" there — but this descriptor is constructed, so it has to declare the report itself. Built
// without it the pad enumerates perfectly and then delivers NOTHING: hidclass rejects the host's
// `HidD_GetFeature` before the driver sees it, the host refuses to hand over the DATA section
// (measured on .173 2026-08-09 — WGI `RawGameController` saw `045E:0B13` with every axis pinned at
// 0.5000 and a timestamp frozen for 12 consecutive samples), and the pad serves only its neutral
// report forever. `0x3F` payload bytes so `FeatureReportByteLength` lands on 64, the buffer size
// `channel_proof::query` asks with; the proof itself needs 17.
#[rustfmt::skip]
static XBOX_RDESC: [u8; 223] = [
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x09, 0x30, // Usage (X) — left stick X
0x09, 0x31, // Usage (Y) — left stick Y
0x15, 0x00, // Logical Minimum (0)
0x27, 0xFF, 0xFF, 0x00, 0x00, // Logical Maximum (65535)
0x95, 0x02, // Report Count (2)
0x75, 0x10, // Report Size (16)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection
// 🛑 THE RIGHT STICK IS `Z`/`Rz`, NOT `Rx`/`Ry`. This declared `Rx`/`Ry` until 2026-08-09 and
// the right stick was DEAD: measured on `.173`, with every axis sweeping on its own phase,
// `LX`/`LY`/`LT`/`RT` all reached XInput and `RX [0..0] RY [-1..-1]` never moved. Left and right
// were declared identically here apart from these two usage bytes, so the usages are the whole
// difference — `xinputhid`, which translates this collection into XUSB, maps `Z`/`Rz` to the
// right stick and does not treat `Rx`/`Ry` as one. `DUALSENSE_RDESC` above (a real capture) uses
// `Z`/`Rz` for its right stick too; the PS pads put the TRIGGERS on `Rx`/`Ry`, which is probably
// where the original mistake came from.
// ⚠️ This survived every bench measurement because the devtest only ever swept LS-X — the axis
// that worked — so `RX [0..0]` read as "nothing is driving it". It was found on glass. The
// devtest now sweeps all six axes on distinct phases so the harness can tell those two apart.
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x09, 0x32, // Usage (Z) — right stick X
0x09, 0x35, // Usage (Rz) — right stick Y
0x15, 0x00, // Logical Minimum (0)
0x27, 0xFF, 0xFF, 0x00, 0x00, // Logical Maximum (65535)
0x95, 0x02, // Report Count (2)
0x75, 0x10, // Report Size (16)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection
0x05, 0x02, // Usage Page (Simulation Controls)
0x09, 0xC5, // Usage (Brake) — left trigger
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x03, // Logical Maximum (1023)
0x95, 0x01, // Report Count (1)
0x75, 0x10, // Report Size (16)
0x81, 0x02, // Input (Data,Var,Abs)
0x09, 0xC4, // Usage (Accelerator) — right trigger
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x03, // Logical Maximum (1023)
0x95, 0x01, // Report Count (1)
0x75, 0x10, // Report Size (16)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x01, // Logical Minimum (1)
0x25, 0x08, // Logical Maximum (8)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x3B, 0x01, // Physical Maximum (315)
0x65, 0x14, // Unit (Eng Rot: Degrees)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null State)
0x65, 0x00, // Unit (None)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Cnst,Var,Abs) — pad the hat byte
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (Button 1)
0x29, 0x0F, // Usage Maximum (Button 15)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x0F, // Report Count (15)
0x81, 0x02, // Input (Data,Var,Abs)
0x75, 0x01, // Report Size (1)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary
// ---- Rumble OUTPUT report `0x03` (Physical Interface Device page) ----
//
// Without this the pad can receive NOTHING. hidclass routes an output report only if the
// descriptor declares one, so with no `0x91` item `on_output_report` never fires,
// `publish_output` never writes the ring, and `parse_xbox_output`
// (`inject/windows/xbox_windows.rs`) is unreachable code — the whole host-side rumble plane is
// already built and was simply never fed. That is why the HID Xbox pad had no rumble at all,
// not merely no trigger rumble.
//
// ⚠️ PROVENANCE — HAND-WRITTEN, and it could not be otherwise. Every other output collection in
// this file is a capture, and §3 of `design/xbox-pad-windows-handoff.md` insists on captures.
// But the Elite capture taken for that work reports `OUTPUT items: 0` (Windows exposes no
// literal report-descriptor bytes; hidapi reconstructs from `HidD_GetPreparsedData`, and that
// reconstruction carries no output collection for this pad). So there was nothing to copy.
// This block is the documented Xbox One S / Elite Bluetooth rumble report — PID-page
// `Set Effect Report`, id `0x03`, 8 payload bytes — chosen because it is exactly the layout
// `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already specify:
// [0x03][enable][left_trigger][right_trigger][left][right][duration][delay][loop]
// with magnitudes 0..100 (hence `Logical Maximum (100)`, not 255).
// **Replace it with a Linux hidraw capture when one can be taken** — that is the only route to
// byte-exact truth here, and the enable-bit assignments for the two TRIGGER actuators remain
// unverified (see trigger-rumble-plane.md WP0).
//
// Declared AFTER the final Input item and re-stating every global it uses, so it cannot
// retroactively alter the 16-byte input layout `xbox_proto`'s tests pin.
0x05, 0x0F, // Usage Page (Physical Interface Device)
0x09, 0x21, // Usage (Set Effect Report)
0x85, 0x03, // Report ID (3)
0xA1, 0x02, // Collection (Logical)
0x09, 0x97, // Usage (DC Enable Actuators)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x91, 0x02, // Output (Data,Var,Abs) — the enable mask, low nibble
0x15, 0x00, // Logical Minimum (0)
0x25, 0x00, // Logical Maximum (0)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x91, 0x03, // Output (Cnst,Var,Abs) — pad the enable byte
0x09, 0x70, // Usage (Magnitude)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x64, // Logical Maximum (100) — percent, NOT 255
0x75, 0x08, // Report Size (8)
0x95, 0x04, // Report Count (4) — LT, RT, left handle, right handle
0x91, 0x02, // Output (Data,Var,Abs)
0x09, 0x50, // Usage (Duration)
0x66, 0x01, 0x10, // Unit (SI Linear: seconds)
0x55, 0x0E, // Unit Exponent (-2) — centiseconds
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x91, 0x02, // Output (Data,Var,Abs)
0x09, 0xA7, // Usage (Start Delay) — same unit and range as Duration
0x91, 0x02, // Output (Data,Var,Abs)
0x65, 0x00, // Unit (None)
0x55, 0x00, // Unit Exponent (0)
0x09, 0x7C, // Usage (Loop Count)
0x91, 0x02, // Output (Data,Var,Abs)
0xC0, // End Collection
// The channel-proof feature report — see the ⚠️ above. Declared last so it cannot disturb the
// INPUT layout `xbox_proto` packs against: every global item here (Report Size/Count, Logical
// Min/Max) is re-stated after the final Input item, so nothing above is retroactively changed.
0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00)
0x85, 0x85, // Report ID (0x85)
0x09, 0x2D, // Usage (0x2D) — the id the PS descriptors use for it
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63) — 1 id + 63 = 64 = FeatureReportByteLength
0xB1, 0x02, // Feature (Data,Var,Abs)
0xC0, // End Collection
];
/// Bytes the Xbox input report occupies on the wire, report id included — 1 id + 8 sticks +
/// 4 triggers + 1 hat + 2 buttons. hidclass sizes its READ_REPORT buffer from the descriptor, and
/// [`Request::copy_to_output`] REFUSES a source longer than that buffer (it does not truncate), so
/// the completion path must serve exactly this many bytes. See [`input_report_len`].
const XBOX_INPUT_REPORT_LEN: usize = 16;
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
// DualSense Edge = 389 (0x0185).
@@ -248,24 +523,68 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
// Serves device_type 4, 5 AND 6 — one descriptor, three identities (see the XBOX_RDESC header).
static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes
// Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's
// array size, and the two are edited in different places. Getting them out of step does not fail
// loudly — hidclass asks for `wReportLength` bytes and then parses whatever it got, so the pad
// either enumerates with a truncated descriptor or fails to enumerate at all, with nothing naming
// the cause. Assert the pairing at compile time instead; adding an item to a descriptor now cannot
// build until its length is updated too.
const fn declared_len(hid_desc: &[u8; 9]) -> usize {
(hid_desc[7] as usize) | ((hid_desc[8] as usize) << 8)
}
const _: () = assert!(declared_len(&HID_DESC) == DUALSENSE_RDESC.len());
const _: () = assert!(declared_len(&DS4_HID_DESC) == DS4_RDESC.len());
const _: () = assert!(declared_len(&EDGE_HID_DESC) == DS_EDGE_RDESC.len());
const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len());
const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len());
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
// `devtype` selects the identity: PS family (same Sony VID/version), the N4-spike Deck, or one of
// the three Xbox pads (same Microsoft VID/version — only the PID differs, which is the entire
// difference between them; they share a report descriptor).
//
// ⚠️ THIS is where an Xbox identity is actually decided. Everything else in the Xbox path —
// descriptor, HID descriptor, report length, neutral report — is shared, so a new Xbox model is a
// PID here, a product string in `on_get_string`, an INF model line and nothing else.
fn hid_attrs(devtype: u8) -> [u8; 32] {
let (vid, pid) = match devtype {
1 => (DS_VID, DS4_PID),
2 => (DS_VID, DS_EDGE_PID),
3 => (DECK_VID, DECK_PID),
_ => (DS_VID, DS_PID),
let (vid, pid, ver) = match devtype {
1 => (DS_VID, DS4_PID, DS_VER),
2 => (DS_VID, DS_EDGE_PID, DS_VER),
3 => (DECK_VID, DECK_PID, DS_VER),
4 => (XBOX_VID, XBOX_PID, XBOX_VER),
5 => (XBOX_VID, XBOX_PID_ONE_S, XBOX_VER),
6 => (XBOX_VID, XBOX_PID_ELITE2, XBOX_VER),
_ => (DS_VID, DS_PID, DS_VER),
};
let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes());
a[4..6].copy_from_slice(&vid.to_le_bytes());
a[6..8].copy_from_slice(&pid.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a[8..10].copy_from_slice(&ver.to_le_bytes());
a
}
/// Bytes to hand a pended `IOCTL_HID_READ_REPORT`, per identity.
///
/// The PlayStation/Deck identities all declare 64-byte input reports, which is why the report slot
/// and [`INPUT_REPORT`] are 64 bytes wide and the completion path could hand the whole buffer over
/// unconditionally. The Xbox identity declares a [`XBOX_INPUT_REPORT_LEN`]-byte report, and
/// [`Request::copy_to_output`] returns `STATUS_INVALID_BUFFER_SIZE` when the source is LONGER than
/// the caller's buffer rather than truncating — so handing hidclass 64 bytes for a 16-byte report
/// fails every single read and the pad looks dead.
///
/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. All three
/// Xbox identities share one descriptor, hence one report length.
fn input_report_len(devtype: u8) -> usize {
match devtype {
4..=6 => XBOX_INPUT_REPORT_LEN,
_ => 64,
}
}
// Neutral DualSense input report 0x01 (64 bytes): sticks centered (0x80), triggers 0, dpad neutral (8).
const NEUTRAL_REPORT: [u8; 64] = {
let mut r = [0u8; 64];
@@ -299,10 +618,27 @@ const DECK_NEUTRAL_REPORT: [u8; 64] = {
r[3] = 0x3C;
r
};
// Neutral Xbox input report 0x01: both sticks centred (0x8000 on a 0..65535 axis), triggers 0,
// hat 0 (the descriptor's NULL state — the logical range starts at 1), no buttons held. Only the
// first [`XBOX_INPUT_REPORT_LEN`] bytes are ever served; the rest of the 64-byte slot stays zero so
// the shared [`INPUT_REPORT`] type is unchanged.
const XBOX_NEUTRAL_REPORT: [u8; 64] = {
let mut r = [0u8; 64];
r[0] = 0x01; // report id
r[2] = 0x80; // LX = 0x8000 (little-endian)
r[3] = 0xFF; // LY = 0x7FFF — the Y axes are INVERTED (+y is up on the wire, down in HID),
r[4] = 0x7F; // and mirroring an even-sized range centres one unit low. See `xbox_proto`.
r[6] = 0x80; // RX = 0x8000
r[7] = 0xFF; // RY = 0x7FFF
r[8] = 0x7F;
r
};
fn neutral_report(devtype: u8) -> [u8; 64] {
match devtype {
1 => DS4_NEUTRAL_REPORT,
3 => DECK_NEUTRAL_REPORT,
// Wireless / One S / Elite Series 2 — one report shape, three identities.
4..=6 => XBOX_NEUTRAL_REPORT,
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
}
}
@@ -455,8 +791,8 @@ static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new();
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge,
/// 3 = Steam Deck) — the neutral-report shape when the channel detaches, and the fallback identity
/// while unattached.
/// 3 = Steam Deck, 4 = Xbox Wireless, 5 = Xbox One S, 6 = Xbox Elite Series 2) — the
/// neutral-report shape when the channel detaches, and the fallback identity while unattached.
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
/// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]);
/// `u32::MAX` = not resolved. See [`device_type`] for why this exists.
@@ -472,9 +808,15 @@ static TICK: AtomicU32 = AtomicU32::new(0);
/// can never disagree.
///
/// Order matters: `pf_dualsense` is a prefix of `pf_dualsenseedge`, so the Edge is tested first.
/// (No Xbox token is a prefix of another — `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite`
/// diverge at the 8th character — but `hwid_devtype_table_matches_the_driver` re-checks that for
/// every pair rather than trusting this note.)
fn devtype_from_hwids(ids: &str) -> Option<u8> {
for (token, devtype) in [
("pf_steamdeck", 3u8),
("pf_xboxwireless", 4u8),
("pf_xboxones", 5),
("pf_xboxelite", 6),
("pf_steamdeck", 3),
("pf_dualsenseedge", 2),
("pf_dualshock4", 1),
("pf_dualsense", 0),
@@ -724,7 +1066,7 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
// SAFETY: timer valid; the due time is TIMER_PERIOD_MS in 100 ns units, negative = relative.
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, due) };
log("[pf-gamepad] device ready (DualSense 054C:0CE6)");
log("[pf-gamepad] device ready");
STATUS_SUCCESS
}
@@ -762,13 +1104,17 @@ extern "C" fn evt_io_device_control(
1 => &DS4_HID_DESC,
2 => &EDGE_HID_DESC,
3 => &DECK_HID_DESC,
4..=6 => &XBOX_HID_DESC,
_ => &HID_DESC,
}),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
// The three Xbox identities share ONE report descriptor on purpose — see the XBOX_RDESC
// header. Only `hid_attrs` (VID/PID) and `on_get_string` (product string) tell them apart.
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
1 => &DS4_RDESC[..],
2 => &DS_EDGE_RDESC[..],
3 => &DECK_RDESC[..],
4..=6 => &XBOX_RDESC[..],
_ => &DUALSENSE_RDESC[..],
}),
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
@@ -776,7 +1122,13 @@ extern "C" fn evt_io_device_control(
}
IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())),
// Sliced to the identity's declared report length for the same reason the timer's
// completion is (see `input_report_len`): a source longer than the caller's buffer is
// refused outright, not truncated.
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
let dt = device_type();
request.copy_to_output(&neutral_report(dt)[..input_report_len(dt)])
}
IOCTL_HID_GET_STRING => on_get_string(&request),
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
@@ -1024,6 +1376,7 @@ fn on_get_string(request: &Request) -> NTSTATUS {
0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment".into(),
3 => "Valve Software".into(),
4..=6 => "Microsoft".into(),
_ => "Sony Interactive Entertainment".into(),
},
// Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and
@@ -1035,12 +1388,30 @@ fn on_get_string(request: &Request) -> NTSTATUS {
1 => format!("DEADBEEF00{:02X}", 0x01u8.wrapping_add(pad_index())),
2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())),
3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32),
// Xbox pads report a Bluetooth MAC-shaped serial; the low octet carries the pad index
// so Steam dedups multiple forwarded pads, exactly like the PS identities above. Each
// Xbox identity gets its OWN base octet (0x10 / 0x30 / 0x50) rather than sharing one:
// a mixed session can present a Wireless pad and an Elite at once, and two identities
// whose serials differ only by pad index are one off-by-one away from colliding — the
// failure being Steam silently treating two live pads as one device.
4 => format!("F4B0FC2A6C{:02X}", 0x10u8.wrapping_add(pad_index())),
5 => format!("F4B0FC2A6C{:02X}", 0x30u8.wrapping_add(pad_index())),
6 => format!("F4B0FC2A6C{:02X}", 0x50u8.wrapping_add(pad_index())),
_ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())),
},
_ => match devtype {
1 => "Wireless Controller".into(),
2 => "DualSense Edge Wireless Controller".into(),
3 => "Steam Deck Controller".into(),
// ⚠️ 4 and 5 share a product string ON PURPOSE — a real Xbox Wireless Controller
// (Series X|S, `0B13`) and a real Xbox One S pad (`02FD`) BOTH report exactly
// "Xbox Wireless Controller" over Bluetooth. The PID is what tells them apart, and
// that is what SDL/Steam/Windows key their stock mappings off. Do not "fix" this by
// inventing a distinguishing string; it would make the One S identity a device that
// has never existed. (The INF's Device Manager descriptions DO differ — that string
// is ours, not the pad's.)
4 | 5 => "Xbox Wireless Controller".into(),
6 => "Xbox Elite Wireless Controller Series 2".into(),
_ => "DualSense Wireless Controller".into(),
},
};
@@ -1052,7 +1423,8 @@ fn on_get_string(request: &Request) -> NTSTATUS {
request.copy_to_output(&wide)
}
/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck.
/// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck,
/// 4 = Xbox Wireless Controller, 5 = Xbox One S, 6 = Xbox Elite Wireless Controller Series 2.
/// Read fresh on each enumeration query — cheap.
///
/// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for
@@ -1142,7 +1514,10 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
// SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs.
if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } {
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
let st = request.copy_to_output(&report);
// Serve exactly what this identity's descriptor declares — `copy_to_output` REFUSES a
// source longer than hidclass's buffer instead of truncating, so a 64-byte hand-over for
// the Xbox pad's 16-byte report would fail every read and the pad would look dead.
let st = request.copy_to_output(&report[..input_report_len(device_type())]);
request.complete(st);
}
}
@@ -39,6 +39,17 @@ pf_xusb.dll
[pfXusb.NT.HW]
Include=WUDFRD.inf
Needs=WUDFRD.NT.HW
AddReg=pfXusb_HW_AddReg
; The WGI/GameInput admission tripwire. Classic `xinput1_4` needs nothing here — it finds us by the
; XUSB device-interface GUID and polls GET_STATE, which is why the pad has always worked there
; (verified on .173 2026-08-09: our pad takes XInput slot 1 with live state). WGI and GameInput
; instead expect the in-box `xinputhid` filter on the stack, and without it they never admit the
; device however correct its IOCTL surface is. Pairs with the async WAIT_FOR_INPUT pump in
; src/lib.rs — the filter and the async wait are the two halves this driver's README has always
; listed as the missing WGI work; neither needs kernel-mode code.
[pfXusb_HW_AddReg]
HKR,,"UpperFilters",0x00010000,"xinputhid"
[pfXusb.NT.Services]
Include=WUDFRD.inf
+82 -4
View File
@@ -25,7 +25,7 @@
#![deny(unsafe_op_in_unsafe_fn)]
#![deny(clippy::undocumented_unsafe_blocks)]
use core::sync::atomic::{AtomicBool, Ordering};
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
use pf_driver_proto::gamepad::XusbShm;
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
use pf_umdf_util::nt_success;
@@ -34,7 +34,7 @@ use pf_umdf_util::wdf::{self, Request};
use wdk_sys::{
GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFREQUEST, WDFTIMER,
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
};
@@ -78,6 +78,15 @@ const XUSB_VERSION: u16 = 0x0103;
// ---- WDF enum values ----
const WdfIoQueueDispatchParallel: i32 = 2;
const WdfIoQueueDispatchManual: i32 = 3;
/// Manual queue holding pended [`IOCTL_XUSB_WAIT_FOR_INPUT`] requests; the periodic timer completes
/// them when the host publishes a new packet. See [`evt_timer`].
static WAIT_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
/// The `dwPacketNumber` the last completed wait reported — the edge the timer compares against, so
/// a waiter is only released when the state actually MOVED (that is the contract of an async wait;
/// completing it unconditionally would spin the caller at timer rate).
static WAIT_LAST_PACKET: AtomicU32 = AtomicU32::new(0);
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL
const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE
@@ -272,6 +281,35 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
return st;
}
// Manual queue for the ASYNC input wait (`IOCTL_XUSB_WAIT_FOR_INPUT`), completed by the timer.
//
// Declining that IOCTL is enough for CLASSIC XInput — `xinput1_4` just falls back to synchronous
// GET_STATE polling, which is why the pad has always worked there. It is NOT enough for
// WGI/GameInput: those poll asynchronously, so to them the decline is not a fallback but a
// refusal, and the device is never admitted. Measured 2026-08-09 on .173 — the pad reaches
// XInput slot 1 with live data while WGI/GameInput never see it at all.
// SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use.
let mut wcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
wcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
wcfg.DispatchType = WdfIoQueueDispatchManual;
wcfg.PowerManaged = WdfUseDefault;
let mut wait_queue: WDFQUEUE = core::ptr::null_mut();
// SAFETY: `device` + `wcfg` are valid; attributes null; `wait_queue` receives the handle.
let st = unsafe {
call_unsafe_wdf_function_binding!(
WdfIoQueueCreate,
device,
&mut wcfg,
WDF_NO_OBJECT_ATTRIBUTES,
&mut wait_queue
)
};
if !nt_success(st) {
dbglog!("[pf-xusb] wait WdfIoQueueCreate failed 0x{:08x}", st as u32);
return st;
}
WAIT_QUEUE.store(wait_queue, Ordering::SeqCst);
// Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in
// the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and
// the host's driver-attach health check goes green) even before any game polls XInput. Bounded;
@@ -333,6 +371,28 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI
extern "C" fn evt_timer(_timer: WDFTIMER) {
let live = CHANNEL.pump(&channel_cfg()).is_some();
HOST_LIVE.store(live, Ordering::Relaxed);
// Release one pended `WAIT_FOR_INPUT` per tick, but only on a real edge — the host bumps
// `dwPacketNumber` whenever it publishes new state, so an unchanged packet means nothing moved
// and a waiter that is completed anyway would just spin its caller at timer rate.
let data = CHANNEL.data();
let (packet, ..) = read_state(data);
if packet == WAIT_LAST_PACKET.load(Ordering::Relaxed) {
return;
}
let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst);
if wq.is_null() {
return;
}
// SAFETY: `wq` is the live manual queue created in EvtDeviceAdd — the contract
// `retrieve_next_request` requires. `None` simply means nobody is waiting.
if let Some(request) = unsafe { wdf::retrieve_next_request(wq) } {
WAIT_LAST_PACKET.store(packet, Ordering::Relaxed);
// Answer with the same 29-byte GET_STATE payload the synchronous path serves, so a caller
// that waits and a caller that polls observe byte-identical state.
let st = request.copy_to_output(&build_get_state(data));
request.complete(st);
}
}
/// The current controller state from the attached DATA section (zeros / neutral when unattached).
@@ -504,8 +564,26 @@ extern "C" fn evt_io_device_control(
IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS,
// Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling.
IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST,
// The async input wait is PENDED on the manual queue and completed by the timer when the
// packet number moves (see `evt_timer`) — WGI/GameInput poll this way and will not admit a
// device that refuses it. Classic `xinput1_4` never issues it (it polls GET_STATE), so this
// costs the working path nothing. A forward failure completes the request with its error.
IOCTL_XUSB_WAIT_FOR_INPUT => {
let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst);
if wq.is_null() {
STATUS_INVALID_DEVICE_REQUEST
} else {
// SAFETY: `wq` is the live manual queue created in EvtDeviceAdd; `request` is this
// dispatch's request and is CONSUMED by the forward (hence the early return).
match unsafe { request.forward_to_queue(wq) } {
Ok(()) => return,
Err((req, st)) => req.complete(st),
}
return;
}
}
// Still declined: the guide-button wait has no state of ours to signal on.
IOCTL_XUSB_WAIT_GUIDE_BUTTON => STATUS_INVALID_DEVICE_REQUEST,
other => {
dbglog!("[pf-xusb] unhandled IOCTL 0x{other:08x} in={input_len} out={output_len}");
STATUS_INVALID_DEVICE_REQUEST
+9 -2
View File
@@ -340,10 +340,17 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru
; install laid down); `driver uninstall` is best-effort and no-ops when nothing is installed.
; A VB-CABLE from an OLDER punktfunk install (bundled until the audio-substrate change) is
; deliberately NOT removed: it is a third-party shared component the user may use elsewhere.
; The host's own minted audio devnodes ("Punktfunk Speakers/Microphone") are likewise left in
; place - they are plain instances of Steam's streaming drivers, inert without the host.
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkVdisplayDriverUninstall"
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkGamepadDriverUninstall"
; ...and the audio devices the RUNNING HOST mints ("Punktfunk Speakers", "Punktfunk Microphone",
; the per-pad "Wireless Controller" endpoints). These have no installer payload behind them - the
; host creates them at runtime and re-resolves them across restarts by design - so nothing else in
; this uninstall would ever touch them, and the field report was that they sat in Sound settings
; forever after an uninstall. Marker-matched, so Steam's own streaming-audio devices and drivers
; (which our instances ride on, and which Remote Play still needs) are left alone. Runs after the
; two driver legs, and well after `service uninstall`: a live host re-mints them on its next
; wiring pass.
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --audio"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkAudioDeviceUninstall"
#ifdef WithWeb
; Remove the console's firewall rule + any LEGACY PunktfunkWeb task and stray listener (the
; service-supervised console itself died with `service uninstall` above, via its kill-on-close job;
+78
View File
@@ -0,0 +1,78 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "hid-descriptor-dump"
version = "0.26.0"
dependencies = [
"hidapi",
]
[[package]]
name = "hidapi"
version = "2.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c78dadfc12f865bc3fcac3897e64533b930737ceb9ef245c8277de98d0b010e9"
dependencies = [
"cc",
"cfg-if",
"libc",
"pkg-config",
"windows-sys",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+27
View File
@@ -0,0 +1,27 @@
# Capture a real HID device's report descriptor and decode it into something diffable against the
# blobs `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves. Every descriptor we ship must be
# CAPTURED, not constructed (see that file's provenance warning, and the three bugs a constructed
# one already cost us) — this is the tool that captures them.
#
# Deliberately NOT a workspace member (see the root `Cargo.toml` `exclude` list): it pulls `hidapi`,
# a C library needing libudev on Linux, which we do not want in `cargo build --workspace` or on any
# CI leg. It is a bring-your-own-hardware measurement tool — build it standalone on the box that has
# the pad:
#
# cargo run --manifest-path tools/hid-descriptor-dump/Cargo.toml -- --list
#
# Stands alone. Without this, cargo walks up, finds the repo's `[workspace]` and refuses to build a
# package that root does not list as a member.
[workspace]
[package]
name = "hid-descriptor-dump"
description = "Capture and decode a real HID device's report descriptor, for diffing against the ones we synthesize"
version = "0.26.0"
edition = "2024"
rust-version = "1.96.0"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
hidapi = "2.6"
@@ -0,0 +1,425 @@
DualSense Wireless Controller — report descriptor, captured 2026-08-09 on .173 over USB.
WHY THIS FILE EXISTS: it is the CONTROL that makes the Elite capture next to it trustworthy.
`DUALSENSE_RDESC` in packaging/windows/drivers/pf-gamepad/src/lib.rs is verbatim from real hardware
(via inputtino), so pointing the tool at a real DualSense on the same box, in the same session,
tests the tool against a known-good answer.
RESULT — PASS, on both halves of the tool:
* descriptor: the reconstruction reproduces the real DualSense layout exactly — input report 0x01,
64 bytes, axes X,Y,Z,Rz,Rx,Ry packed 8-bit at bytes 1..6, hat at 8.0, 15 buttons at 8.4, vendor
bulk to byte 63, output report 0x02, and the feature-report ladder 0x05/0x08/0x09/0x0A/0x0B/
0x0C/0x20/0x21/0x22/0x80..0x85/0xA0/0xE0/0xF0..0xF5. It came back 467 bytes against the real
273 — same layout, more verbose encoding. That single number is the evidence for the "diff the
layout, not the bytes" rule stated in the Elite capture's header.
* live reads: `--read 4` returned len=64 reports whose first byte is 0x01 (the report id), sticks
centred at 80 80 80 80 with the triggers at 00 00, byte 7 a monotonic counter, and the IMU and
trailing CRC bytes moving every frame. Exactly the documented report.
================================================================================================
COLLECTION 1/1 — 054C:0CE6 usage_page 0x0001 (Generic Desktop) usage 0x0005
manufacturer : Sony Interactive Entertainment
product : DualSense Wireless Controller
serial :
release : 0x0100
interface : 3
path : \\?\HID#VID_054C&PID_0CE6&MI_03#9&2429cc0c&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}
================================================================================================
-- RAW (467 bytes) --
0000 05 01 09 05 A1 01 85 01 09 30 09 31 09 32 09 35
0010 09 33 09 34 15 00 26 FF 00 75 08 95 06 81 02 06
0020 00 FF 09 20 15 00 26 FF 00 75 08 95 01 81 02 05
0030 01 09 39 15 00 25 07 35 00 46 3B 01 65 14 75 04
0040 95 01 81 42 05 09 19 01 29 0F 15 00 25 01 75 01
0050 95 0F 45 00 65 00 81 02 06 00 FF 09 21 15 00 25
0060 01 75 01 95 0D 81 02 09 22 15 00 26 FF 00 35 00
0070 46 3B 01 75 08 95 34 81 02 85 02 09 23 15 00 26
0080 FF 00 75 08 95 2F 91 02 85 05 09 33 15 00 26 FF
0090 00 75 08 95 28 B1 02 85 08 09 34 15 00 26 FF 00
00A0 75 08 95 2F B1 02 85 09 09 24 15 00 26 FF 00 75
00B0 08 95 13 B1 02 85 0A 09 25 15 00 26 FF 00 75 08
00C0 95 1A B1 02 85 0B 09 41 15 00 26 FF 00 75 08 95
00D0 29 B1 02 85 0C 09 42 15 00 26 FF 00 75 08 95 29
00E0 B1 02 85 20 09 26 15 00 26 FF 00 75 08 95 3F B1
00F0 02 85 21 09 27 15 00 26 FF 00 75 08 95 04 B1 02
0100 85 22 09 40 15 00 26 FF 00 75 08 95 3F B1 02 85
0110 80 09 28 15 00 26 FF 00 75 08 95 3F B1 02 85 81
0120 09 29 15 00 26 FF 00 75 08 95 3F B1 02 85 82 09
0130 2A 15 00 26 FF 00 75 08 95 09 B1 02 85 83 09 2B
0140 15 00 26 FF 00 75 08 95 3F B1 02 85 84 09 2C 15
0150 00 26 FF 00 75 08 95 3F B1 02 85 85 09 2D 15 00
0160 26 FF 00 75 08 95 02 B1 02 85 A0 09 2E 15 00 26
0170 FF 00 75 08 95 01 B1 02 85 E0 09 2F 15 00 26 FF
0180 00 75 08 95 3F B1 02 85 F0 09 30 15 00 26 FF 00
0190 75 08 95 3F B1 02 85 F1 09 31 15 00 26 FF 00 75
01A0 08 95 3F B1 02 85 F2 09 32 15 00 26 FF 00 75 08
01B0 95 0F B1 02 85 F4 09 35 15 00 26 FF 00 75 08 95
01C0 3F B1 02 85 F5 09 36 15 00 26 FF 00 75 08 95 03
01D0 B1 02 C0
-- ITEMS --
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x32, // Usage (Z)
0x09, 0x35, // Usage (Rz)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x06, // Report Count (6)
0x81, 0x02, // Input (Data,Var,Abs)
0x06, 0x00, 0xFF, // Usage Page (Vendor Defined)
0x09, 0x20, // Usage (0x20)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x07, // Logical Maximum (7)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x3B, 0x01, // Physical Maximum (315)
0x65, 0x14, // Unit (Eng Rot: Degrees)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null State)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x0F, // Usage Maximum (15)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x0F, // Report Count (15)
0x45, 0x00, // Physical Maximum (0)
0x65, 0x00, // Unit (None)
0x81, 0x02, // Input (Data,Var,Abs)
0x06, 0x00, 0xFF, // Usage Page (Vendor Defined)
0x09, 0x21, // Usage (0x21)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x0D, // Report Count (13)
0x81, 0x02, // Input (Data,Var,Abs)
0x09, 0x22, // Usage (0x22)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x3B, 0x01, // Physical Maximum (315)
0x75, 0x08, // Report Size (8)
0x95, 0x34, // Report Count (52)
0x81, 0x02, // Input (Data,Var,Abs)
0x85, 0x02, // Report ID (2)
0x09, 0x23, // Usage (0x23)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x2F, // Report Count (47)
0x91, 0x02, // Output (Data,Var,Abs)
0x85, 0x05, // Report ID (5)
0x09, 0x33, // Usage (0x33)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x28, // Report Count (40)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x08, // Report ID (8)
0x09, 0x34, // Usage (0x34)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x2F, // Report Count (47)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x09, // Report ID (9)
0x09, 0x24, // Usage (0x24)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x13, // Report Count (19)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x0A, // Report ID (10)
0x09, 0x25, // Usage (0x25)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x1A, // Report Count (26)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x0B, // Report ID (11)
0x09, 0x41, // Usage (0x41)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x29, // Report Count (41)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x0C, // Report ID (12)
0x09, 0x42, // Usage (0x42)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x29, // Report Count (41)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x20, // Report ID (32)
0x09, 0x26, // Usage (0x26)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x21, // Report ID (33)
0x09, 0x27, // Usage (0x27)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x04, // Report Count (4)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x22, // Report ID (34)
0x09, 0x40, // Usage (0x40)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x80, // Report ID (128)
0x09, 0x28, // Usage (0x28)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x81, // Report ID (129)
0x09, 0x29, // Usage (0x29)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x82, // Report ID (130)
0x09, 0x2A, // Usage (0x2A)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x09, // Report Count (9)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x83, // Report ID (131)
0x09, 0x2B, // Usage (0x2B)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x84, // Report ID (132)
0x09, 0x2C, // Usage (0x2C)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x85, // Report ID (133)
0x09, 0x2D, // Usage (0x2D)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xA0, // Report ID (160)
0x09, 0x2E, // Usage (0x2E)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xE0, // Report ID (224)
0x09, 0x2F, // Usage (0x2F)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xF0, // Report ID (240)
0x09, 0x30, // Usage (0x30)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xF1, // Report ID (241)
0x09, 0x31, // Usage (0x31)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xF2, // Report ID (242)
0x09, 0x32, // Usage (0x32)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x0F, // Report Count (15)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xF4, // Report ID (244)
0x09, 0x35, // Usage (0x35)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x3F, // Report Count (63)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0xF5, // Report ID (245)
0x09, 0x36, // Usage (0x36)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x03, // Report Count (3)
0xB1, 0x02, // Feature (Data,Var,Abs)
0xC0, // End Collection
-- LAYOUT --
Input report 0x01 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×6 X, Y, Z, Rz, Rx, Ry 0..255 Data,Var,Abs
7.0 8×1 0x20 0..255 Data,Var,Abs
8.0 4×1 Hat switch 0..7 Data,Var,Abs,Null State
8.4 1×15 Button 1..15 0..1 Data,Var,Abs
10.3 1×13 0x21 0..1 Data,Var,Abs
12.0 8×52 0x22 0..255 Data,Var,Abs
Output report 0x02 — 376 bits, 48 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×47 0x23 0..255 Data,Var,Abs
Feature report 0x05 — 320 bits, 41 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×40 0x33 0..255 Data,Var,Abs
Feature report 0x08 — 376 bits, 48 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×47 0x34 0..255 Data,Var,Abs
Feature report 0x09 — 152 bits, 20 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×19 0x24 0..255 Data,Var,Abs
Feature report 0x0A — 208 bits, 27 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×26 0x25 0..255 Data,Var,Abs
Feature report 0x0B — 328 bits, 42 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×41 0x41 0..255 Data,Var,Abs
Feature report 0x0C — 328 bits, 42 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×41 0x42 0..255 Data,Var,Abs
Feature report 0x20 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x26 0..255 Data,Var,Abs
Feature report 0x21 — 32 bits, 5 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×4 0x27 0..255 Data,Var,Abs
Feature report 0x22 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x40 0..255 Data,Var,Abs
Feature report 0x80 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x28 0..255 Data,Var,Abs
Feature report 0x81 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x29 0..255 Data,Var,Abs
Feature report 0x82 — 72 bits, 10 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×9 0x2A 0..255 Data,Var,Abs
Feature report 0x83 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x2B 0..255 Data,Var,Abs
Feature report 0x84 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x2C 0..255 Data,Var,Abs
Feature report 0x85 — 16 bits, 3 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×2 0x2D 0..255 Data,Var,Abs
Feature report 0xA0 — 8 bits, 2 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×1 0x2E 0..255 Data,Var,Abs
Feature report 0xE0 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x2F 0..255 Data,Var,Abs
Feature report 0xF0 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x30 0..255 Data,Var,Abs
Feature report 0xF1 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x31 0..255 Data,Var,Abs
Feature report 0xF2 — 120 bits, 16 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×15 0x32 0..255 Data,Var,Abs
Feature report 0xF4 — 504 bits, 64 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×63 0x35 0..255 Data,Var,Abs
Feature report 0xF5 — 24 bits, 4 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 8×3 0x36 0..255 Data,Var,Abs
-- SUMMARY --
INPUT items: 6
OUTPUT items: 1
FEATURE items: 22
structure: OK
-- RUST --
#[rustfmt::skip]
static DUALSENSE_CAPTURED: [u8; 467] = [
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0x81, 0x02, 0x05,
0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04,
0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
0x95, 0x0F, 0x45, 0x00, 0x65, 0x00, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21, 0x15, 0x00, 0x25,
0x01, 0x75, 0x01, 0x95, 0x0D, 0x81, 0x02, 0x09, 0x22, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x35, 0x00,
0x46, 0x3B, 0x01, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x15, 0x00, 0x26, 0xFF,
0x00, 0x75, 0x08, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00,
0x75, 0x08, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
0x08, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08,
0x95, 0x1A, 0xB1, 0x02, 0x85, 0x0B, 0x09, 0x41, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
0x29, 0xB1, 0x02, 0x85, 0x0C, 0x09, 0x42, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x29,
0xB1, 0x02, 0x85, 0x20, 0x09, 0x26, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1,
0x02, 0x85, 0x21, 0x09, 0x27, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85,
0x80, 0x09, 0x28, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81,
0x09, 0x29, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09,
0x2A, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0xB1, 0x02, 0x85, 0x83, 0x09, 0x2B,
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x15,
0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x15, 0x00,
0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F, 0x15, 0x00, 0x26, 0xFF,
0x00, 0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x15, 0x00, 0x26, 0xFF, 0x00,
0x75, 0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF1, 0x09, 0x31, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
0x08, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08,
0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03,
0xB1, 0x02, 0xC0,
];
@@ -0,0 +1,204 @@
Xbox Elite Wireless Controller Series 2 — report descriptor, as captured 2026-08-09.
HOW THIS WAS TAKEN
box .173, Windows 11 26200, German locale
pad Xbox Elite Wireless Controller Series 2, VID 045E PID 0B22, HID rev 0x0521,
BD_ADDR 686CE647F191, paired and connected over BLUETOOTH LOW ENERGY (HID-over-GATT).
Windows enumerates it as BTHLEDEVICE\{00001812-...}, NOT classic BTHENUM.
command hid-descriptor-dump --vid 045E --pid 0B22 --name XBOX_ELITE2_RDESC
tool tools/hid-descriptor-dump (this directory)
⚠️ WHAT THIS IS AND IS NOT — READ BEFORE COPYING BYTES OUT OF IT.
Windows exposes no API returning a device's literal report-descriptor bytes: the HID class driver
keeps only the parsed form, so hidapi RECONSTRUCTS a descriptor from HidD_GetPreparsedData. The
reconstruction is faithful in STRUCTURE, ITEM ORDER and every field's BIT OFFSET; the byte encoding
is not the wire encoding. Measured proof, from the same run against the DualSense on the same box:
its real descriptor is 273 bytes and the reconstruction came back 467, because the reconstructor
re-states global items (Logical Min/Max, Report Size) before every report instead of letting them
persist. Same layout, different bytes.
⇒ DIFF THE LAYOUT TABLE, NOT THE RAW BYTES. A byte-exact capture needs Linux
/sys/class/hidraw/hidrawN/device/report_descriptor.
⚠️ UNVERIFIED: whether this equals the pad's NATIVE report map. `xinputhid` is attached as an
UpperFilter on this pad's BLE transport node (DevicePropertyFlags=0x1 "BusDevice"), and the shape
below — one combined 16-bit `Z` trigger axis, 16 buttons, no report id, no OUTPUT collection — is
the classic legacy/DirectInput view rather than the two-separate-triggers layout documented for
Xbox pads over classic Bluetooth. The absence of ANY output collection is the tell: a real Xbox BT
pad does accept rumble output reports, and this view offers nowhere to send them. Cross-check on
Linux hidraw before treating this as the native map.
================================================================================================
COLLECTION 1/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0005
manufacturer : Microsoft
product : Xbox Wireless Controller
serial : 686ce647f191
release : 0x0521
interface : -1
path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col01&IG_00#c&7384879&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}
================================================================================================
-- RAW (117 bytes) --
0000 05 01 09 05 A1 01 09 00 A1 00 09 30 09 31 15 00
0010 25 FF 35 00 45 FF 75 10 95 02 81 02 C0 09 00 A1
0020 00 09 33 09 34 15 00 25 FF 75 10 95 02 81 02 C0
0030 09 00 A1 00 09 32 15 00 25 FF 75 10 95 01 81 02
0040 C0 05 09 19 01 29 10 15 00 25 01 75 01 95 10 45
0050 00 81 02 05 01 09 39 15 01 25 08 35 00 46 3B 10
0060 65 0E 75 04 95 01 81 42 75 04 95 01 81 03 75 08
0070 95 02 81 03 C0
-- ITEMS --
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x09, 0x00, // Usage (0x00)
0xA1, 0x00, // Collection (Physical)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x00, // Logical Minimum (0)
0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255)
0x35, 0x00, // Physical Minimum (0)
0x45, 0xFF, // Physical Maximum (-1)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection
0x09, 0x00, // Usage (0x00)
0xA1, 0x00, // Collection (Physical)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x15, 0x00, // Logical Minimum (0)
0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection
0x09, 0x00, // Usage (0x00)
0xA1, 0x00, // Collection (Physical)
0x09, 0x32, // Usage (Z)
0x15, 0x00, // Logical Minimum (0)
0x25, 0xFF, // Logical Maximum (-1 — unsigned reading: 255)
0x75, 0x10, // Report Size (16)
0x95, 0x01, // Report Count (1)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0, // End Collection
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x10, // Usage Maximum (16)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x10, // Report Count (16)
0x45, 0x00, // Physical Maximum (0)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x01, // Logical Minimum (1)
0x25, 0x08, // Logical Maximum (8)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x3B, 0x10, // Physical Maximum (4155)
0x65, 0x0E, // Unit (0xE)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null State)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Cnst,Var,Abs)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x03, // Input (Cnst,Var,Abs)
0xC0, // End Collection
-- LAYOUT --
Input report 0x00 — 120 bits, 15 bytes on the wire (unnumbered)
byte.bit size×cnt usage logical range flags
0.0 16×2 X, Y 0..-1 Data,Var,Abs
4.0 16×2 Rx, Ry 0..-1 Data,Var,Abs
8.0 16×1 Z 0..-1 Data,Var,Abs
10.0 1×16 Button 1..16 0..1 Data,Var,Abs
12.0 4×1 Hat switch 1..8 Data,Var,Abs,Null State
12.4 4×1 — (padding) 1..8 Cnst,Var,Abs
13.0 8×2 — (padding) 1..8 Cnst,Var,Abs
-- SUMMARY --
INPUT items: 7
OUTPUT items: 0 <-- NONE
FEATURE items: 0 <-- NONE
structure: OK
-- RUST --
#[rustfmt::skip]
static XBOX_ELITE2_RDESC_COL01: [u8; 117] = [
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x09, 0x00, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x00,
0x25, 0xFF, 0x35, 0x00, 0x45, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0, 0x09, 0x00, 0xA1,
0x00, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x02, 0x81, 0x02, 0xC0,
0x09, 0x00, 0xA1, 0x00, 0x09, 0x32, 0x15, 0x00, 0x25, 0xFF, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02,
0xC0, 0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x10, 0x45,
0x00, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x01, 0x25, 0x08, 0x35, 0x00, 0x46, 0x3B, 0x10,
0x65, 0x0E, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x75, 0x04, 0x95, 0x01, 0x81, 0x03, 0x75, 0x08,
0x95, 0x02, 0x81, 0x03, 0xC0,
];
================================================================================================
COLLECTION 2/2 — 045E:0B22 usage_page 0x0001 (Generic Desktop) usage 0x0006
manufacturer : Microsoft
product : Xbox Wireless Controller
serial : 686ce647f191
release : 0x0521
interface : -1
path : \\?\HID#{00001812-0000-1000-8000-00805f9b34fb}&Dev&VID_045e&PID_0b22&REV_0521&686ce647f191&Col02&IG_00#c&7384879&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}\KBD
================================================================================================
-- RAW (45 bytes) --
0000 05 01 09 06 A1 01 85 05 05 07 19 E0 29 E7 15 00
0010 25 01 75 01 95 08 81 02 75 08 95 01 81 03 19 00
0020 29 65 15 00 25 65 75 08 95 06 81 00 C0
-- ITEMS --
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x05, // Report ID (5)
0x05, 0x07, // Usage Page (Keyboard/Keypad)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x03, // Input (Cnst,Var,Abs)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x75, 0x08, // Report Size (8)
0x95, 0x06, // Report Count (6)
0x81, 0x00, // Input (Data,Arr,Abs)
0xC0, // End Collection
-- LAYOUT --
Input report 0x05 — 64 bits, 9 bytes on the wire (id included)
byte.bit size×cnt usage logical range flags
1.0 1×8 Keyboard/Keypad 224..231 0..1 Data,Var,Abs
2.0 8×1 — (padding) 0..1 Cnst,Var,Abs
3.0 8×6 Keyboard/Keypad 0..101 0..101 Data,Arr,Abs
-- SUMMARY --
INPUT items: 3
OUTPUT items: 0 <-- NONE
FEATURE items: 0 <-- NONE
structure: OK
-- RUST --
#[rustfmt::skip]
static XBOX_ELITE2_RDESC_COL02: [u8; 45] = [
0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, 0x05, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00,
0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x75, 0x08, 0x95, 0x01, 0x81, 0x03, 0x19, 0x00,
0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
];
+581
View File
@@ -0,0 +1,581 @@
//! A HID 1.11 report-descriptor decoder, written for ONE job: making a captured descriptor
//! diffable, by eye, against the hand-annotated blobs in
//! `packaging/windows/drivers/pf-gamepad/src/lib.rs`.
//!
//! Two outputs matter, and they answer different questions:
//!
//! * the **item listing** — one line per HID item, formatted exactly like the annotated `static
//! XBOX_RDESC` arrays, so a capture can be pasted straight in and read side by side;
//! * the **layout map** — the running bit offset of every field, per report id and per report
//! kind. This is the one that catches the bugs that actually bite: `xbox_proto`'s layout tests
//! pin byte offsets, and a descriptor that declares the same usages in a different ORDER lands
//! every control on the wrong byte while looking correct item for item.
use std::fmt::Write as _;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MainKind {
Input,
Output,
Feature,
}
impl MainKind {
pub fn as_str(self) -> &'static str {
match self {
MainKind::Input => "Input",
MainKind::Output => "Output",
MainKind::Feature => "Feature",
}
}
}
/// One `Input`/`Output`/`Feature` main item, resolved against the global/local state in force.
pub struct Field {
pub kind: MainKind,
pub report_id: u8,
/// Bit offset within the report, report id byte NOT included (it is offset 0 of the wire
/// bytes, so add 8 when comparing against a wire layout that carries the id).
pub bit_offset: u32,
pub bit_size: u32,
pub count: u32,
pub usage_page: u16,
pub usages: Vec<u32>,
pub usage_range: Option<(u32, u32)>,
pub logical_min: i64,
pub logical_max: i64,
pub flags: u32,
}
impl Field {
fn is_constant(&self) -> bool {
self.flags & 1 != 0
}
/// How the field would be written in an `Input (...)` annotation.
fn flags_str(&self) -> String {
let mut parts: Vec<&str> = Vec::new();
parts.push(if self.flags & 0x01 != 0 {
"Cnst"
} else {
"Data"
});
parts.push(if self.flags & 0x02 != 0 { "Var" } else { "Arr" });
parts.push(if self.flags & 0x04 != 0 { "Rel" } else { "Abs" });
if self.flags & 0x08 != 0 {
parts.push("Wrap");
}
if self.flags & 0x10 != 0 {
parts.push("NonLin");
}
if self.flags & 0x20 != 0 {
parts.push("NoPref");
}
if self.flags & 0x40 != 0 {
parts.push("Null State");
}
if self.flags & 0x80 != 0 {
parts.push("Volatile");
}
if self.flags & 0x100 != 0 {
parts.push("Buff");
}
parts.join(",")
}
}
pub struct Decoded {
/// The annotated item listing.
pub listing: String,
pub fields: Vec<Field>,
/// Anything structurally wrong — trailing bytes, unbalanced collections, a truncated item.
pub problems: Vec<String>,
}
#[derive(Clone, Default)]
struct GlobalState {
usage_page: u16,
logical_min: i64,
logical_max: i64,
physical_min: i64,
physical_max: i64,
unit: u32,
unit_exp: u32,
report_size: u32,
report_id: u8,
report_count: u32,
}
/// Running bit cursor, keyed by (report id, kind) — each report kind numbers its bits from zero.
#[derive(Default)]
struct Cursors {
input: Vec<(u8, u32)>,
output: Vec<(u8, u32)>,
feature: Vec<(u8, u32)>,
}
impl Cursors {
fn take(&mut self, kind: MainKind, id: u8, bits: u32) -> u32 {
let v = match kind {
MainKind::Input => &mut self.input,
MainKind::Output => &mut self.output,
MainKind::Feature => &mut self.feature,
};
match v.iter_mut().find(|(rid, _)| *rid == id) {
Some((_, at)) => {
let start = *at;
*at += bits;
start
}
None => {
v.push((id, bits));
0
}
}
}
}
/// Sign-extend `value`, which came off the wire in `size` bytes.
fn sign_extend(value: u32, size: usize) -> i64 {
match size {
1 => value as u8 as i8 as i64,
2 => value as u16 as i16 as i64,
4 => value as i32 as i64,
_ => value as i64,
}
}
pub fn usage_page_name(page: u16) -> &'static str {
match page {
0x01 => "Generic Desktop",
0x02 => "Simulation Controls",
0x03 => "VR Controls",
0x04 => "Sport Controls",
0x05 => "Game Controls",
0x06 => "Generic Device Controls",
0x07 => "Keyboard/Keypad",
0x08 => "LED",
0x09 => "Button",
0x0A => "Ordinal",
0x0C => "Consumer",
0x0D => "Digitizer",
0x0F => "Physical Input Device (PID)",
0xFF00..=0xFFFF => "Vendor Defined",
_ => "",
}
}
fn usage_name(page: u16, usage: u32) -> &'static str {
match (page, usage) {
(0x01, 0x01) => "Pointer",
(0x01, 0x02) => "Mouse",
(0x01, 0x04) => "Joystick",
(0x01, 0x05) => "Game Pad",
(0x01, 0x06) => "Keyboard",
(0x01, 0x30) => "X",
(0x01, 0x31) => "Y",
(0x01, 0x32) => "Z",
(0x01, 0x33) => "Rx",
(0x01, 0x34) => "Ry",
(0x01, 0x35) => "Rz",
(0x01, 0x36) => "Slider",
(0x01, 0x37) => "Dial",
(0x01, 0x38) => "Wheel",
(0x01, 0x39) => "Hat switch",
(0x01, 0x3A) => "Counted Buffer",
(0x01, 0x80) => "System Control",
(0x01, 0x85) => "System Main Menu",
(0x02, 0xC4) => "Accelerator",
(0x02, 0xC5) => "Brake",
(0x02, 0xBB) => "Throttle",
(0x02, 0xBA) => "Rudder",
(0x06, 0x20) => "Battery Strength",
(0x0C, 0x01) => "Consumer Control",
(0x0C, 0x223) => "AC Home",
(0x0C, 0x224) => "AC Back",
_ => "",
}
}
fn collection_name(v: u32) -> &'static str {
match v {
0x00 => "Physical",
0x01 => "Application",
0x02 => "Logical",
0x03 => "Report",
0x04 => "Named Array",
0x05 => "Usage Switch",
0x06 => "Usage Modifier",
_ => "Vendor",
}
}
pub fn decode(desc: &[u8]) -> Decoded {
let mut listing = String::new();
let mut problems = Vec::new();
let mut fields = Vec::new();
let mut g = GlobalState::default();
let mut stack: Vec<GlobalState> = Vec::new();
let mut usages: Vec<u32> = Vec::new();
let mut usage_min: Option<u32> = None;
let mut usage_max: Option<u32> = None;
let mut cursors = Cursors::default();
let mut depth: usize = 0;
let mut i = 0usize;
while i < desc.len() {
let prefix = desc[i];
let start = i;
// Long items (prefix 0xFE) exist in the spec and in no gamepad we have ever seen; carry
// them through so an unexpected one is reported rather than silently desynchronising the
// rest of the parse.
if prefix == 0xFE {
if i + 2 >= desc.len() {
problems.push(format!("truncated long item at byte {start}"));
break;
}
let data_size = desc[i + 1] as usize;
let tag = desc[i + 2];
let end = i + 3 + data_size;
if end > desc.len() {
problems.push(format!("long item at byte {start} runs past the end"));
break;
}
let _ = writeln!(
listing,
"{:pad$}0xFE, /* long item, tag 0x{tag:02X}, {data_size} bytes */",
"",
pad = depth * 2
);
i = end;
continue;
}
let size_code = (prefix & 0x03) as usize;
let data_size = if size_code == 3 { 4 } else { size_code };
let ty = (prefix >> 2) & 0x03;
let tag = prefix >> 4;
if i + 1 + data_size > desc.len() {
problems.push(format!(
"truncated item at byte {start}: prefix 0x{prefix:02X} wants {data_size} data bytes, \
{} remain",
desc.len() - i - 1
));
break;
}
let mut raw: u32 = 0;
for b in 0..data_size {
raw |= (desc[i + 1 + b] as u32) << (8 * b);
}
let signed = sign_extend(raw, data_size);
i += 1 + data_size;
let bytes_hex = desc[start..i]
.iter()
.map(|b| format!("0x{b:02X},"))
.collect::<Vec<_>>()
.join(" ");
// Indentation mirrors the annotated arrays in the driver: collections indent their body.
let mut emit = |depth: usize, text: String| {
let _ = writeln!(
listing,
"{:<38} // {:pad$}{text}",
bytes_hex,
"",
pad = depth * 2
);
};
match ty {
// ---- Main ----
0 => match tag {
0x08 | 0x09 | 0x0B => {
let kind = match tag {
0x08 => MainKind::Input,
0x09 => MainKind::Output,
_ => MainKind::Feature,
};
let bits = g.report_size * g.report_count;
let bit_offset = cursors.take(kind, g.report_id, bits);
let f = Field {
kind,
report_id: g.report_id,
bit_offset,
bit_size: g.report_size,
count: g.report_count,
usage_page: g.usage_page,
usages: usages.clone(),
usage_range: match (usage_min, usage_max) {
(Some(a), Some(b)) => Some((a, b)),
_ => None,
},
logical_min: g.logical_min,
logical_max: g.logical_max,
flags: raw,
};
emit(depth, format!("{} ({})", kind.as_str(), f.flags_str()));
fields.push(f);
usages.clear();
usage_min = None;
usage_max = None;
}
0x0A => {
emit(depth, format!("Collection ({})", collection_name(raw)));
depth += 1;
usages.clear();
usage_min = None;
usage_max = None;
}
0x0C => {
depth = depth.saturating_sub(1);
emit(depth, "End Collection".to_string());
usages.clear();
usage_min = None;
usage_max = None;
}
_ => {
problems.push(format!("unknown Main tag 0x{tag:X} at byte {start}"));
emit(depth, format!("<unknown Main tag 0x{tag:X}>"));
}
},
// ---- Global ----
1 => match tag {
0x0 => {
g.usage_page = raw as u16;
let n = usage_page_name(g.usage_page);
emit(
depth,
if n.is_empty() {
format!("Usage Page (0x{:04X})", g.usage_page)
} else {
format!("Usage Page ({n})")
},
);
}
0x1 => {
g.logical_min = signed;
emit(depth, format!("Logical Minimum ({signed})"));
}
0x2 => {
g.logical_max = signed;
emit(
depth,
// A maximum is only signed when the minimum was; showing both readings
// keeps a `0x25 0xFF` (255 or -1) from being silently misread.
if g.logical_min < 0 || signed >= 0 {
format!("Logical Maximum ({signed})")
} else {
format!("Logical Maximum ({signed} — unsigned reading: {raw})")
},
);
}
0x3 => {
g.physical_min = signed;
emit(depth, format!("Physical Minimum ({signed})"));
}
0x4 => {
g.physical_max = signed;
emit(depth, format!("Physical Maximum ({signed})"));
}
0x5 => {
g.unit_exp = raw;
emit(depth, format!("Unit Exponent (0x{raw:X})"));
}
0x6 => {
g.unit = raw;
emit(
depth,
match raw {
0x14 => "Unit (Eng Rot: Degrees)".to_string(),
0x00 => "Unit (None)".to_string(),
_ => format!("Unit (0x{raw:X})"),
},
);
}
0x7 => {
g.report_size = raw;
emit(depth, format!("Report Size ({raw})"));
}
0x8 => {
g.report_id = raw as u8;
emit(depth, format!("Report ID ({raw})"));
}
0x9 => {
g.report_count = raw;
emit(depth, format!("Report Count ({raw})"));
}
0xA => {
stack.push(g.clone());
emit(depth, "Push".to_string());
}
0xB => {
match stack.pop() {
Some(prev) => g = prev,
None => problems.push(format!("Pop with an empty stack at byte {start}")),
}
emit(depth, "Pop".to_string());
}
_ => {
problems.push(format!("unknown Global tag 0x{tag:X} at byte {start}"));
emit(depth, format!("<unknown Global tag 0x{tag:X}>"));
}
},
// ---- Local ----
2 => match tag {
0x0 => {
// A 4-byte Usage carries its page in the high half.
let (page, u) = if data_size == 4 {
((raw >> 16) as u16, raw & 0xFFFF)
} else {
(g.usage_page, raw)
};
usages.push(u);
let n = usage_name(page, u);
emit(
depth,
if n.is_empty() {
format!("Usage (0x{u:02X})")
} else {
format!("Usage ({n})")
},
);
}
0x1 => {
usage_min = Some(raw);
emit(depth, format!("Usage Minimum ({raw})"));
}
0x2 => {
usage_max = Some(raw);
emit(depth, format!("Usage Maximum ({raw})"));
}
0x3 => emit(depth, format!("Designator Index ({raw})")),
0x4 => emit(depth, format!("Designator Minimum ({raw})")),
0x5 => emit(depth, format!("Designator Maximum ({raw})")),
0x7 => emit(depth, format!("String Index ({raw})")),
0x8 => emit(depth, format!("String Minimum ({raw})")),
0x9 => emit(depth, format!("String Maximum ({raw})")),
0xA => emit(depth, format!("Delimiter ({raw})")),
_ => {
problems.push(format!("unknown Local tag 0x{tag:X} at byte {start}"));
emit(depth, format!("<unknown Local tag 0x{tag:X}>"));
}
},
_ => {
problems.push(format!("reserved item type at byte {start}"));
emit(depth, "<reserved item type>".to_string());
}
}
}
if depth != 0 {
problems.push(format!("{depth} collection(s) never closed"));
}
if !stack.is_empty() {
problems.push(format!("{} Push(es) never popped", stack.len()));
}
Decoded {
listing,
fields,
problems,
}
}
/// The bit-offset table. This is what a layout diff should be read off — item order, not item
/// presence, is what silently lands a control on the wrong byte.
pub fn layout_map(fields: &[Field]) -> String {
let mut out = String::new();
for kind in [MainKind::Input, MainKind::Output, MainKind::Feature] {
let mut ids: Vec<u8> = fields
.iter()
.filter(|f| f.kind == kind)
.map(|f| f.report_id)
.collect();
ids.sort_unstable();
ids.dedup();
for id in ids {
let of_report: Vec<&Field> = fields
.iter()
.filter(|f| f.kind == kind && f.report_id == id)
.collect();
let bits: u32 = of_report.iter().map(|f| f.bit_size * f.count).sum();
// The id byte is on the wire whenever the descriptor numbers its reports at all.
let wire = if id == 0 {
bits.div_ceil(8) as usize
} else {
bits.div_ceil(8) as usize + 1
};
let _ = writeln!(
out,
"\n {} report 0x{id:02X} — {bits} bits, {wire} bytes on the wire{}",
kind.as_str(),
if id == 0 {
" (unnumbered)"
} else {
" (id included)"
}
);
let _ = writeln!(
out,
" {:<12} {:<9} {:<26} {:<20} flags",
"byte.bit", "size×cnt", "usage", "logical range"
);
for f in of_report {
let id_shift = if id == 0 { 0 } else { 8 };
let abs = f.bit_offset + id_shift;
let usage = if let Some((a, b)) = f.usage_range {
format!("{} {a}..{b}", usage_page_name(f.usage_page))
} else if f.usages.is_empty() {
if f.is_constant() {
"— (padding)".to_string()
} else {
"— (none declared)".to_string()
}
} else {
f.usages
.iter()
.map(|u| {
let n = usage_name(f.usage_page, *u);
if n.is_empty() {
format!("0x{u:02X}")
} else {
n.to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
};
let _ = writeln!(
out,
" {:<12} {:<9} {:<26} {:<20} {}",
format!("{}.{}", abs / 8, abs % 8),
format!("{}×{}", f.bit_size, f.count),
usage,
format!("{}..{}", f.logical_min, f.logical_max),
f.flags_str()
);
}
}
}
out
}
/// Emit the blob as a `static` ready to paste into the driver.
pub fn rust_array(name: &str, desc: &[u8]) -> String {
let mut out = format!(
"#[rustfmt::skip]\nstatic {name}: [u8; {}] = [\n",
desc.len()
);
for chunk in desc.chunks(16) {
out.push_str(" ");
for b in chunk {
let _ = write!(out, "0x{b:02X}, ");
}
out.push('\n');
}
out.push_str("];\n");
out
}
+432
View File
@@ -0,0 +1,432 @@
//! Capture a real HID device's report descriptor, decode it, and print it in the shape the
//! `pf-gamepad` driver keeps its blobs in.
//!
//! WHY THIS EXISTS. `packaging/windows/drivers/pf-gamepad/src/lib.rs` serves a report descriptor
//! per emulated pad. Three of the four are verbatim captures off real hardware; `XBOX_RDESC` was
//! hand-constructed, and its own provenance warning came true three separate times (a missing
//! channel-proof Feature report meant the pad never delivered a single input report; there is no
//! OUTPUT collection at all, so rumble cannot arrive; `xinputhid` appears to validate the
//! descriptor and rejects ours). We claim a genuine Microsoft VID/PID, and SDL, Steam and Windows
//! all apply stock mappings keyed on it — so a layout that differs from the real pad lands every
//! control on the wrong action. Captures, not constructions.
//!
//! USAGE
//! ```text
//! hid-descriptor-dump --list # every HID device, with vid/pid and usage
//! hid-descriptor-dump --vid 045E --pid 0B22 # dump every collection of that device
//! hid-descriptor-dump --vid 054C --pid 0CE6 --name DUALSENSE_RDESC
//! hid-descriptor-dump --path '\\?\HID#...' # one exact collection
//! ```
//!
//! WHAT THE DESCRIPTOR COMES FROM, PER PLATFORM. On Linux hidapi reads
//! `/sys/class/hidraw/hidrawN/device/report_descriptor` — the literal bytes the device sent. On
//! Windows there is no API that returns those bytes: the HID class driver keeps only the parsed
//! form, so hidapi RECONSTRUCTS a descriptor from `HidD_GetPreparsedData`. The reconstruction is
//! faithful in structure, item order and every field's bit offset — which is what a layout diff
//! needs — but the byte encoding may differ from the wire (an item the device sent as one byte can
//! come back as two, and hidapi emits collections it inferred). ⇒ **Diff the LAYOUT MAP and the
//! item listing, not the raw bytes, when the capture came off Windows.** A byte-exact capture
//! needs Linux hidraw.
//!
//! This tool is deliberately not a workspace member; see its `Cargo.toml`.
mod decode;
use std::process::ExitCode;
struct Args {
list: bool,
vid: Option<u16>,
pid: Option<u16>,
path: Option<String>,
name: Option<String>,
read: Option<usize>,
rust_source: Option<String>,
symbol: Option<String>,
}
/// Pull a `static NAME: [u8; N] = [ 0x.., ... ];` out of a Rust source file.
///
/// This is what makes the diff exact rather than eyeballed: our own shipped blobs get decoded by
/// the same decoder, into the same listing and the same layout table, as a capture off real
/// hardware. No hardware needed for this mode.
fn extract_rust_array(src: &str, symbol: &str) -> Result<Vec<u8>, String> {
let at = src
.find(&format!("static {symbol}:"))
.ok_or_else(|| format!("no `static {symbol}:` in that file"))?;
// 🛑 STRIP COMMENTS FIRST, then find the brackets — not the other way round. These arrays are
// heavily annotated and the annotations contain both `,` and `]` (a comment documenting a wire
// layout as `[0x03][enable][left]…` is real, and it appears inside `XBOX_RDESC`). Locating the
// closing bracket on the raw text stops at the first `]` in a COMMENT and silently truncates
// the array — which reads as a corrupt descriptor rather than a parse bug.
let tail: String = src[at..]
.lines()
.map(|l| l.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
// First `[` is the type's `[u8; N]`; the next one opens the literal.
let open = tail
.find('[')
.and_then(|i| tail[i + 1..].find('[').map(|j| i + 1 + j + 1))
.ok_or("could not find the array literal")?;
let close = tail[open..]
.find(']')
.ok_or("array literal is never closed")?
+ open;
let body = &tail[open..close];
let mut out = Vec::new();
for tok in body.split(',') {
let tok = tok.trim();
if tok.is_empty() {
continue;
}
let hex = tok.trim_start_matches("0x").trim_start_matches("0X");
out.push(u8::from_str_radix(hex, 16).map_err(|_| format!("`{tok}` is not a hex byte"))?);
}
Ok(out)
}
fn parse_u16(s: &str) -> Option<u16> {
let s = s.trim_start_matches("0x").trim_start_matches("0X");
u16::from_str_radix(s, 16).ok()
}
fn parse_args() -> Result<Args, String> {
let mut a = Args {
list: false,
vid: None,
pid: None,
path: None,
name: None,
read: None,
rust_source: None,
symbol: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--list" | "-l" => a.list = true,
"--vid" => {
let v = it.next().ok_or("--vid wants a hex value")?;
a.vid = Some(parse_u16(&v).ok_or_else(|| format!("--vid: {v} is not hex"))?);
}
"--pid" => {
let v = it.next().ok_or("--pid wants a hex value")?;
a.pid = Some(parse_u16(&v).ok_or_else(|| format!("--pid: {v} is not hex"))?);
}
"--path" => a.path = Some(it.next().ok_or("--path wants a device path")?),
"--name" => a.name = Some(it.next().ok_or("--name wants an identifier")?),
"--read" => {
let v = it.next().ok_or("--read wants a count")?;
a.read = Some(
v.parse()
.map_err(|_| format!("--read: {v} is not a count"))?,
);
}
"--rust-source" => a.rust_source = Some(it.next().ok_or("--rust-source wants a path")?),
"--symbol" => a.symbol = Some(it.next().ok_or("--symbol wants an identifier")?),
"--help" | "-h" => {
println!("{}", HELP);
std::process::exit(0);
}
other => return Err(format!("unknown argument {other}")),
}
}
if !a.list && a.vid.is_none() && a.path.is_none() && a.rust_source.is_none() {
a.list = true;
}
if a.rust_source.is_some() != a.symbol.is_some() {
return Err("--rust-source and --symbol go together".into());
}
Ok(a)
}
const HELP: &str = "\
hid-descriptor-dump capture a real HID device's report descriptor
--list list every HID device this box can open
--vid <hex> select by vendor id (e.g. 045E)
--pid <hex> select by product id (e.g. 0B22)
--path <string> select one exact collection by its device path
--name <IDENT> also emit a `static IDENT: [u8; N]` ready to paste into the driver
--read <n> after dumping, read n live input reports and show which bytes move
--rust-source <file> decode a blob we already ship instead of a device (no hardware needed)
--symbol <IDENT> which `static IDENT: [u8; N]` in that file to decode
With --vid/--pid every matching collection is dumped: a real Xbox pad presents two (a game
controller and a keyboard), and they are separate devices to hidapi.
--read is the ground truth a reconstructed descriptor cannot give you: it is the literal wire
bytes. Use it to settle report length, whether reports are numbered, and which byte a control
actually lives in wiggle one control at a time and watch the changed-byte mask.";
/// Print a descriptor every way that is useful for a diff: raw, item listing, layout table,
/// a presence summary, and optionally a paste-ready Rust `static`.
fn report(desc: &[u8], emit_as: Option<&str>) {
println!("\n-- RAW ({} bytes) --", desc.len());
for (off, chunk) in desc.chunks(16).enumerate() {
let hex = chunk
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ");
println!(" {:04X} {hex}", off * 16);
}
let decoded = decode::decode(desc);
println!("\n-- ITEMS --");
print!("{}", decoded.listing);
println!("\n-- LAYOUT --");
print!("{}", decode::layout_map(&decoded.fields));
println!("\n-- SUMMARY --");
for (k, label) in [
(decode::MainKind::Input, "INPUT"),
(decode::MainKind::Output, "OUTPUT"),
(decode::MainKind::Feature, "FEATURE"),
] {
let count = decoded.fields.iter().filter(|f| f.kind == k).count();
println!(
" {label:<8} items: {count}{}",
if count == 0 { " <-- NONE" } else { "" }
);
}
if decoded.problems.is_empty() {
println!(" structure: OK");
} else {
println!(" structure: {} PROBLEM(S)", decoded.problems.len());
for p in &decoded.problems {
println!(" - {p}");
}
}
if let Some(name) = emit_as {
println!("\n-- RUST --");
print!("{}", decode::rust_array(name, desc));
}
}
/// Read live input reports and show which bytes ever move. The descriptor says where a control
/// SHOULD be; this says where it IS.
fn watch(dev: &hidapi::HidDevice, count: usize) {
println!("\n-- LIVE REPORTS ({count} requested, 3 s each) --");
println!(" (move ONE control at a time and read the changed-byte mask)");
let mut buf = [0u8; 256];
let mut first: Option<Vec<u8>> = None;
let mut ever_changed = vec![false; 256];
let mut got = 0usize;
for _ in 0..count {
match dev.read_timeout(&mut buf, 3000) {
Ok(0) => {
println!(" (timeout — no report; the pad may be idle)");
continue;
}
Ok(n) => {
got += 1;
let sample = &buf[..n];
let hex = sample
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ");
match &first {
None => {
println!(" len={n} {hex} <-- baseline");
first = Some(sample.to_vec());
}
Some(base) => {
let mut marks = String::new();
for i in 0..n {
let differs = base.get(i) != Some(&sample[i]);
if differs {
ever_changed[i] = true;
}
marks.push_str(if differs { "^^ " } else { ".. " });
}
println!(" len={n} {hex}");
println!(" {marks}");
}
}
}
Err(e) => {
println!(" read error: {e}");
break;
}
}
}
if let Some(base) = &first {
let moved: Vec<String> = (0..base.len())
.filter(|i| ever_changed[*i])
.map(|i| i.to_string())
.collect();
println!(
" {got} report(s); report length {}; bytes that ever moved: {}",
base.len(),
if moved.is_empty() {
"none".to_string()
} else {
moved.join(", ")
}
);
println!(
" first byte of every report was 0x{:02X} — {}",
base[0],
if base[0] == 0x01 {
"consistent with a numbered report id 1"
} else {
"note this when deciding whether reports are numbered"
}
);
}
}
fn main() -> ExitCode {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("error: {e}\n\n{HELP}");
return ExitCode::FAILURE;
}
};
// Decoding one of our own blobs needs no hardware, so it runs before hidapi is even opened —
// this mode works on any box, including CI and a Mac.
if let (Some(file), Some(symbol)) = (&args.rust_source, &args.symbol) {
let src = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {file}: {e}");
return ExitCode::FAILURE;
}
};
let desc = match extract_rust_array(&src, symbol) {
Ok(d) => d,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
println!("{}", "=".repeat(96));
println!("SHIPPED BLOB — {symbol} from {file}");
println!("{}", "=".repeat(96));
report(&desc, args.name.as_deref());
return ExitCode::SUCCESS;
}
let api = match hidapi::HidApi::new() {
Ok(a) => a,
Err(e) => {
eprintln!("error: hidapi init failed: {e}");
return ExitCode::FAILURE;
}
};
let devices: Vec<_> = api.device_list().collect();
if args.list {
println!(
"{:<6} {:<6} {:<5} {:<5} {:<34} path",
"vid", "pid", "page", "usage", "product"
);
for d in &devices {
println!(
"{:04X} {:04X} {:04X} {:04X} {:<34} {}",
d.vendor_id(),
d.product_id(),
d.usage_page(),
d.usage(),
d.product_string().unwrap_or(""),
d.path().to_string_lossy()
);
}
println!("\n{} device(s).", devices.len());
if args.vid.is_none() && args.path.is_none() {
return ExitCode::SUCCESS;
}
}
let selected: Vec<_> = devices
.iter()
.filter(|d| {
if let Some(p) = &args.path {
return d.path().to_string_lossy() == p.as_str();
}
args.vid.is_none_or(|v| d.vendor_id() == v)
&& args.pid.is_none_or(|p| d.product_id() == p)
})
.collect();
if selected.is_empty() {
eprintln!(
"error: nothing matched. If this is a Bluetooth pad, POWER IT ON — a disconnected BLE \
device leaves its devnodes behind but has no HID interface to open."
);
return ExitCode::FAILURE;
}
let mut failures = 0usize;
for (n, d) in selected.iter().enumerate() {
println!("\n{}", "=".repeat(96));
println!(
"COLLECTION {}/{} — {:04X}:{:04X} usage_page 0x{:04X} ({}) usage 0x{:04X}",
n + 1,
selected.len(),
d.vendor_id(),
d.product_id(),
d.usage_page(),
decode::usage_page_name(d.usage_page()),
d.usage()
);
println!(
" manufacturer : {}",
d.manufacturer_string().unwrap_or("")
);
println!(" product : {}", d.product_string().unwrap_or(""));
println!(" serial : {}", d.serial_number().unwrap_or(""));
println!(" release : 0x{:04X}", d.release_number());
println!(" interface : {}", d.interface_number());
println!(" path : {}", d.path().to_string_lossy());
println!("{}", "=".repeat(96));
let dev = match api.open_path(d.path()) {
Ok(dev) => dev,
Err(e) => {
eprintln!(" !! could not open: {e}");
failures += 1;
continue;
}
};
// 4 KiB is the HID class driver's own ceiling for a report descriptor.
let mut buf = vec![0u8; 4096];
let len = match dev.get_report_descriptor(&mut buf) {
Ok(n) => n,
Err(e) => {
eprintln!(" !! could not read the report descriptor: {e}");
failures += 1;
continue;
}
};
buf.truncate(len);
let emit_as = args.name.as_ref().map(|name| {
if selected.len() > 1 {
format!("{name}_COL{:02}", n + 1)
} else {
name.clone()
}
});
report(&buf, emit_as.as_deref());
if let Some(count) = args.read {
watch(&dev, count);
}
}
if failures > 0 {
eprintln!("\n{failures} collection(s) could not be read.");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
+155
View File
@@ -0,0 +1,155 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "win-input-matrix"
version = "0.26.0"
dependencies = [
"windows",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
+35
View File
@@ -0,0 +1,35 @@
# Ask every Windows input API, in one shot, whether it can see a given gamepad.
#
# The whole Xbox-pad-on-Windows programme is a matrix of five rows — classic XInput, WGI `Gamepad`,
# WGI `RawGameController`, GameInput, and the HID/DirectInput/Steam family — and until this crate
# existed NOTHING in the tree measured any of it. Every reading in
# `design/xbox-pad-windows-handoff.md` came from ad-hoc off-tree tools, which is why several of them
# could not be reproduced or A/B'd later. This makes the matrix a command.
#
# Deliberately NOT a workspace member (see the root `Cargo.toml` `exclude` list): it is a
# bring-your-own-hardware measurement tool, Windows-only, and has no business on a CI leg.
#
# cargo run --release -- --watch 20
#
[workspace]
[package]
name = "win-input-matrix"
description = "Which Windows input APIs can see this gamepad? XInput / WGI Gamepad / WGI RawGameController / XUSB"
version = "0.26.0"
edition = "2024"
rust-version = "1.96.0"
license = "MIT OR Apache-2.0"
publish = false
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_UI_Input_XboxController",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_System_Com",
"Gaming_Input",
"Foundation",
"Win32_System_LibraryLoader",
"Foundation_Collections",
] }
@@ -0,0 +1,102 @@
# `xinputhid` as a BUS FILTER promotes our HID Xbox pad — 2026-08-09, `.173`
Three arms, same box, same session, ~15 minutes apart, all with `win-input-matrix --watch`.
Box: `.173`, Win11 26200. Pad stood up with
`punktfunk-host.exe dualsense-windows-test --xboxhid` (the shipping `SwDeviceCreate` path, so a real
`045E:0B13` identity — **not** a `devgen` node, which has no PID token).
Devnodes involved:
| role | instance |
|---|---|
| parent / transport | `SWD\PUNKTFUNK\PF_XBOX_0``Service=MsHidUmdf`, software key `{745a17a0-…}\0072` |
| HID child | `HID\PUNKTFUNK\1&1F9456C7&3&0000` — software key `…\0073` |
## The result
| | **baseline** (no virtual pad) | **A — control** (pad up, no change) | **B — filter ONLY** | **C — filter + `DevicePropertyFlags=1`** |
|---|---|---|---|---|
| `IG_` token on the child | — | ❌ `HID\PUNKTFUNK\…` | ❌ `HID\PUNKTFUNK\…` | ✅ **`HID\PUNKTFUNK&IG_00\…`** |
| XUSB interface registered | none | ❌ none | ❌ none | ✅ **`\\?\hid#punktfunk&ig_00#…#{ec87f1e3…}`** |
| classic XInput | all 4 slots `1167` | ❌ all `1167` | ❌ all `1167` | ⚠️ **slot 0 `rc=0`** — admitted, data wrong |
| WGI `Gamepad` | 1 (PS5 only) | ❌ absent | ❌ absent | ⚠️ **present**, `ts=0` MUTE |
| WGI `RawGameController` | 2 | ✅ **LIVE** `[045E:0B13]` | ✅ LIVE | 🛑 **MUTE** (regressed) |
| HID class (Steam/SDL/DirectInput) | — | ✅ | ✅ | ✅ |
**Arm C is the first time the HID backend has ever reached classic XInput or WGI `Gamepad` at all.**
## What the A/B proves
Arm B is arm C minus one registry value. Removing **only** `DevicePropertyFlags` reverts *all three*
structural wins at once — the `IG_` token, the XUSB interface and XInput admission. Restoring it
brings them all back.
**`DevicePropertyFlags = 1` (`BusDevice`) is the decisive ingredient, and `UpperFilters` alone does
nothing.** Microsoft's own comment in `xinputhid.inf` says exactly this and we had read past it:
`BusDevice = 0x1`*"we're a focused bus filter driver **for the IG_ problem**"*.
This retro-explains the earlier "🛑 MEASURED REGRESSION — never ship it" result, where the filter was
installed and the device came up `CM_PROB_NONE` while "no XUSB interface [was] registered". That was
arm B. The filter was loading and then sitting inert because nothing had put it in bus-filter mode.
⚠️ Placement matters and is easy to get wrong, because the two values live in **different keys**
exactly as `xinputhid.inf` writes them:
* `UpperFilters` (REG_MULTI_SZ) → the **hardware/instance** key, `…\Enum\SWD\PUNKTFUNK\PF_XBOX_0`
(an INF `[X.HW]` section);
* `DevicePropertyFlags` (REG_DWORD) → the **software/driver** key,
`…\Control\Class\{745a17a0-…}\0072` (an INF `[X]` DDInstall section).
Both go on the **PARENT**, not on the HID child. Confirmed against the real Elite, whose BTLE
transport node carries `InfSection=Btle_Bus`, `DevicePropertyFlags=1`, `ConfigFlags=1` while its HID
child carries plain `input.inf`/`HID_Raw_Inst.NT` and no filter at all.
## What is still broken, and the evidence pointing at why
Everything **enumerates**; nothing **translates**.
* XInput slot 0 reads `packet=34 buttons=0x0000 LT=0 RT=0 LX=1024 LY=0 RX=0 RY=-1`. The devtest
sweeps LX across ±32700 — `LX=1024, RY=-1` is not that sweep, it is a misparse.
* WGI `Gamepad` lists our pad with `ts=0` for every sample.
* Our `RawGameController` entry went from LIVE to MUTE: `xinputhid` claims the HID collection
exclusively, so the reports that used to reach WGI Raw now go into a translator that drops them.
(A real connected Elite behaves the same way — it yields nothing to a user-mode HID reader.)
* In arm C a **second** `[045E:0B13]` entry appears with a different shape, `buttons=14 switches=0`
against our descriptor's `buttons=15 switches=1`. That is `xinputhid`'s synthesized view, and its
shape does not match what we declare.
**The blocker is now the report descriptor, which is WP-A's subject.** `xinputhid` is translating
a HID report into XUSB and expects the real Xbox layout. Ours differs in exactly the ways
`tools/hid-descriptor-dump` measured against a real Elite: we number our input report (the real pad
does not), we carry two Simulation-page trigger axes (the real pad carries one combined `Z`), and we
put 15 buttons *after* the hat (the real pad puts 16 *before* it).
**Next experiment:** rebuild `pf-gamepad` with the captured layout and re-run arm C. That is the
one change that would confirm or kill the descriptor theory, and it is gated on the
descriptor-vs-sealed-channel decision in `design/xbox-pad-windows-handoff.md` §3.3 — the real pad
declares no Feature report, and `0x85` is our channel-proof transport.
## Reproducing
```powershell
# baseline FIRST, with no virtual pad — a real Xbox pad owns XInput slot 0 and will fake a pass
win-input-matrix --watch 8
Start-Process punktfunk-host.exe -ArgumentList 'dualsense-windows-test','--xboxhid','--seconds','90'
# arm C:
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Enum\SWD\PUNKTFUNK\PF_XBOX_0' `
-Name UpperFilters -PropertyType MultiString -Value @('xinputhid') -Force
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{745a17a0-74d3-11d0-b6fe-00a0c90f57da}\0072' `
-Name DevicePropertyFlags -PropertyType DWord -Value 1 -Force
# restart the devtest so PnP rebuilds the stack, then measure again
```
⚠️ The software-key index (`\0072`) is assigned at install and will differ on another box — read it
from the parent's `Driver` value, do not hardcode it.
⚠️ Everything above was applied **by hand to a live devnode**. Shipping it means an `AddReg` in
`pf_gamepad.inx` (`[pfGamepad.NT.hw]` for `UpperFilters`, `[pfGamepad.NT]` for
`DevicePropertyFlags`) — and that INF today contains no `AddReg` of any kind.
All changes on `.173` were reverted: registry values removed, devnodes removed, `oem100.inf`
deleted, both certs delstored, the 6 pre-existing `pf_gamepad` packages and the production service
left untouched.

Some files were not shown because too many files have changed in this diff Show More