Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8508f8f3c3 | ||
|
|
6695300b67 | ||
|
|
73d435b967 | ||
|
|
c7df7b45af | ||
|
|
5d7091bf87 | ||
|
|
e19f11bb0d | ||
|
|
7f1f7ba87c | ||
|
|
d39843a858 | ||
|
|
fb309e0262 | ||
|
|
94c2f62490 | ||
|
|
2b1843ed1c | ||
|
|
4f9071b980 | ||
|
|
1317901122 | ||
|
|
bd5735b803 | ||
|
|
7a9fa4501c | ||
|
|
d87a8df28d | ||
|
|
46390739d8 | ||
|
|
77f0a25d18 | ||
|
|
3500e95660 | ||
|
|
f9fe496dbc | ||
|
|
13438b1287 | ||
|
|
ae35e8b4d7 | ||
|
|
f34acf1d73 | ||
|
|
bc9201d136 | ||
|
|
003ce8bea7 | ||
|
|
235b8e55d4 | ||
|
|
d2a2bcc25d | ||
|
|
0b252403cd | ||
|
|
0ab17ee81d | ||
|
|
4e04c2bbf8 | ||
|
|
31aef4b09f | ||
|
|
97928516a0 | ||
|
|
d498ff4a60 | ||
|
|
d13d253c2f | ||
|
|
99f2130b28 | ||
|
|
f266636392 | ||
|
|
516a295432 | ||
|
|
2c190b27b4 | ||
|
|
3cfa5ca194 | ||
|
|
bf913c5706 | ||
|
|
5bd92dac5d | ||
|
|
e8a4f54c07 | ||
|
|
f80636f901 | ||
|
|
0f79587dd6 | ||
|
|
651a7a82a1 | ||
|
|
08eaf337e8 | ||
|
|
39869031be |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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, <, &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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 P1–P4) + 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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 *t−1*, 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(<.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;
|
||||
|
||||
@@ -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..])
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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++):**
|
||||
|
||||
|
||||
@@ -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 15–18 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 15–18 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.
|
||||
|
||||
@@ -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 P1–P4) + 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-10
@@ -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()
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+2
-2
@@ -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" );
|
||||
|
||||
+7
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+78
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+155
@@ -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",
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user