Compare commits
43
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 |
+44
-16
@@ -356,19 +356,33 @@ jobs:
|
||||
# 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.)
|
||||
apt-get install -y --no-install-recommends libglm-dev cmake libxcursor-dev || true
|
||||
# `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
|
||||
# Best-effort on canary, HARD on a stable tag — same reasoning as rpm.yml. A warning
|
||||
# here is what let v0.26.0 promise an apt-installable gamescope that was never built.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::punktfunk-gamescope failed to build on noble — a stable tag must not ship without it (the release notes and docs-site say it is installable)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 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
|
||||
|
||||
@@ -378,13 +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
|
||||
# Authoritative, and covers the cache path the build step skips — same as rpm.yml.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::no usable punktfunk-gamescope — a stable tag must not ship without its .deb"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 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
|
||||
|
||||
@@ -415,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
|
||||
|
||||
+53
-20
@@ -222,17 +222,10 @@ jobs:
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Best-effort on canary, HARD on a stable tag. A `::warning::` here is what let
|
||||
# v0.26.0 publish release notes promising a Fedora gamescope package that did not
|
||||
# exist: the step "succeeded", the job was green, and the only evidence was a warning
|
||||
# nobody reads. A release must not be able to make a claim its own CI silently
|
||||
# dropped, so on `refs/tags/v*` this is an error.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 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
|
||||
|
||||
@@ -250,18 +243,35 @@ jobs:
|
||||
--binary gs-cache/punktfunk-gamescope \
|
||||
--release "$PF_RELEASE"
|
||||
else
|
||||
# The authoritative "does this package exist" decision, and the one that also covers
|
||||
# the cache path — the build step above is skipped entirely on a cache hit, so a stale
|
||||
# or empty cache would otherwise reach here and skip in silence on a release.
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
echo "::error::no usable punktfunk-gamescope for f${{ matrix.fedver }} — a stable tag must not ship without its RPM"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 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 }}
|
||||
@@ -342,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[*]}"
|
||||
|
||||
@@ -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++):**
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,331 @@
|
||||
//! The GameInput row of the matrix, and the only API that can drive TRIGGER rumble.
|
||||
//!
|
||||
//! WHY THIS IS HAND-WRITTEN COM. GameInput has no binding in the `windows` crate, so the vtables
|
||||
//! below are declared by hand. Every slot index is taken from the SDK header
|
||||
//! `Windows Kits\10\Include\10.0.26100.0\um\GameInput.h`, not from guesswork — a COM vtable is
|
||||
//! positional, so a wrong slot calls a different method with the wrong signature and corrupts the
|
||||
//! stack. Only the slots up to the ones actually called are declared; anything past them is simply
|
||||
//! absent from the struct, which is sound because a vtable is only ever read through the offsets we
|
||||
//! name and we never call beyond the last declared entry.
|
||||
//!
|
||||
//! ⭐ WHY IT MATTERS BEYOND ENUMERATION. `XINPUT_VIBRATION` has exactly two members, so classic
|
||||
//! XInput can never exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has
|
||||
//! four — `lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger` — which makes this the one
|
||||
//! path that can settle the open question in `design/trigger-rumble-plane.md` §2.1: the `enable`-
|
||||
//! mask bit assignment for the two trigger actuators in the pad's HID output report `0x03` is
|
||||
//! CONJECTURE (bits 2/3 = the handles are measured; bits 0/1 = the triggers are inferred from field
|
||||
//! order and nothing else).
|
||||
//!
|
||||
//! The experiment `--gi-rumble` exists for: drive four DISTINCT magnitudes, then read what the pad
|
||||
//! actually decoded. Four distinct values make the mapping self-identifying — a channel that comes
|
||||
//! back zero had its enable bit guessed wrong, and a channel that comes back holding another's
|
||||
//! value is a swap.
|
||||
//!
|
||||
//! The runtime is loaded by name rather than linked, so this builds with no import library and
|
||||
//! degrades to a clean "GameInput not present" on a box without it.
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use windows::Win32::Foundation::{FreeLibrary, HMODULE};
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||
use windows::core::{HRESULT, PCSTR, PCWSTR};
|
||||
|
||||
/// `GameInputKind` values we use (GameInput.h).
|
||||
const GAME_INPUT_KIND_GAMEPAD: u32 = 0x0004_0000;
|
||||
const GAME_INPUT_KIND_CONTROLLER: u32 = 0x0000_000E;
|
||||
|
||||
/// The four rumble channels, 0.0..=1.0 each. Layout verbatim from GameInput.h.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct GameInputRumbleParams {
|
||||
pub lowFrequency: f32,
|
||||
pub highFrequency: f32,
|
||||
pub leftTrigger: f32,
|
||||
pub rightTrigger: f32,
|
||||
}
|
||||
|
||||
/// `IGameInput`, declared only as far as `GetCurrentReading` (slot 4).
|
||||
#[repr(C)]
|
||||
struct IGameInputVtbl {
|
||||
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
|
||||
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
Release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
GetCurrentTimestamp: unsafe extern "system" fn(*mut c_void) -> u64,
|
||||
GetCurrentReading:
|
||||
unsafe extern "system" fn(*mut c_void, u32, *mut c_void, *mut *mut c_void) -> HRESULT,
|
||||
}
|
||||
|
||||
/// `IGameInputReading`, declared only as far as `GetDevice` (slot 6).
|
||||
#[repr(C)]
|
||||
struct IGameInputReadingVtbl {
|
||||
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
|
||||
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
Release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
GetInputKind: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
GetSequenceNumber: unsafe extern "system" fn(*mut c_void, u32) -> u64,
|
||||
GetTimestamp: unsafe extern "system" fn(*mut c_void) -> u64,
|
||||
GetDevice: unsafe extern "system" fn(*mut c_void, *mut *mut c_void),
|
||||
}
|
||||
|
||||
/// `IGameInputDevice`, declared only as far as `SetRumbleState` (slot 10).
|
||||
#[repr(C)]
|
||||
struct IGameInputDeviceVtbl {
|
||||
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
|
||||
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
Release: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
GetDeviceInfo: unsafe extern "system" fn(*mut c_void) -> *const c_void,
|
||||
GetDeviceStatus: unsafe extern "system" fn(*mut c_void) -> u32,
|
||||
GetBatteryState: unsafe extern "system" fn(*mut c_void, *mut c_void),
|
||||
CreateForceFeedbackEffect:
|
||||
unsafe extern "system" fn(*mut c_void, u32, *const c_void, *mut *mut c_void) -> HRESULT,
|
||||
IsForceFeedbackMotorPoweredOn: unsafe extern "system" fn(*mut c_void, u32) -> i32,
|
||||
SetForceFeedbackMotorGain: unsafe extern "system" fn(*mut c_void, u32, f32),
|
||||
SetHapticMotorState: unsafe extern "system" fn(*mut c_void, u32, *const c_void),
|
||||
SetRumbleState: unsafe extern "system" fn(*mut c_void, *const GameInputRumbleParams),
|
||||
}
|
||||
|
||||
/// A loaded GameInput runtime plus the root object. Dropping it releases both.
|
||||
pub struct GameInput {
|
||||
module: HMODULE,
|
||||
root: *mut c_void,
|
||||
}
|
||||
|
||||
impl Drop for GameInput {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `root` came from GameInputCreate and is released exactly once; `module` came from
|
||||
// LoadLibraryW. Freeing the module after the object is the required order.
|
||||
unsafe {
|
||||
if !self.root.is_null() {
|
||||
let vtbl = *(self.root as *mut *const IGameInputVtbl);
|
||||
((*vtbl).Release)(self.root);
|
||||
}
|
||||
let _ = FreeLibrary(self.module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GameInput {
|
||||
/// Load `gameinput.dll` and create the root object. `Err` carries a human reason — a box
|
||||
/// without the runtime is a legitimate outcome, not a crash.
|
||||
pub fn create() -> Result<Self, String> {
|
||||
let name: Vec<u16> = "gameinput.dll\0".encode_utf16().collect();
|
||||
// SAFETY: `name` is a NUL-terminated wide string that outlives the call.
|
||||
let module = unsafe { LoadLibraryW(PCWSTR(name.as_ptr())) }
|
||||
.map_err(|e| format!("gameinput.dll not loadable: {e}"))?;
|
||||
// SAFETY: `module` is live; the name is a NUL-terminated byte string.
|
||||
let proc =
|
||||
unsafe { GetProcAddress(module, PCSTR(c"GameInputCreate".as_ptr() as *const u8)) }
|
||||
.ok_or_else(|| "gameinput.dll has no GameInputCreate export".to_string())?;
|
||||
// SAFETY: the export's documented signature is
|
||||
// `HRESULT GameInputCreate(IGameInput**)` — GameInput.h, `STDAPI GameInputCreate`.
|
||||
let create: unsafe extern "system" fn(*mut *mut c_void) -> HRESULT =
|
||||
unsafe { std::mem::transmute(proc) };
|
||||
let mut root: *mut c_void = std::ptr::null_mut();
|
||||
// SAFETY: `root` is a valid out-param slot.
|
||||
let hr = unsafe { create(&mut root) };
|
||||
if hr.is_err() || root.is_null() {
|
||||
// SAFETY: nothing was created; drop the module by hand since we have no object yet.
|
||||
unsafe {
|
||||
let _ = FreeLibrary(module);
|
||||
}
|
||||
return Err(format!("GameInputCreate failed: {hr:?}"));
|
||||
}
|
||||
Ok(Self { module, root })
|
||||
}
|
||||
|
||||
/// The current reading for `kind`, if any device is producing one.
|
||||
fn reading(&self, kind: u32) -> Option<*mut c_void> {
|
||||
let mut reading: *mut c_void = std::ptr::null_mut();
|
||||
// SAFETY: `self.root` is a live IGameInput; slot 4 is GetCurrentReading with this exact
|
||||
// signature (GameInput.h). A null `device` means "any device", which is what we want.
|
||||
let hr = unsafe {
|
||||
let vtbl = *(self.root as *mut *const IGameInputVtbl);
|
||||
((*vtbl).GetCurrentReading)(self.root, kind, std::ptr::null_mut(), &mut reading)
|
||||
};
|
||||
(hr.is_ok() && !reading.is_null()).then_some(reading)
|
||||
}
|
||||
|
||||
/// Poll for a reading, because GameInput's device enumeration is ASYNCHRONOUS.
|
||||
///
|
||||
/// A freshly created `IGameInput` has not finished enumerating yet, so the first
|
||||
/// `GetCurrentReading` reliably returns nothing even with a pad actively reporting — measured
|
||||
/// on `.173` 2026-08-09, where a sweeping devtest pad and a live DualSense both read "no
|
||||
/// reading" on the first call. This is the GameInput analogue of `wake_wgi`: the API looks like
|
||||
/// a query and is really a cache someone else fills.
|
||||
///
|
||||
/// ⚠️ Focus is NOT the cause and was ruled out from the header: `GameInputDefaultFocusPolicy`
|
||||
/// is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION
|
||||
/// (`GameInputDisableBackgroundInput`, `GameInputExclusiveForegroundInput`, …), so the default
|
||||
/// already admits background input. Do not "fix" this by calling `SetFocusPolicy`.
|
||||
fn reading_wait(&self, kind: u32, timeout: std::time::Duration) -> Option<*mut c_void> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(r) = self.reading(kind) {
|
||||
return Some(r);
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// `(vendorId, productId)` for a device, read straight off `GameInputDeviceInfo`.
|
||||
///
|
||||
/// The struct begins `uint32_t infoSize; uint16_t vendorId; uint16_t productId; …`
|
||||
/// (GameInput.h), so the two ids sit at byte offsets 4 and 6. Only those two are read — the
|
||||
/// rest of the struct carries variable-size members whose layout we would have to mirror
|
||||
/// exactly, and nothing here needs them.
|
||||
fn device_ids(dev: *mut c_void) -> (u16, u16) {
|
||||
// SAFETY: `dev` is a live IGameInputDevice; slot 3 is GetDeviceInfo, which returns a
|
||||
// pointer to a struct owned by the runtime and valid for the device's lifetime.
|
||||
unsafe {
|
||||
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
|
||||
let info = ((*vtbl).GetDeviceInfo)(dev) as *const u8;
|
||||
if info.is_null() {
|
||||
return (0, 0);
|
||||
}
|
||||
(
|
||||
u16::from_le_bytes([*info.add(4), *info.add(5)]),
|
||||
u16::from_le_bytes([*info.add(6), *info.add(7)]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the device off a reading (AddRef'd), releasing the reading.
|
||||
fn device_of(r: *mut c_void) -> *mut c_void {
|
||||
let mut dev: *mut c_void = std::ptr::null_mut();
|
||||
// SAFETY: `r` is a live IGameInputReading; slot 6 is GetDevice (returns void, hands back
|
||||
// an AddRef'd device), slot 2 is Release.
|
||||
unsafe {
|
||||
let vtbl = *(r as *mut *const IGameInputReadingVtbl);
|
||||
((*vtbl).GetDevice)(r, &mut dev);
|
||||
((*vtbl).Release)(r);
|
||||
}
|
||||
dev
|
||||
}
|
||||
|
||||
/// Hunt for a device with `pid`, polling because several devices take turns reporting and
|
||||
/// `GetCurrentReading(kind, null, …)` hands back whichever one spoke most recently. A box with
|
||||
/// a chatty pad on it (a DualSense streams continuously) will otherwise never yield ours.
|
||||
fn find_device(&self, pid: u16, timeout: std::time::Duration) -> Option<*mut c_void> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let mut seen: Vec<(u16, u16)> = Vec::new();
|
||||
loop {
|
||||
for kind in [GAME_INPUT_KIND_GAMEPAD, GAME_INPUT_KIND_CONTROLLER] {
|
||||
if let Some(r) = self.reading(kind) {
|
||||
let dev = Self::device_of(r);
|
||||
if !dev.is_null() {
|
||||
let ids = Self::device_ids(dev);
|
||||
if !seen.contains(&ids) {
|
||||
seen.push(ids);
|
||||
println!(" saw device {:04X}:{:04X}", ids.0, ids.1);
|
||||
}
|
||||
if ids.1 == pid {
|
||||
return Some(dev);
|
||||
}
|
||||
// SAFETY: not our target; drop our reference.
|
||||
unsafe {
|
||||
let v = *(dev as *mut *const IGameInputDeviceVtbl);
|
||||
((*v).Release)(dev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// Does GameInput see a gamepad at all? This is the matrix row.
|
||||
pub fn report(&self) {
|
||||
for (kind, label) in [
|
||||
(GAME_INPUT_KIND_GAMEPAD, "Gamepad"),
|
||||
(GAME_INPUT_KIND_CONTROLLER, "Controller"),
|
||||
] {
|
||||
match self.reading_wait(kind, std::time::Duration::from_secs(3)) {
|
||||
Some(r) => {
|
||||
println!(" {label:<11}: reading available (a device is producing input)");
|
||||
// SAFETY: `r` is a live IGameInputReading we own a reference to.
|
||||
unsafe {
|
||||
let vtbl = *(r as *mut *const IGameInputReadingVtbl);
|
||||
((*vtbl).Release)(r);
|
||||
}
|
||||
}
|
||||
None => println!(" {label:<11}: no reading"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `SetRumbleState` on whatever device is currently reporting.
|
||||
///
|
||||
/// Returns whether a device was found and driven. The values are deliberately the caller's to
|
||||
/// choose: the whole point of the probe is sending four DISTINCT magnitudes so the pad's decoded
|
||||
/// output identifies the channel mapping by itself.
|
||||
pub fn rumble(
|
||||
&self,
|
||||
params: GameInputRumbleParams,
|
||||
hold: std::time::Duration,
|
||||
target_pid: Option<u16>,
|
||||
) -> bool {
|
||||
// A gamepad reading is the right one to hang this off: it is the kind an Xbox pad produces,
|
||||
// and the device it names is the one a game would rumble.
|
||||
let wait = std::time::Duration::from_secs(6);
|
||||
let dev = match target_pid {
|
||||
Some(pid) => {
|
||||
println!(" hunting for PID {pid:04X} …");
|
||||
match self.find_device(pid, wait) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
println!(" never saw PID {pid:04X} — nothing to rumble");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let Some(r) = self
|
||||
.reading_wait(GAME_INPUT_KIND_GAMEPAD, wait)
|
||||
.or_else(|| self.reading_wait(GAME_INPUT_KIND_CONTROLLER, wait))
|
||||
else {
|
||||
println!(" no GameInput reading — nothing to rumble");
|
||||
return false;
|
||||
};
|
||||
Self::device_of(r)
|
||||
}
|
||||
};
|
||||
if dev.is_null() {
|
||||
println!(" reading had no device");
|
||||
return false;
|
||||
}
|
||||
let ids = Self::device_ids(dev);
|
||||
println!(" driving {:04X}:{:04X}", ids.0, ids.1);
|
||||
println!(
|
||||
" SetRumbleState(low={:.2} high={:.2} lt={:.2} rt={:.2}) for {:?}",
|
||||
params.lowFrequency,
|
||||
params.highFrequency,
|
||||
params.leftTrigger,
|
||||
params.rightTrigger,
|
||||
hold
|
||||
);
|
||||
// SAFETY: `dev` is a live IGameInputDevice; slot 10 is SetRumbleState, which returns void
|
||||
// and takes a const pointer to the four-float struct above.
|
||||
unsafe {
|
||||
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
|
||||
((*vtbl).SetRumbleState)(dev, ¶ms);
|
||||
}
|
||||
std::thread::sleep(hold);
|
||||
let off = GameInputRumbleParams::default();
|
||||
// SAFETY: as above; stopping is the same call with zeroes.
|
||||
unsafe {
|
||||
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
|
||||
((*vtbl).SetRumbleState)(dev, &off);
|
||||
((*vtbl).Release)(dev);
|
||||
}
|
||||
println!(" cleared.");
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
//! Which Windows input APIs can see this gamepad?
|
||||
//!
|
||||
//! WHY THIS EXISTS. The Xbox-pad-on-Windows programme
|
||||
//! (`punktfunk-planning/design/xbox-pad-windows-handoff.md`) is a five-row matrix — classic
|
||||
//! XInput, WGI `Gamepad`, WGI `RawGameController`, GameInput, and the HID/DirectInput/Steam
|
||||
//! family — and **nothing in this tree measured any of it**. Every reading in that document came
|
||||
//! from ad-hoc off-tree tools, which is why several of them could not be reproduced or A/B'd
|
||||
//! afterwards, and why one of them turned out to be a false positive. This makes the matrix a
|
||||
//! command you can run twice and diff.
|
||||
//!
|
||||
//! ⚠️⚠️ **THE FALSE-POSITIVE TRAP, and why `--watch` exists.** A test box usually has REAL pads on
|
||||
//! it. A real Xbox pad owns XInput slot 0 and appears in WGI, so "I can see a pad" proves nothing.
|
||||
//! This already burned one session: `XInputGetState(0)` read `rc=0 LX=-885` with the virtual pad
|
||||
//! live *and* with it killed — slot 0 was always the real Elite.
|
||||
//! ⇒ **ALWAYS take a baseline with your pad STOPPED and diff it**, and identify entries by name and
|
||||
//! vendor/product id, never by slot index alone.
|
||||
//! `--watch N` is the second half of that discipline: it samples repeatedly and reports whether a
|
||||
//! device's timestamps ADVANCE. An entry that enumerates but never moves is the exact failure mode
|
||||
//! this programme is chasing — WGI listing a gamepad that reports nothing is arguably worse than
|
||||
//! not listing it, because a title that binds the first gamepad latches a dead one.
|
||||
//!
|
||||
//! GAP: **GameInput is not covered here.** It has no binding in the `windows` crate and needs
|
||||
//! hand-written COM vtables; it is measured separately for now. Everything else is.
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() {
|
||||
eprintln!("win-input-matrix is Windows-only.");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod gameinput;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::Foundation::EventHandler;
|
||||
use windows::Gaming::Input::{Gamepad, IGameController, RawGameController};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, HDEVINFO, SP_DEVICE_INTERFACE_DATA,
|
||||
SP_DEVICE_INTERFACE_DETAIL_DATA_W, SetupDiDestroyDeviceInfoList,
|
||||
SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, SetupDiGetDeviceInterfaceDetailW,
|
||||
};
|
||||
use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS;
|
||||
use windows::Win32::System::Com::CoIncrementMTAUsage;
|
||||
use windows::Win32::UI::Input::XboxController::{
|
||||
XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState,
|
||||
};
|
||||
// `Interface` brings `cast()` into scope, which is how a WinRT `Gamepad` is correlated to the
|
||||
// `RawGameController` that knows its name.
|
||||
use windows::core::{GUID, Interface};
|
||||
|
||||
/// `GUID_DEVINTERFACE_XUSB` — the interface class `xinput1_4` enumerates. This is the one that
|
||||
/// matters: XInput does not read HID at all, it walks this class.
|
||||
const GUID_DEVINTERFACE_XUSB: GUID = GUID::from_u128(0xec87f1e3_c13b_4100_b5f7_8b84d54260cb);
|
||||
/// `GUID_DEVINTERFACE_HID` — what Steam, SDL/hidapi, RawInput, DirectInput and joy.cpl walk.
|
||||
const GUID_DEVINTERFACE_HID: GUID = GUID::from_u128(0x4d1e55b2_f16f_11cf_88cb_001111000030);
|
||||
|
||||
/// Every PRESENT device interface in `class`. Present-only on purpose: the registry lists
|
||||
/// long-dead devnodes too, and "is it there right now" is the whole question.
|
||||
fn interfaces(class: GUID) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: `class` is a valid GUID; we pass no enumerator and no owner window. The returned
|
||||
// handle is destroyed unconditionally below.
|
||||
let set: HDEVINFO = match unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&class),
|
||||
None,
|
||||
None,
|
||||
DIGCF_PRESENT | DIGCF_DEVICEINTERFACE,
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(_) => return out,
|
||||
};
|
||||
|
||||
let mut index = 0u32;
|
||||
loop {
|
||||
let mut ifdata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `set` is a live device-info set; `ifdata.cbSize` is initialised as the API
|
||||
// requires. A failure here means "no more items", which ends the loop.
|
||||
let ok = unsafe { SetupDiEnumDeviceInterfaces(set, None, &class, index, &mut ifdata) }
|
||||
.is_ok();
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
|
||||
// Two-call dance: ask for the required byte count, then fetch into a buffer of that
|
||||
// size. The detail struct is variable-length (a trailing WCHAR path), so it cannot be
|
||||
// stack-allocated by type alone.
|
||||
let mut needed = 0u32;
|
||||
// SAFETY: passing a null detail pointer with a null size is the documented way to
|
||||
// query the required length; it always "fails" with ERROR_INSUFFICIENT_BUFFER.
|
||||
let _ = unsafe {
|
||||
SetupDiGetDeviceInterfaceDetailW(set, &ifdata, None, 0, Some(&mut needed), None)
|
||||
};
|
||||
if needed == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut buf = vec![0u8; needed as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
// SAFETY: `buf` is `needed` bytes, the size the API just asked for. `cbSize` must be
|
||||
// the size of the FIXED part of the struct (not the buffer) — 8 on x64.
|
||||
unsafe {
|
||||
(*detail).cbSize = 8;
|
||||
}
|
||||
// SAFETY: `detail` points into `buf`, which lives until the end of this iteration and
|
||||
// is exactly the length the API requested.
|
||||
if unsafe {
|
||||
SetupDiGetDeviceInterfaceDetailW(set, &ifdata, Some(detail), needed, None, None)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// SAFETY: on success the API wrote a NUL-terminated wide string into `DevicePath`.
|
||||
let path = unsafe {
|
||||
let p = (*detail).DevicePath.as_ptr();
|
||||
let mut len = 0usize;
|
||||
while *p.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
String::from_utf16_lossy(std::slice::from_raw_parts(p, len))
|
||||
};
|
||||
out.push(path);
|
||||
}
|
||||
|
||||
// SAFETY: `set` came from SetupDiGetClassDevsW and is not used again.
|
||||
let _ = unsafe { SetupDiDestroyDeviceInfoList(set) };
|
||||
let _ = ERROR_NO_MORE_ITEMS;
|
||||
out
|
||||
}
|
||||
|
||||
/// 🛑 **DO NOT DELETE THIS — without it the whole WGI half of the matrix reads zero.**
|
||||
///
|
||||
/// `Gamepad::Gamepads()` and `RawGameController::RawGameControllers()` are not queries; they
|
||||
/// return a cache that WGI's device-watcher fills in. In a GUI app something else has already
|
||||
/// started that watcher, so the cache looks like a query and everyone writes code as if it
|
||||
/// were one. In a bare console process nothing has, and both collections come back **EMPTY
|
||||
/// even with real controllers attached** — measured here on 2026-08-09: a DualSense sitting in
|
||||
/// the HID interface class, `RawGameControllers` count=0.
|
||||
///
|
||||
/// Subscribing to the Added events is what starts the watcher. The handlers deliberately do
|
||||
/// nothing; registering them is the entire point. The sleep gives the watcher a beat to
|
||||
/// enumerate before the first read.
|
||||
///
|
||||
/// ⚠️ This is a live trap for the readings in `design/xbox-pad-windows-handoff.md`: an
|
||||
/// off-tree probe without this would report "WGI cannot see the pad" when WGI could not see
|
||||
/// ANYTHING, which is a very different conclusion.
|
||||
fn wake_wgi() {
|
||||
let gp_tok = Gamepad::GamepadAdded(&EventHandler::<Gamepad>::new(|_, _| Ok(())));
|
||||
let raw_tok = RawGameController::RawGameControllerAdded(
|
||||
&EventHandler::<RawGameController>::new(|_, _| Ok(())),
|
||||
);
|
||||
if gp_tok.is_err() || raw_tok.is_err() {
|
||||
eprintln!("warning: could not subscribe to WGI Added events; counts may read zero");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
}
|
||||
|
||||
/// Drive rumble into an XInput slot and hold it, so the other end of the pipe can be watched.
|
||||
///
|
||||
/// This is the WP0 probe from `design/trigger-rumble-plane.md`: does anything Windows-side ever
|
||||
/// write an output report back to a synthesized `045E:0B13`? For the HID backend the chain
|
||||
/// under test is `XInputSetState` → `xinputhid` → a HID output report on our collection →
|
||||
/// `on_output_report` → the shm out-ring → `parse_xbox_output`, and the observable is the
|
||||
/// devtest printing `rumble from game`. Run this with the devtest live and watch its stdout.
|
||||
///
|
||||
/// ⚠️ `XINPUT_VIBRATION` has exactly TWO members, so this can only ever drive the two handle
|
||||
/// motors — it can never source TRIGGER rumble. That is a property of the API, not of our
|
||||
/// plumbing, and it is why the trigger plane needs its own transport.
|
||||
fn rumble(slot: u32, seconds: u64) {
|
||||
println!("== RUMBLE PROBE: XInputSetState(slot {slot}) for {seconds}s ==");
|
||||
let v = XINPUT_VIBRATION {
|
||||
wLeftMotorSpeed: 0xFFFF,
|
||||
wRightMotorSpeed: 0x8000,
|
||||
};
|
||||
// SAFETY: `v` is a valid, fully-initialised XINPUT_VIBRATION.
|
||||
let rc = unsafe { XInputSetState(slot, &v) };
|
||||
println!(
|
||||
" set low=0xFFFF high=0x8000 -> rc={rc}{}",
|
||||
if rc == 0 {
|
||||
" (accepted)"
|
||||
} else {
|
||||
" (REJECTED)"
|
||||
}
|
||||
);
|
||||
if rc != 0 {
|
||||
println!(" (slot not connected — nothing downstream can be concluded)");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(seconds));
|
||||
let off = XINPUT_VIBRATION::default();
|
||||
// SAFETY: as above.
|
||||
let rc2 = unsafe { XInputSetState(slot, &off) };
|
||||
println!(" clear low=0 high=0 -> rc={rc2}");
|
||||
println!(" ⇒ now check the devtest stdout for `rumble from game`.");
|
||||
}
|
||||
|
||||
fn xinput() {
|
||||
println!("== classic XInput (xinput1_4 walks GUID_DEVINTERFACE_XUSB) ==");
|
||||
for slot in 0..4u32 {
|
||||
let mut st = XINPUT_STATE::default();
|
||||
// SAFETY: `st` is a valid, fully-initialised XINPUT_STATE for the call to fill in.
|
||||
let rc = unsafe { XInputGetState(slot, &mut st) };
|
||||
if rc == 0 {
|
||||
let g = st.Gamepad;
|
||||
println!(
|
||||
" slot {slot}: rc=0 packet={} buttons=0x{:04X} LT={} RT={} LX={} LY={} RX={} RY={}",
|
||||
st.dwPacketNumber,
|
||||
g.wButtons.0,
|
||||
g.bLeftTrigger,
|
||||
g.bRightTrigger,
|
||||
g.sThumbLX,
|
||||
g.sThumbLY,
|
||||
g.sThumbRX,
|
||||
g.sThumbRY
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" slot {slot}: rc={rc}{}",
|
||||
if rc == 1167 {
|
||||
" (ERROR_DEVICE_NOT_CONNECTED)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One WGI sample, for the mute detector.
|
||||
struct Sample {
|
||||
label: String,
|
||||
ts: u64,
|
||||
axes: Vec<f64>,
|
||||
}
|
||||
|
||||
fn wgi_gamepads() -> Vec<Sample> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(list) = Gamepad::Gamepads() else {
|
||||
return out;
|
||||
};
|
||||
let n = list.Size().unwrap_or(0);
|
||||
for i in 0..n {
|
||||
let Ok(gp) = list.GetAt(i) else { continue };
|
||||
// Correlate to a RawGameController purely to get a human-readable name — a bare
|
||||
// `Gamepad` has none, and identifying entries by index is how false positives happen.
|
||||
let label = gp
|
||||
.cast::<IGameController>()
|
||||
.ok()
|
||||
.and_then(|c| RawGameController::FromGameController(&c).ok())
|
||||
.and_then(|r| r.DisplayName().ok())
|
||||
.map(|h| h.to_string())
|
||||
.unwrap_or_else(|| format!("<gamepad {i}>"));
|
||||
let (ts, axes) = match gp.GetCurrentReading() {
|
||||
Ok(r) => (
|
||||
r.Timestamp,
|
||||
vec![
|
||||
r.LeftThumbstickX,
|
||||
r.LeftThumbstickY,
|
||||
r.RightThumbstickX,
|
||||
r.RightThumbstickY,
|
||||
r.LeftTrigger,
|
||||
r.RightTrigger,
|
||||
],
|
||||
),
|
||||
Err(_) => (0, Vec::new()),
|
||||
};
|
||||
out.push(Sample { label, ts, axes });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn wgi_raw() -> Vec<Sample> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(list) = RawGameController::RawGameControllers() else {
|
||||
return out;
|
||||
};
|
||||
let n = list.Size().unwrap_or(0);
|
||||
for i in 0..n {
|
||||
let Ok(rc) = list.GetAt(i) else { continue };
|
||||
let name = rc
|
||||
.DisplayName()
|
||||
.map(|h| h.to_string())
|
||||
.unwrap_or_else(|_| "<unnamed>".into());
|
||||
let vid = rc.HardwareVendorId().unwrap_or(0);
|
||||
let pid = rc.HardwareProductId().unwrap_or(0);
|
||||
let nb = rc.ButtonCount().unwrap_or(0).max(0) as usize;
|
||||
let ns = rc.SwitchCount().unwrap_or(0).max(0) as usize;
|
||||
let na = rc.AxisCount().unwrap_or(0).max(0) as usize;
|
||||
let mut buttons = vec![false; nb];
|
||||
let mut switches = vec![Default::default(); ns];
|
||||
let mut axes = vec![0f64; na];
|
||||
let ts = rc
|
||||
.GetCurrentReading(&mut buttons, &mut switches, &mut axes)
|
||||
.unwrap_or(0);
|
||||
out.push(Sample {
|
||||
label: format!("{name} [{vid:04X}:{pid:04X}] buttons={nb} switches={ns} axes={na}"),
|
||||
ts,
|
||||
axes,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn print_samples(title: &str, s: &[Sample]) {
|
||||
println!("== {title} == count={}", s.len());
|
||||
for (i, e) in s.iter().enumerate() {
|
||||
let axes = e
|
||||
.axes
|
||||
.iter()
|
||||
.map(|v| format!("{v:.4}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
println!(" [{i}] ts={} {} axes=[{axes}]", e.ts, e.label);
|
||||
}
|
||||
if s.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample XInput over the whole watch window and report the RANGE each axis covered.
|
||||
///
|
||||
/// A single `XInputGetState` call cannot tell "translated correctly" from "stuck at zero" —
|
||||
/// a sweeping stick reads 0 every time it crosses centre. `dwPacketNumber` advancing proves
|
||||
/// the state is changing at all; the min/max spread proves the AXES specifically are, which is
|
||||
/// the half that can fail on its own while buttons work.
|
||||
struct XiTrack {
|
||||
first_packet: u32,
|
||||
last_packet: u32,
|
||||
lx: (i16, i16),
|
||||
ly: (i16, i16),
|
||||
rx: (i16, i16),
|
||||
ry: (i16, i16),
|
||||
buttons: u16,
|
||||
lt: (u8, u8),
|
||||
rt: (u8, u8),
|
||||
}
|
||||
|
||||
fn xinput_watch(rounds: usize) {
|
||||
println!("\n== XINPUT WATCH ({rounds} samples) — do PACKETS advance and AXES move? ==");
|
||||
for slot in 0..4u32 {
|
||||
let mut t: Option<XiTrack> = None;
|
||||
for _ in 0..rounds {
|
||||
let mut st = XINPUT_STATE::default();
|
||||
// SAFETY: `st` is a valid, fully-initialised XINPUT_STATE.
|
||||
if unsafe { XInputGetState(slot, &mut st) } != 0 {
|
||||
break;
|
||||
}
|
||||
let g = st.Gamepad;
|
||||
match &mut t {
|
||||
None => {
|
||||
t = Some(XiTrack {
|
||||
first_packet: st.dwPacketNumber,
|
||||
last_packet: st.dwPacketNumber,
|
||||
lx: (g.sThumbLX, g.sThumbLX),
|
||||
ly: (g.sThumbLY, g.sThumbLY),
|
||||
rx: (g.sThumbRX, g.sThumbRX),
|
||||
ry: (g.sThumbRY, g.sThumbRY),
|
||||
buttons: g.wButtons.0,
|
||||
lt: (g.bLeftTrigger, g.bLeftTrigger),
|
||||
rt: (g.bRightTrigger, g.bRightTrigger),
|
||||
});
|
||||
}
|
||||
Some(t) => {
|
||||
t.last_packet = st.dwPacketNumber;
|
||||
t.lx = (t.lx.0.min(g.sThumbLX), t.lx.1.max(g.sThumbLX));
|
||||
t.ly = (t.ly.0.min(g.sThumbLY), t.ly.1.max(g.sThumbLY));
|
||||
t.rx = (t.rx.0.min(g.sThumbRX), t.rx.1.max(g.sThumbRX));
|
||||
t.ry = (t.ry.0.min(g.sThumbRY), t.ry.1.max(g.sThumbRY));
|
||||
t.buttons |= g.wButtons.0;
|
||||
t.lt = (t.lt.0.min(g.bLeftTrigger), t.lt.1.max(g.bLeftTrigger));
|
||||
t.rt = (t.rt.0.min(g.bRightTrigger), t.rt.1.max(g.bRightTrigger));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
match t {
|
||||
None => println!(" slot {slot}: not connected"),
|
||||
Some(t) => {
|
||||
let moved = t.last_packet != t.first_packet;
|
||||
let axes_moved = t.lx.0 != t.lx.1
|
||||
|| t.ly.0 != t.ly.1
|
||||
|| t.rx.0 != t.rx.1
|
||||
|| t.ry.0 != t.ry.1
|
||||
|| t.lt.0 != t.lt.1
|
||||
|| t.rt.0 != t.rt.1;
|
||||
println!(
|
||||
" slot {slot}: packets {}..{} ({}), buttons seen 0x{:04X}",
|
||||
t.first_packet,
|
||||
t.last_packet,
|
||||
if moved { "ADVANCING" } else { "FROZEN" },
|
||||
t.buttons
|
||||
);
|
||||
println!(
|
||||
" LX [{}..{}] LY [{}..{}] RX [{}..{}] RY [{}..{}] LT [{}..{}] RT [{}..{}] -> axes {}",
|
||||
t.lx.0,
|
||||
t.lx.1,
|
||||
t.ly.0,
|
||||
t.ly.1,
|
||||
t.rx.0,
|
||||
t.rx.1,
|
||||
t.ry.0,
|
||||
t.ry.1,
|
||||
t.lt.0,
|
||||
t.lt.1,
|
||||
t.rt.0,
|
||||
t.rt.1,
|
||||
if axes_moved { "MOVING" } else { "STUCK" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag every device whose current reading differs from the baseline one. Once a device has
|
||||
/// moved it stays flagged — a pad that twitches once in twenty samples is still LIVE.
|
||||
fn mark_moved(base: &[Sample], now: &[Sample], moved: &mut [bool]) {
|
||||
for (i, e) in now.iter().enumerate() {
|
||||
if let Some(b) = base.get(i)
|
||||
&& (b.ts != e.ts || b.axes != e.axes)
|
||||
&& let Some(m) = moved.get_mut(i)
|
||||
{
|
||||
*m = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample repeatedly and report, per device, whether anything ever MOVED. This is the
|
||||
/// enumerated-but-mute detector: `ts` frozen across every sample means the API lists a pad
|
||||
/// that is not reporting.
|
||||
fn watch(rounds: usize) {
|
||||
println!("\n== WATCH ({rounds} rounds, 200 ms apart) — does anything actually MOVE? ==");
|
||||
let mut first_gp: Option<Vec<Sample>> = None;
|
||||
let mut first_raw: Option<Vec<Sample>> = None;
|
||||
let mut moved_gp: Vec<bool> = Vec::new();
|
||||
let mut moved_raw: Vec<bool> = Vec::new();
|
||||
|
||||
for _ in 0..rounds {
|
||||
let gp = wgi_gamepads();
|
||||
let raw = wgi_raw();
|
||||
match &first_gp {
|
||||
None => {
|
||||
moved_gp = vec![false; gp.len()];
|
||||
first_gp = Some(gp);
|
||||
}
|
||||
Some(base) => mark_moved(base, &gp, &mut moved_gp),
|
||||
}
|
||||
match &first_raw {
|
||||
None => {
|
||||
moved_raw = vec![false; raw.len()];
|
||||
first_raw = Some(raw);
|
||||
}
|
||||
Some(base) => mark_moved(base, &raw, &mut moved_raw),
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
for (label, base, moved) in [
|
||||
("WGI Gamepad", first_gp, moved_gp),
|
||||
("WGI RawGameController", first_raw, moved_raw),
|
||||
] {
|
||||
println!(" {label}:");
|
||||
let Some(base) = base else { continue };
|
||||
if base.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for (i, e) in base.iter().enumerate() {
|
||||
println!(
|
||||
" [{i}] {} — {}",
|
||||
e.label,
|
||||
if *moved.get(i).unwrap_or(&false) {
|
||||
"LIVE (readings changed)"
|
||||
} else {
|
||||
"MUTE (ts and axes frozen for every sample)"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut rounds = 0usize;
|
||||
let mut rumble_slot: Option<u32> = None;
|
||||
let mut gameinput_report = false;
|
||||
let mut gi_rumble: Option<String> = None;
|
||||
let mut gi_pid: Option<u16> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--watch" => {
|
||||
rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20);
|
||||
i += 1;
|
||||
}
|
||||
"--gameinput" => gameinput_report = true,
|
||||
"--gi-pid" => {
|
||||
gi_pid = args
|
||||
.get(i + 1)
|
||||
.and_then(|v| u16::from_str_radix(v.trim_start_matches("0x"), 16).ok());
|
||||
i += 1;
|
||||
}
|
||||
"--gi-rumble" => {
|
||||
gi_rumble = args.get(i + 1).cloned();
|
||||
i += 1;
|
||||
}
|
||||
"--rumble" => {
|
||||
rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0));
|
||||
i += 1;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"win-input-matrix [--watch N] [--rumble SLOT]\n\n \
|
||||
--watch N sample WGI N times and report LIVE vs MUTE per device\n \
|
||||
--rumble SLOT drive XInputSetState into that slot for 3 s (WP0 probe:\n \
|
||||
does anything write an output report back to our pad?)\n\n\
|
||||
ALWAYS take a baseline with your virtual pad STOPPED and diff it: a real\n\
|
||||
pad on the box owns XInput slot 0 and shows up in WGI."
|
||||
);
|
||||
return;
|
||||
}
|
||||
other => eprintln!("(ignoring unknown argument {other})"),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// WinRT needs an initialised apartment. CoIncrementMTAUsage keeps an MTA alive for the
|
||||
// life of the process without committing this thread to a specific apartment.
|
||||
// SAFETY: no arguments to get wrong. The cookie is a plain handle value and is dropped on
|
||||
// purpose — decrementing would tear the MTA down again, and we want it up for the whole
|
||||
// process.
|
||||
match unsafe { CoIncrementMTAUsage() } {
|
||||
Ok(_cookie) => {}
|
||||
Err(e) => eprintln!("warning: could not start an MTA, WGI calls may fail: {e}"),
|
||||
}
|
||||
wake_wgi();
|
||||
|
||||
xinput();
|
||||
println!();
|
||||
print_samples("WGI Gamepad", &wgi_gamepads());
|
||||
println!();
|
||||
print_samples("WGI RawGameController", &wgi_raw());
|
||||
|
||||
println!("\n== XUSB device interfaces (GUID_DEVINTERFACE_XUSB, present only) ==");
|
||||
let xusb = interfaces(GUID_DEVINTERFACE_XUSB);
|
||||
if xusb.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for p in &xusb {
|
||||
println!(" {p}");
|
||||
}
|
||||
|
||||
println!("\n== HID device interfaces (what Steam/SDL/DirectInput/joy.cpl walk) ==");
|
||||
let hid = interfaces(GUID_DEVINTERFACE_HID);
|
||||
println!(" {} present; those matching a gamepad vendor:", hid.len());
|
||||
for p in &hid {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
if lower.contains("vid_045e")
|
||||
|| lower.contains("vid_054c")
|
||||
|| lower.contains("punktfunk")
|
||||
{
|
||||
println!(" {p}");
|
||||
}
|
||||
}
|
||||
|
||||
if rounds > 0 {
|
||||
watch(rounds);
|
||||
xinput_watch(rounds);
|
||||
}
|
||||
if let Some(slot) = rumble_slot {
|
||||
println!();
|
||||
rumble(slot, 3);
|
||||
}
|
||||
if gameinput_report || gi_rumble.is_some() {
|
||||
println!("\n== GameInput ==");
|
||||
match crate::gameinput::GameInput::create() {
|
||||
Err(e) => println!(" unavailable: {e}"),
|
||||
Ok(gi) => {
|
||||
gi.report();
|
||||
if let Some(spec) = &gi_rumble {
|
||||
let v: Vec<f32> = spec
|
||||
.split(',')
|
||||
.map(|p| p.trim().parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
let p = crate::gameinput::GameInputRumbleParams {
|
||||
lowFrequency: v.first().copied().unwrap_or(0.0),
|
||||
highFrequency: v.get(1).copied().unwrap_or(0.0),
|
||||
leftTrigger: v.get(2).copied().unwrap_or(0.0),
|
||||
rightTrigger: v.get(3).copied().unwrap_or(0.0),
|
||||
};
|
||||
gi.rumble(p, std::time::Duration::from_secs(3), gi_pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
imp::run();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user