Compare commits

...
Author SHA1 Message Date
enricobuehler 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 2f071a9a93 Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:08:15 +00:00
enricobuehler 62d35bc4b6 Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:07:54 +00:00
enricobuehler 5d06ef26ac Merge pull request 'fix(feedback): the pad stops keeping a game's trigger effect after the stream ends' (#44) from worktree-haptics-m9-richfb into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:07:35 +00:00
enricobuehler fcf4076eb7 Merge remote-tracking branch 'origin/main' into worktree-haptics-m9-richfb
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 2m10s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m45s
android / android (pull_request) Successful in 4m54s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m17s
ci / rust (pull_request) Successful in 7m59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m0s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:07:18 +02:00
enricobuehler 53eb592c43 Merge pull request 'fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn' (#43) from worktree-haptics-m8-proto into main
deb / build-publish-client-arm64 (push) Successful in 1m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
android / android (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m58s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / web (push) Successful in 2m52s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 3m8s
ci / docs-site (push) Canceled after 2m56s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 2m48s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 13s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:03:33 +00:00
enricobuehler 956d8dd8ef Merge pull request 'fix(host/windows): two virtual pads stop tearing each other's reports' (#39) from worktree-haptics-m7-windows into main
deb / build-publish-host (push) Canceled after 4s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
android / android (push) Canceled after 10s
deb / build-publish-client-arm64 (push) Canceled after 4s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 10s
ci / rust-arm64 (push) Canceled after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
ci / web (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 14s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 14s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 33s
windows-drivers / driver-build (push) Successful in 1m58s
2026-08-04 21:03:13 +00:00
enricobuehler b2e716ad5f Merge pull request 'fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit' (#38) from worktree-haptics-m6-presenter into main
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
android / android (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 27s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 4m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:02:36 +00:00
enricobuehler ec288d64d3 Merge pull request 'fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking' (#35) from worktree-haptics-m5-android into main
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
android / android (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust (push) Canceled after 4s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
2026-08-04 21:02:15 +00:00
enricobuehler 68353a5d57 Merge pull request 'fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying' (#32) from worktree-haptics-m4-apple into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
apple / swift (push) Canceled after 53s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
2026-08-04 21:01:51 +00:00
enricobuehler ffd5a33598 Merge pull request 'fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals' (#30) from worktree-haptics-m3-rumble-engine into main
windows-host / package (push) Canceled after 1m3s
windows-host / canary-manifest (push) Canceled after 0s
deb / build-publish (push) Canceled after 58s
deb / build-publish-host (push) Canceled after 56s
deb / build-publish-client-arm64 (push) Canceled after 49s
windows-host / winget-source (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
apple / swift (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
arch / build-publish (push) Canceled after 56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 49s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
android / android (push) Canceled after 7s
release / apple (push) Canceled after 5m31s
2026-08-04 21:01:32 +00:00
enricobuehler 4af8b02be1 Merge pull request 'fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back' (#52) from worktree-conflict-detect-and-isolate-recovery into main
deb / build-publish (push) Canceled after 1s
deb / build-publish-client-arm64 (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
android / android (push) Canceled after 25s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m11s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
apple / swift (push) Canceled after 32s
windows-host / winget-source (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
arch / build-publish (push) Canceled after 37s
ci / rust (push) Canceled after 46s
ci / web (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 48s
ci / docs-site (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
Reviewed-on: #52
2026-08-04 21:00:56 +00:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler b31495bea5 fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back
ci / rust (pull_request) Successful in 9m53s
ci / rust-arm64 (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
apple / swift (pull_request) Successful in 1m20s
ci / docs-site (pull_request) Successful in 1m12s
android / android (pull_request) Successful in 2m56s
ci / web (pull_request) Successful in 2m0s
Three things a field report (Discord, upgrade from 0.1x) turned up, all on the
Windows host.

1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled.
   Both uninstallers leave their config/log directory in Program Files behind, and
   `detect.rs` counted a bare directory, or a service registered at ANY start type
   (including `disabled`), as a live conflict. The installer's own probe was
   narrowed to "service start type <= 2" after exactly this cried wolf on a
   `winget install`, and the tray dropped its always-on warning for the same reason
   in 3e782852 — the runtime probe never got the same treatment, so the one surface
   the user actually looks at kept shouting. `Evidence::is_active` now draws the
   line (running, or set to start on its own) and only active detections reach the
   startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant
   findings still print in the full report, under a heading that says they need no
   action — that report is where "why does it think I have Apollo?" gets answered.

2. The console's conflicts card hardcoded "Another game-streaming server is
   **running** on this machine" regardless of what was found, so a dormant leftover
   was announced as a running server. It now says "active", and each entry names
   the observation — `Sunshine (running)`, `Apollo (starts automatically)`.

3. "The exclusive screen never times out going back to re-enabling the display."
   `isolate_displays_ccd` deactivates the operator's panels and hands the
   pre-isolate topology to the caller, which restores it at teardown — but that
   snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated
   topology to the CCD database. So a host that crashed, was killed, or was stopped
   mid-session left the desk dark with nothing in the product to undo it. There was
   one startup recovery leg already, but only for the EXPERIMENTAL
   `pnp_disable_monitors` axis, which is off by default — the default Exclusive path
   had none. `isolate_journal` now marks what an isolate is about to switch off
   (before the apply, so dying mid-apply is covered), clears the mark on restore,
   and force-EXTENDs at host startup if a mark survived. EXTEND rather than
   replaying the saved blob: the blob pins the virtual display's target id, which
   dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into
   the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays
   correct across a reboot, where saved ids would be stale.
2026-08-04 22:34:33 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

cbindgen emits a bare #define per `pub const`, so those names landed in the
namespace of every C program that includes the header. The rename table already
said this was the rule and already carried the handful someone had noticed —
and its own comment spells out why it matters: a clashing #define silently
takes the last definition rather than failing to compile, so the failure mode
is a wrong value, not a build error. This is the remaining 149.

Associated constants are deliberately left alone. cbindgen already qualifies
those with their type name, which is the very property whose absence makes a
bare MAX_PADS dangerous — they are namespaced, just not by us.

Nothing in this repository consumed the unprefixed spellings except one Swift
test, which sat next to lines already using the prefixed form because its
constant happened never to have been added to the table; it is updated here.
The C harness links and runs against the regenerated header.

Scheduled deliberately: the sweep flagged this for a release boundary, and
0.24.0 has shipped. External C embedders using the old spellings must add the
prefix; there is no silent breakage, since the old names simply stop existing.
2026-08-04 20:52:58 +02:00
enricobuehler 77ddd05b13 fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry
Three wire and ABI faults.

An out-of-range pad index reached one rumble consumer and not the other. It
skipped the reorder gate — the per-pad seq cursor has no slot for it — and was
handed to the legacy queue, while the policy engine discarded it on its own
bounds check, so the comment promising both consumers are fed was false for
exactly these. An embedder draining the queue could be handed an index it would
use to subscript its own per-pad array. The host never emits one, so it is
malformed or hostile either way; both consumers now agree by dropping it before
either sees it.

The adaptive-trigger effect was the only variable-length wire field bounded on
neither side. Encode appended whatever it was handed and decode took the whole
tail, while its sibling raw-report field had been bounded both ways all along;
there is now one constant both sides clamp to. Worse than the missing bound was
the empty case: a body with no effect bytes decoded as an EMPTY effect, and
downstream an empty block is written as an all-zero trigger report, which is
mode 0x00 — release. A truncated datagram could therefore silently cancel the
trigger effect a game was holding. That shape is now rejected outright; a
genuine release is a full-length zero block and still decodes.

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
enricobuehler a9a514dea0 fix(feedback): the pad stops keeping a game's trigger effect after the stream ends
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m0s
ci / docs-site (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m34s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 3m26s
android / android (pull_request) Successful in 4m9s
ci / rust (pull_request) Successful in 7m51s
Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive
triggers — both of which leave a controller physically wrong with nothing to
put it right.

Nothing reset the pad on teardown. Rumble stops on its own the moment nothing
renews it, but the rich planes are LATCHED in the controller's firmware: they
outlive the stream, the app, and being unplugged. Ending a session while a game
held a weapon's trigger resistance left the physical trigger stiff on the
desktop afterwards, and its lightbar showing whatever the game last set, until
another game happened to set one. The Apple client already reset on teardown;
the desktop and Android halves now do too — triggers to mode 0x00, lightbar
dark, player indicator cleared. Android writes them EP0-direct like its rumble
stop, because the reader thread is stopping and the queue would never drain.

A single lost datagram stranded the pad on the previous value. The plane is
deduped AND rides unreliable datagrams, which is a bad pairing: a change is
forwarded exactly once, so when that datagram is dropped nothing re-derives it
— the game keeps sending the same value and the dedup swallows every copy. The
pad then holds the last weapon's trigger effect, or the last lightbar colour,
for as long as the game keeps that setting, which can be the rest of a level.
The dedup already remembers the current state, so it can repair itself: it now
re-emits what it has latched once a second. Slow on purpose — this is a repair
mechanism, not a transport, and every value is idempotent, so a client that did
receive the original simply re-applies it. A forward re-stamps the clock, so a
plane the game is actively driving never pays for a renewal it does not need.

One-shot pulses are deliberately excluded from that renewal: replaying a
trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are
excluded too — the device's own refresh cadence already re-sends them verbatim.
2026-08-04 20:37:25 +02:00
enricobuehler 6e001e54b4 fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn
android / android (pull_request) Successful in 9m9s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 11m18s
ci / web (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 2m8s
ci / rust-arm64 (pull_request) Successful in 4m2s
Four encoder faults, plus a note on a fifth that turned out not to be one.

A centred stick did not encode as centre on the Y axes. The mapper inverted the
already-quantised byte, and 0..255 has no exact midpoint: the forward map puts
centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that
DsState::neutral and the pad's own resting report use. Games idle-poll a
centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent
sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by
construction and keeps both extremes exact; the only cost is i16::MIN and
-32767 sharing a code, one LSB at the very end of the travel.

Force-feedback ignored replay.delay. It was decoded on upload and never read:
an effect started the moment it was played and ended replay.length later, so
anything scheduling a delayed effect — DirectInput under Wine does this
routinely — fired early AND finished early by the same amount. The delay now
shifts the whole window, with length measured from the end of the delay rather
than eaten into by it. Writing the test for that surfaced a second bug in the
same path: a waiting effect was still a candidate for the abandoned-effect
force-off, and since the play command is itself the last FF activity, an
infinite effect with a delay longer than the idle window would be killed on its
first contributing tick after sitting silent the whole time it waited. Being
abandoned now requires the effect to have been audible for the window too.

An empty serial panicked the service thread. The reply builder clamped the
length to at least 1 and then sliced that many bytes out of the string, which
asks a zero-byte slice for one byte. The kernel already has a graceful answer
for a length it rejects, so report the true one and let it fall back.

Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a
declared 32767, leaving the last 127 counts unreachable, so no game could ever
see the axis bottom out. One multiply gets both ends exact.

The idle watchdog is left alone. It does cut finite multi-second effects that
the uinput path exempts, but only the uinput path is handed an explicit
duration; the protocols behind the watchdog are level-triggered with no
duration field anywhere in a report, so there is nothing at that layer to
exempt. The choice is between cutting a long effect and letting an abandoned
one drone forever, and only the latter has field evidence behind it. Recorded
at the constant so the next reader sees the cost rather than rediscovering it.
2026-08-04 20:08:56 +02:00
enricobuehler 31b5f90b12 fix(host/windows): two virtual pads stop tearing each other's reports
apple / swift (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 2m52s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 25m42s
windows-drivers / probe-and-proto (pull_request) Successful in 29s
windows-drivers / driver-build (pull_request) Successful in 1m37s
Three faults on the Windows pad path, two of them races that only bite when a
game drives a pad hard enough for two callbacks to overlap.

pf-gamepad's output ring could hand the host a torn report. Publishing is a
read-modify-write — read the cursor, write the slot it names, advance it — and
the framework dispatches output callbacks in parallel, so two could be inside
it at once: both read the same head, both wrote the SAME slot, and both stored
head+1, so the cursor moved once for two reports and the host read a single
entry with two reports mixed into it. An atomic fetch_add does not fix this. It
hands each writer its own slot but advances the cursor before the bytes exist,
so the host is then invited to read a slot still being filled. Serializing the
publish is what makes the cursor bump mean "the slot below is complete". The
ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat
that STOP with no idle watchdog behind it.

Both drivers also promised the host an ordering they never established. The
host loads out_seq and rumble_seq with Acquire and says so in its own comments
— "Acquire pairs with the driver's publish-then-bump store order" — but the
drivers bumped both with plain writes, and an Acquire load pairs with a Release
store and nothing else. On a weakly-ordered core the host could see a fresh seq
against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring:
two SET_STATE calls could both read one value and both write back value+1, so
the host saw one bump for two writes and skipped a level. A skipped stop is the
one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the
game went quiet, which is what bounds the damage.

Diagnosing an unattached driver stalled the session. The pad service thread —
the one feeding input and rumble — waited up to two seconds for a pnputil
enumeration, per unattached pad, at exactly the moment a session was already
going wrong. The diagnosis now runs on its own thread. Off the hot path the
wait no longer has to be a compromise, so it is generous enough to report what
it actually found instead of giving up with "still enumerating" — which, given
pnputil routinely takes longer than the old budget, is what it usually did.
2026-08-04 19:20:01 +02:00
enricobuehler 8abdd74a62 fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 8m52s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 12m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
Three faults in the desktop session's gamepad path.

The Steam Deck lost its built-in trackpad-mouse at the start of every session.
SDL's Valve HIDAPI driver clears the pad's digital mappings during
*enumeration*, which is part of bringing the gamepad subsystem up — so holding
the drivers off from inside GamepadService::pumped could never work: receiving
a GamepadSubsystem means the enumeration has already happened. The hint set
there detached a driver that had already done the damage, and lizard mode only
came back seconds later when the firmware watchdog restored it. The presenter
now disables them with its other pre-SDL_Init hints. The threaded worker always
had this right; only the caller-pumped path was wrong, and it could not fix
itself, hence a separate entry point its callers can place correctly.

Player LEDs did nothing at all on any pad that is not a DualSense. The match
arm handled the DualSense raw-effects path and let everything else fall through
a bare `_`, though SDL exposes set_player_index and owns the per-device
pattern. The wire carries a positional bitmask rather than an index, and the
bridge is the popcount: every convention that reaches this wire spells "player
N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the
Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both,
where reading a bit position would only ever suit one. No lit LED means no
player, not player 0. The remaining unhandled variants are now named rather
than swept up by `_`, so a new one cannot join them silently.

A forwarded pad could be left buzzing when the session ended. detach() only
posts Ctl::Detach; the close that flushes the pad, tells the host to remove it
and explicitly zeroes the motors runs when the pump next drains that message.
Single mode broke out of the loop immediately after detaching and Event::Quit
never detached at all, so both skipped it entirely. The teardown now sits where
every exit converges instead of on the individual breaks. That still leaves the
several paths that leave by `?` on a fatal overlay or present error, so the
pump also silences its slots on Drop — the explicit call stays, because a pad
should go quiet before a long teardown rather than after it. Drop closes the
slots directly rather than draining the queue that would have done it: same
physical outcome, and it touches no lock, where draining reaches an unwrap on a
Mutex that would abort the process if it panicked mid-unwind.
2026-08-04 19:10:45 +02:00
enricobuehler 66a28d5abb ci(android): run the kit's unit tests
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / docs-site (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 3m17s
ci / rust (pull_request) Failing after 13m58s
They were running nowhere. This workflow only assembled, and the screenshot
workflow runs the :app module's tests, so nothing enforced :kit's — the pure
parsers, migrations and feedback policies could go red without anyone
noticing. A couple of seconds against a module the build already produces.
2026-08-04 18:14:13 +02:00
enricobuehler e2faecfd42 fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking
Four faults in the Android feedback path, all of them silent.

Rumble stopped for the rest of the session if one vibrator call threw. The
poll thread called cancel() unguarded while every call around it was already
wrapped, so an unchecked throw — DeadSystemRuntimeException, or the
RuntimeException a dying service wraps a RemoteException in — unwound the
thread. `running` stayed true, so nothing noticed it was gone and nothing
restarted it. Guarding the two bare cancels is not enough on its own: the
binder calls that bind a vibrator can throw just the same, so the loop itself
now survives a failed render, and the same guard covers the hidout thread.

A rumble stop that was never written was treated as one that landed. The
DualSense capture disarmed its backstop timer *before* the write, on a queue
that discarded failed submits without saying so, so a dropped stop left the
motors running with nothing scheduled to try again — and a USB pad holds its
last level until told zero. Writes now report whether they were accepted, the
backstop is disarmed only once the stop is actually on its way, and the
backstop re-arms rather than giving up if its own write is refused.

A full write queue dropped lightbar colours, player-LED masks and trigger
effects. Its overflow rule was "drop the oldest", which is right for rumble —
re-sent continuously, so a lost frame returns milliseconds later — and wrong
for everything else, which the host sends once on change and never repeats.
Eviction is now driven by an explicit key from the caller rather than by
inspecting the bytes: rumble supersedes the pending rumble in place, and a
one-shot is discarded only if the queue holds nothing but one-shots. The key
cannot be recovered from the report itself, which is why this is not keyed by
report id — every DualSense output report carries the *same* id and differs
only in its valid_flag bytes, so an id-keyed rule would let a rumble
supersede a lightbar, which is this bug again by another route.

An unplug leaked the USB connection and the detach receiver. The link only
signalled the drop; neither capture released anything, so the interfaces
stayed claimed (the pad could not return to Android's own input stack) and a
re-plug overwrote the field holding the receiver, stranding one live for the
rest of the process. The captures now release the transport, stop() is safe
to call from the callback it arrives on — the reader thread must not join
itself — and a close is reported exactly once however many detectors see it.
A reader that could not queue a single request now reports itself down too,
instead of leaving the owner waiting on a capture that never streams.
2026-08-04 18:14:07 +02:00
enricobuehler 76832a5b86 fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying
apple / swift (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m48s
ci / rust (pull_request) Successful in 7m11s
Five faults in the Apple client's feedback path.

With two DualSenses attached, each pad's renderer opened "the first connected
DualSense" — taken from an unordered Set, so the choice could differ between
two calls in one process. Both renderers could land on the same device, one
pad's rumble coming out of the other while their per-instance write dedupes
fought over it, or they could split by luck. Each renderer now asks for the
device its own controller is, correlating GameController's stable ordering with
IOKit's location ids; the selection rule is a pure function so it can be tested
without an IOHIDDevice, which cannot be constructed. Without a preference the
lowest location id wins — still arbitrary, but stable, which Set.first was not.

A failed HID write was logged and swallowed, so a write that never reached the
device still counted as a successful render. That matters most for a stop,
which has nothing behind it: the renderer stamped its write clock even on
failure, the keepalive only re-writes non-zero levels, the ticker is cancelled
once the target is zero, and on USB there is no firmware timeout. A swallowed
stop therefore left the motors running with nothing scheduled to try again.
The write result now reaches the caller, which drops the handle and falls back
to CoreHaptics rather than claiming success.

A half-failed split-handle setup reported HEALTHY. Only the all-nil case
counted as failure, so one surviving handle passed silently while rendering
something wrong in a direction that depended on which handle died: lose the
right one and render falls to the combined branch, playing max(low, high) on
the LEFT handle; lose the left and the split branch discards the heavy motor
outright. A half-open split now tears the survivor down and takes the combined
path, which at least renders both motors somewhere.

Session end never put the lightbar out. This class is what turned it on, and
every DS write is valid-flag-selective, so a game's last colour stayed lit in
firmware after the stream ended — a DS4 was cleared incidentally because its
player indicator IS the lightbar, a DualSense was not.

And the renderer's stop() ran on the main actor. It is a queue.sync whose body
is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own
notes record as able to hang — plus a blocking HID write to a device that has
just departed, and it queues behind any in-flight setup(). It runs on every
unplug and every pin change, and the main thread drives the presenter's
CADisplayLink, so it hitched the picture mid-stream. It is detached now; the
renderer is already off routing by then, so nothing observes it.

Verified: swift build clean, 188 tests pass (185 before), and the three new
device-selection tests fail if the deterministic fallback is reverted.

Note for anyone rebuilding here: the checked-in xcframework was stale (it
predates punktfunk_connection_report_phase) and build-xcframework.sh still dies
on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand
from `cargo build --target aarch64-apple-darwin` is enough to typecheck.

From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20).
2026-08-04 08:20:12 +02:00
enricobuehler ec4bf75a6e fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m54s
ci / rust-arm64 (pull_request) Successful in 4m8s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 9m39s
Three faults in the shared rumble policy engine, all answered by one change of
shape: the free-running jitter phase becomes `last_emit` — the exact value last
handed to an embedder — and every emit routes through one helper. That single
field answers all three live questions: would re-sending this be a no-op device
write, is this stop redundant, and would the nudge invent a stop.

The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an
SDL-class layer discards a write identical to the last one. But the nudge lived
only in the keepalive branch, so every host renewal re-emitted the raw level,
collided with the last jittered write, was discarded, AND re-anchored the
keepalive timer. The gap between distinct device writes stretched to 80 ms at
the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half
times the cadence the quirk exists to guarantee. Nudging on any repeat closes
it: 40 ms throughout.

Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for
"stop now" — and handed it out with a non-zero backstop, under a live lease.
It is the only such level: high must already be zero, and low ^ 1 == 0 implies
low == 1. The nudge now steps the LSB up instead, so the phase still alternates
and no stop is ever invented.

A zero for a pad the engine already believes silent is now dropped. Under the
legacy hatch the host re-sends zeros for every latched pad every 500 ms for the
rest of the session, which cost Android an unconditional log line and a binder
cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because
a stop that was LOST leaves the pad buzzing, and that is exactly the guard's
pass condition.

The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is
sender-side only, so a modified or third-party host could stamp a long TTL and
wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness
policy of its own — and a Deck slot buzzing for all of it.

Every new test was proven to fail with its own fix reverted, including the two
that guard against over-reach: a default-quirks pad must still get the level
verbatim, or an off-by-one amplitude would land in Apple's identical-target
comparison and Android's one-shots.

One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0
cannot take the legacy backstop, because the expiry check preempts the relay
branch. No fix; pinned with a test so that ordering stays load-bearing.

Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings
= 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips`
fails on this Mac with a linker error, identically on an unmodified tree.)

From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1).
2026-08-04 07:40:19 +02:00
52 changed files with 3268 additions and 630 deletions
+8
View File
@@ -160,6 +160,14 @@ jobs:
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
# The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were
# running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app
# module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already
# built module) and it is the only automated cover those behaviours have.
- name: kit unit tests
working-directory: clients/android
run: ./gradlew :kit:testDebugUnitTest --stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
working-directory: clients/android
env:
@@ -116,7 +116,12 @@ class DsCapture(
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
// Nothing can retry after this point, so a failure is worth saying out loud: it is
// the difference between a quiet pad and one that buzzes until it is unplugged.
if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written")
// Motors silenced above; this hands back the lightbar, player LEDs and adaptive
// triggers the game was holding, which outlive the link just as stubbornly.
resetRichFeedback(m)
}
disarmBackstop()
usb.stop()
@@ -145,6 +150,9 @@ class DsCapture(
val wasActive = model != null
model = null
releaseSlot()
// Release the transport too: the link only *signals* the drop, so without this an unplug
// left its connection open, its interfaces claimed and its detach receiver registered.
usb.stop()
if (wasActive) onActiveChanged?.invoke(false)
}
@@ -216,17 +224,20 @@ class DsCapture(
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
val stop = low == 0 && high == 0
if (!stop) armBackstop(backstopMs)
val sent = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE)
}
if (stop) {
// Disarm only once the stop is actually on its way. Dropping the net *before* the
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
}
}
@@ -252,6 +263,9 @@ class DsCapture(
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
// Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current
// fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by
// collapsing a backlog of them down to the last.
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
@@ -261,8 +275,38 @@ class DsCapture(
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
OutReportQueue.KEY_RUMBLE,
)
/**
* Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear.
*
* Rumble stops the moment nothing renews it, but these are LATCHED in the controller's
* firmware — they outlive the stream, the app, and being unplugged. Ending a session while a
* game held a weapon's trigger resistance left the physical trigger stiff afterwards, with
* nothing to release it but another game that happens to set one.
*
* EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT
* queue would never drain. Writes are best-effort — the pad may already be gone.
*/
private fun resetRichFeedback(m: DsDevice.Model) {
if (m == DsDevice.Model.DUALSHOCK4) {
// No adaptive triggers or player LEDs on a DS4, and its write is full-state, so
// blacking the lightbar is a single composed report.
ds4Rgb = 0
usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0))
return
}
// An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger.
for (which in 0..1) {
usb.writeControl(
DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)),
)
}
usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0))
usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0))
}
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
@@ -284,7 +328,12 @@ class DsCapture(
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
val m = model ?: return@Runnable
// The net itself can be refused (a full queue, a connection going away). Re-arm rather
// than give up: this is the last thing between a stalled poll thread and a pad that
// buzzes until it is unplugged. It stops re-arming as soon as the link closes, which
// clears `model` and disarms.
if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS)
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
@@ -297,5 +346,9 @@ class DsCapture(
private companion object {
const val TAG = "DsCapture"
/** How soon to retry a rumble stop whose write was rejected. Short: the motors are running
* and the host has already moved on, so nothing else is coming to silence them. */
const val STOP_RETRY_MS = 100L
}
}
@@ -279,8 +279,8 @@ object DsDevice {
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
it[3] = wireAmplitudeToByte(high).toByte()
it[4] = wireAmplitudeToByte(low).toByte()
}
/**
@@ -324,17 +324,11 @@ object DsDevice {
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[4] = wireAmplitudeToByte(high).toByte()
it[5] = wireAmplitudeToByte(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -88,6 +88,9 @@ class GamepadFeedback(
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
const val TAG_HID_RAW: Byte = 0x05
/** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */
const val LOG_EVERY = 128L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
@@ -125,37 +128,51 @@ class GamepadFeedback(
fun start() {
running = true
rumbleThread = Thread({
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
// rumble policy engine — it owns every lease/staleness/close decision (uniform
// across all clients; the old 60 s legacy-host exposure is gone) and emits
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
// the backstop (the hardware net under a stalled poll thread).
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
// against the Rust packer.
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
// Rendering is binder calls into the vibrator service, and every one of them can
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
// the ordinary RuntimeException a dying service wraps its RemoteException in.
// Unguarded, ONE of those killed this thread outright: `running` stayed true, so
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
// the session. Losing a single command is recoverable; losing the loop is not.
runCatching {
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
}
}, "pf-rumble").apply { isDaemon = true; start() }
hidoutThread = Thread({
// 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes].
val buf = ByteBuffer.allocateDirect(128)
var failures = 0L
while (running) {
val n = NativeBridge.nativeNextHidout(handle, buf)
if (n < 0) continue // timeout / closed
dispatchHidout(buf, n)
// Same hazard as the rumble loop above: lights/trigger rendering is binder and USB
// calls, and an unchecked throw here would silently end the rich-feedback plane.
runCatching { dispatchHidout(buf, n) }
.onFailure { failures = noteRenderFailure("hidout", it, failures) }
}
}, "pf-hidout").apply { isDaemon = true; start() }
}
/**
* Record a render failure the poll loop swallowed, and return the updated count. Logged on the
* first occurrence and sparsely after: a genuinely dead vibrator service fails on *every*
* command, which at a rumble plane's rate would bury the log.
*/
private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long {
if (seen == 0L || seen % LOG_EVERY == 0L) {
Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t)
}
return seen + 1
}
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() {
running = false
@@ -264,12 +281,12 @@ class GamepadFeedback(
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
runCatching { m.cancel() } // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
@@ -294,7 +311,7 @@ class GamepadFeedback(
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
runCatching { lv.cancel() } // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
@@ -314,8 +331,8 @@ class GamepadFeedback(
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
@@ -329,12 +346,6 @@ class GamepadFeedback(
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
@@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
@@ -81,14 +81,20 @@ class HidUsbLink(
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
* request; a second waiter would steal the reader's completions). See [OutReportQueue] for
* what gets discarded when it fills, and why that is not simply "the oldest". */
private val outQueue = OutReportQueue()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however
* many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see
* it. Reset by [start]. */
private val down = AtomicBoolean(false)
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
@@ -114,6 +120,7 @@ class HidUsbLink(
connection = conn
device = dev
claims = claimed
down.set(false)
running = true
Log.i(
config.tag,
@@ -134,10 +141,7 @@ class HidUsbLink(
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
linkDown()
}
}
}
@@ -221,6 +225,9 @@ class HidUsbLink(
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
// `start` already returned true, so without this the owner would sit waiting on a
// capture that never streams and never reports itself dead.
linkDown()
return
}
val scratch = ByteArray(64)
@@ -295,10 +302,23 @@ class HidUsbLink(
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
linkDown()
}
/**
* Report the link down, exactly once, from whichever detector noticed first — the detach
* broadcast (main thread) or the reader thread on its way out.
*
* This only *signals*; releasing the connection and the interfaces stays the owner's job, via
* the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the
* detach receiver flipped a flag and fired the callback, so an unplug left the connection open,
* the interfaces claimed (the pad could not return to Android's own input stack) and the
* receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver
* that stayed live for the process's lifetime.
*/
private fun linkDown() {
running = false
if (down.compareAndSet(false, true)) onClosed()
}
private fun finishReader(claims: List<Claim>) {
@@ -314,28 +334,35 @@ class HidUsbLink(
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*
* [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace
* this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE]
* for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat.
*
* Returns whether the report reached the device or is queued for it. A caller that is writing
* a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken
* for one that landed.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean {
if (data.isEmpty()) return false
return when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
// Interrupt-OUT rides UsbRequests submitted by the reader thread.
outQueue.offer(data, coalesce)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
else -> false
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
private fun setReport(type: Int, data: ByteArray): Boolean {
val conn = connection ?: return false
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false
return sendReport(conn, ifId, type, data)
}
/**
@@ -344,9 +371,8 @@ class HidUsbLink(
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
fun writeControl(data: ByteArray): Boolean =
data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data)
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
@@ -358,27 +384,48 @@ class HidUsbLink(
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
private fun sendReport(
conn: UsbDeviceConnection,
ifaceId: Int,
type: Int,
data: ByteArray,
): Boolean {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
// controlTransfer returns the byte count, or a negative value on failure — a failed write
// must be reported as such, not swallowed (a dropped rumble stop has nothing behind it).
val n = runCatching {
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}.getOrDefault(-1)
return n >= 0
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
/**
* Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed].
*
* Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up,
* and it arrives on the reader thread, which must not try to join itself.
*/
fun stop() {
running = false
// Claim the down-latch so the reader's own exit does not report a close the owner asked for.
down.set(true)
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
if (reader !== Thread.currentThread()) {
runCatching { reader?.join(1000) }
// Only forget the thread once it is actually gone: clearing it while it still runs
// would let a later stop() skip the join and free the connection under it.
reader = null
}
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
@@ -0,0 +1,89 @@
package io.unom.punktfunk.kit
/**
* The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow
* policy knows which reports may be thrown away and which may not.
*
* The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from
* the feedback threads are handed to the reader thread rather than submitted directly. It has to
* be bounded — a stalled or unplugged device would otherwise grow it without limit — and the
* question is what to discard when it fills.
*
* The old policy was "newest wins": drop from the head until there is room. That is right for
* rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is
* replaced milliseconds later and nothing is permanently lost. It is wrong for everything else.
* A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host
* sends them on change and never repeats them. Dropping one leaves the pad wrong until the next
* time that value happens to change, which may be never.
*
* So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes.
* That distinction cannot be recovered from the report itself: every DualSense output report
* carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy
* would happily let a rumble supersede a lightbar — the very bug this replaces, relocated.
*
* Two rules:
* - A report offered with a coalescing key **replaces** the pending report with that key, in
* place. A burst of rumble collapses to its latest value and never displaces anything else.
* - Only when the queue is full does anything get dropped, and then the oldest *coalescable*
* report goes first. A one-shot is discarded only if the queue is full of nothing but
* one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad
* produces.
*
* Thread-safe: offered by the feedback threads, drained by the reader thread.
*/
internal class OutReportQueue(private val cap: Int = CAP) {
private class Entry(val key: Int, val data: ByteArray)
private val items = ArrayDeque<Entry>()
/**
* Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen
* constant identifying a level-styled stream whose newer values supersede older ones.
*
* Returns false only if the report had to be dropped outright — the caller can then treat the
* write as failed rather than assuming it is on its way.
*/
fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) {
if (key != NO_COALESCE) {
val at = items.indexOfFirst { it.key == key }
if (at >= 0) {
// Supersede in place: keeping the queue position stops a fast rumble stream from
// repeatedly jumping the one-shots queued ahead of it.
items[at] = Entry(key, data)
return true
}
}
if (items.size >= cap) {
val victim = items.indexOfFirst { it.key != NO_COALESCE }
if (victim >= 0) {
items.removeAt(victim)
} else if (key != NO_COALESCE) {
// Nothing coalescable to sacrifice and this report is itself replaceable — drop it
// rather than a one-shot that will never come again.
return false
} else {
items.removeFirst()
}
}
items.addLast(Entry(key, data))
return true
}
/** The next report to submit, or null when nothing is pending. */
fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data }
fun clear() = synchronized(items) { items.clear() }
val size: Int get() = synchronized(items) { items.size }
companion object {
/** This report is a one-shot: never superseded, evicted only as a last resort. */
const val NO_COALESCE = 0
/** Motor levels — re-sent continuously, so only the newest is worth keeping. */
const val KEY_RUMBLE = 1
/** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */
const val CAP = 32
}
}
@@ -0,0 +1,47 @@
package io.unom.punktfunk.kit
/**
* The two conversions every rumble path in this module needs, in one place.
*
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
* silent divergence between copies would have been hard to notice.
*/
/**
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
*
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
* imperceptibly light, but it moves.
*/
internal fun wireAmplitudeToByte(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
/**
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
*
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
* compile-time assertion tying the two together, so this can't silently start truncating.
*
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
*/
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
if (ev < 0L) return null // timeout / closed
return RumbleCmd(
pad = ((ev ushr 49) and 0xFL).toInt(),
low = ((ev ushr 16) and 0xFFFF).toInt(),
high = (ev and 0xFFFF).toInt(),
backstopMs = (ev ushr 32) and 0xFFFF,
)
}
@@ -273,10 +273,20 @@ class Sc2Capture(
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
// Both transports share this callback, so read which one was live BEFORE clearing it —
// releasing the other would tear down a link that never dropped.
val dropped = activeLink
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
// Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this
// worse than a single leak: it is the pad that gets power-cycled, so the same process can
// round-trip a link many times in one session.
when (dropped) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
}
onActiveChanged?.invoke(false)
}
@@ -0,0 +1,102 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old
* "drop from the head until there is room" policy did not make: rumble is re-sent continuously and
* may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated.
*/
class OutReportQueueTest {
/** A report carrying a 0..255 marker so a test can tell which one came back out. */
private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte())
// Masked: the marker rides in a Byte, and Byte.toInt() sign-extends.
private fun drain(q: OutReportQueue): List<Int> =
generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList()
@Test
fun `rumble supersedes the pending rumble instead of queueing another`() {
val q = OutReportQueue()
assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE))
assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE))
assertEquals("a rumble burst must collapse to one entry", 1, q.size)
assertArrayEquals(report(3), q.poll())
assertNull(q.poll())
}
@Test
fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10)) // a one-shot queued behind it
q.offer(report(2), OutReportQueue.KEY_RUMBLE)
// The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved
// behind an endlessly-renewed entry.
assertEquals(listOf(2, 10), drain(q))
}
@Test
fun `a full queue sacrifices rumble, never a one-shot`() {
val q = OutReportQueue(cap = 4)
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.offer(report(11))
q.offer(report(12))
assertEquals(4, q.size)
// Full. The old policy dropped the head — here that is a rumble, but only by luck of
// ordering; what matters is that the one-shots all survive.
assertTrue(q.offer(report(13)))
assertEquals(listOf(10, 11, 12, 13), drain(q))
}
@Test
fun `the one-shot the host never repeats survives a rumble storm`() {
val q = OutReportQueue(cap = 4)
// The exact regression: a lightbar colour queued once, then a flood of rumble. Under the
// old newest-wins eviction the colour was dropped from the head and never came back,
// leaving the pad lit wrong until the value next happened to change.
q.offer(report(200)) // lightbar
repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) }
val out = drain(q)
assertTrue("the lightbar report must still be queued, got $out", out.contains(200))
assertEquals("rumble must not have accumulated", listOf(200, 49), out)
}
@Test
fun `a queue full of one-shots refuses a rumble rather than dropping one`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertFalse(
"with nothing coalescable to sacrifice, the replaceable report yields",
q.offer(report(1), OutReportQueue.KEY_RUMBLE),
)
assertEquals(listOf(10, 11), drain(q))
}
@Test
fun `only a queue of nothing but one-shots drops one, and it is the oldest`() {
val q = OutReportQueue(cap = 2)
q.offer(report(10))
q.offer(report(11))
assertTrue(q.offer(report(12)))
assertEquals(listOf(11, 12), drain(q))
}
@Test
fun `clear empties the queue`() {
val q = OutReportQueue()
q.offer(report(1), OutReportQueue.KEY_RUMBLE)
q.offer(report(10))
q.clear()
assertEquals(0, q.size)
assertNull(q.poll())
}
}
@@ -0,0 +1,79 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
* sides, which is the only thing that catches one of them drifting.
*/
class RumbleWireTest {
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
((pad and 0xF).toLong() shl 49) or
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
(low.toLong() shl 16) or
high.toLong()
@Test
fun `every field round-trips at its extremes`() {
val cases = listOf(
listOf(0, 0, 0, 0),
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
listOf(1, 0x1234, 0x5678, 500),
listOf(7, 0, 0xFFFF, 2000),
)
for ((pad, low, high, backstop) in cases) {
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
assertEquals("pad", pad, cmd.pad)
assertEquals("low", low, cmd.low)
assertEquals("high", high, cmd.high)
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
}
}
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
@Test
fun `all sixteen pad indices are distinct`() {
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
assertEquals((0 until 16).toList(), seen)
}
@Test
fun `the negative sentinel is not a command`() {
assertNull(unpackRumbleEvent(-1L))
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
}
@Test
fun `a stop is distinguishable from a hold`() {
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
assertEquals(0, stop.low)
assertEquals(0, stop.high)
assertNotEquals(stop, hold)
}
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
@Test
fun `amplitude takes the high byte`() {
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
assertEquals(0x80, wireAmplitudeToByte(0x8000))
assertEquals(0x12, wireAmplitudeToByte(0x1234))
}
@Test
fun `zero stays silent but a weak nonzero never does`() {
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
for (v in listOf(1, 0x0042, 0x00FF)) {
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
}
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
}
}
+86 -6
View File
@@ -18,6 +18,29 @@ use std::time::Duration;
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
const PAD_BITS: u32 = 4;
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
/// rather than failing the build.
const _: () = assert!(
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
);
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
///
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
/// without a live session handle — the shift arithmetic is the part worth pinning.
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(low) << 16)
| jlong::from(high)
}
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
@@ -54,12 +77,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
Ok(cmd) => {
(jlong::from(cmd.pad & 0xF) << 49)
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(cmd.low) << 16)
| jlong::from(cmd.high)
}
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
@@ -160,3 +178,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
n as jint
})
}
#[cfg(test)]
mod pack_rumble_tests {
use super::*;
use punktfunk_core::input::MAX_PADS;
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
/// broken, and nothing else in the build would say so.
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
let pad = ((ev >> 49) & 0xF) as u16;
let backstop = ((ev >> 32) & 0xFFFF) as u32;
let low = ((ev >> 16) & 0xFFFF) as u16;
let high = (ev & 0xFFFF) as u16;
(pad, low, high, backstop)
}
#[test]
fn round_trips_every_field_at_its_extremes() {
for &(pad, low, high, backstop) in &[
(0u16, 0u16, 0u16, 0u32),
(15, 0xFFFF, 0xFFFF, 0xFFFF),
(1, 0x1234, 0x5678, 500),
(7, 0, 0xFFFF, 2000),
] {
let ev = pack_rumble(pad, low, high, backstop);
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
}
}
#[test]
fn every_representable_pad_survives_the_four_bit_field() {
for pad in 0..MAX_PADS as u16 {
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
}
}
#[test]
fn a_packed_command_is_never_negative() {
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
// "no command" and the rumble would simply vanish.
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
assert!(pack_rumble(0, 0, 0, 0) >= 0);
}
#[test]
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
let ev = pack_rumble(3, 0, 0, u32::MAX);
let (pad, _, _, backstop) = unpack(ev);
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
assert_eq!(backstop, 0xFFFF);
}
#[test]
fn a_stop_is_distinguishable_from_a_hold() {
let stop = pack_rumble(2, 0, 0, 0);
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
assert_ne!(stop, hold);
assert_eq!(unpack(stop).1, 0);
assert_eq!(unpack(stop).2, 0);
}
}
@@ -12,7 +12,7 @@ import GameController
public final class ControllerTester: ObservableObject {
// `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private let renderer = RumbleRenderer()
private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
@@ -21,8 +21,12 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID.
///
/// A caller that owns a particular pad passes the location id it wants (see
/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to,
/// so with two DualSenses attached each renderer drives its own device. Without a preference the
/// lowest location id wins an arbitrary but *stable* choice, where `Set.first` was neither.
final class DualSenseHID {
private let manager: IOHIDManager
private var device: IOHIDDevice?
@@ -43,9 +47,57 @@ final class DualSenseHID {
deinit { close() }
/// Find and open the first connected DualSense. Returns false if none is present or it can't
/// be opened (caller then falls back to CoreHaptics).
func open() -> Bool {
/// The IOKit location id of the device this instance opened the handle a caller correlates
/// with its `GCController`. `nil` until a successful `open`.
private(set) var locationID: UInt32?
/// A device's location id, or `nil` if IOKit does not report one.
static func locationID(of dev: IOHIDDevice) -> UInt32? {
IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32
}
/// Every connected DualSense/Edge, by location id what a caller pairs against its controllers.
static func attachedLocationIDs() -> [UInt32] {
let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let matches = productIDs.map { pid in
[kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray)
guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
return []
}
defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) }
let devices = IOHIDManagerCopyDevices(mgr) as? Set<IOHIDDevice> ?? []
return devices.compactMap(locationID(of:)).sorted()
}
/// Which attached device to drive, as an index into `ids` the whole selection rule, pure so
/// it can be tested without an `IOHIDDevice` (which cannot be constructed).
///
/// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not
/// merely arbitrary it can differ between two calls in one process. With two DualSenses that
/// made each renderer's paddevice binding a coin flip: both could land on the same device
/// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting
/// over it) or split by luck. An explicit location id makes the binding deterministic; the
/// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot
/// place never displaces one it can.
static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? {
if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit }
return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) }
}
/// Pick the device to drive from everything attached (see [`preferredIndex`]).
static func pick(_ devices: Set<IOHIDDevice>, preferring wanted: UInt32?) -> IOHIDDevice? {
let ordered = Array(devices)
guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else {
return nil
}
return ordered[i]
}
/// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns
/// false if none is present or it can't be opened (caller then falls back to CoreHaptics).
func open(preferringLocationID preferred: UInt32? = nil) -> Bool {
let matches = Self.productIDs.map { pid in
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
}
@@ -55,13 +107,21 @@ final class DualSenseHID {
return false
}
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
let dev = devices.first
let dev = Self.pick(devices, preferring: preferred)
else {
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return false
}
device = dev
locationID = Self.locationID(of: dev)
if let preferred, locationID != preferred {
// Not fatal one pad still gets rumble but with two pads attached it means this
// renderer is driving the wrong one, and it is invisible without the log line.
log.error(
"rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)"
)
}
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
@@ -70,8 +130,16 @@ final class DualSenseHID {
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
/// each 0...255. (0, 0) stops.
func rumble(low: UInt8, high: UInt8) {
guard let dev = device else { return }
///
/// Returns whether the write reached the device. The caller needs this: it used to be logged
/// and swallowed, so a failed write still counted as a successful render. That matters most
/// for a **stop**, which has nothing behind it the renderer stamps its write clock even on
/// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled
/// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed
/// stop left the motors running with nothing scheduled to try again.
@discardableResult
func rumble(low: UInt8, high: UInt8) -> Bool {
guard let dev = device else { return false }
let report = bluetooth
? Self.bluetoothReport(low: low, high: high)
: Self.usbReport(low: low, high: high)
@@ -81,7 +149,9 @@ final class DualSenseHID {
}
if rc != kIOReturnSuccess {
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
return false
}
return true
}
func close() {
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
#if os(iOS)
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
deviceRumble = RumbleRenderer(actuator: .device)
} else {
deviceRumble = nil
}
@@ -117,7 +117,15 @@ public final class GamepadFeedback {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
// OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a
// per-motor `CHHapticEngine.stop()` an XPC round trip to gamecontrollerd, which the
// renderer's own notes record as able to hang plus `DualSenseHID.close()`, whose
// blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also
// queues behind any in-flight `setup()`. This runs on every unplug and every pin
// change, and the main thread is what drives the presenter's CADisplayLink, so
// blocking here hitches the picture mid-stream. The renderer is already detached from
// routing above, so nothing observes it after this point.
if let renderer { Task.detached { renderer.stop() } }
}
for (pad, controller) in want {
if let slot = slots[pad] {
@@ -128,7 +136,7 @@ public final class GamepadFeedback {
replay(slot)
} else {
slots[pad] = Slot(controller: controller)
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(controller)
withRouting { rumbleByPad[pad] = renderer }
}
@@ -282,6 +290,12 @@ public final class GamepadFeedback {
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
// Put the lightbar out too. This class is what turned it on (see the `Led` and
// `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game
// set stays lit in firmware after the stream ends back at the launcher, or for a pad
// that merely left the forwarded set. A DS4 is cleared incidentally because its player
// indicator IS the lightbar; a DualSense is not.
c.light?.color = GCColor(red: 0, green: 0, blue: 0)
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
@@ -43,8 +43,14 @@ enum RumbleTuning {
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Wire amplitude DualSense HID motor byte. A nonzero command never collapses to silence:
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
/// nothing at all on this path. Floored at 1 imperceptibly light, but moving. (Android's
/// `toAmplitude` has always done this; this was the odd one out.)
static func hidByte(_ wire: UInt16) -> UInt8 {
let b = UInt8(wire >> 8)
return wire != 0 && b == 0 ? 1 : b
}
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
@@ -81,10 +87,11 @@ enum RumbleTuning {
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
/// `sessionStaleSeconds`; both are gone. Every liveness decision lease expiry, legacy-host
/// staleness, session close now belongs to punktfunk-core's shared policy engine
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
/// is told and never decides on its own when a level should end.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
@@ -93,17 +100,6 @@ enum RumbleTuning {
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
/// commands verbatim the engine (punktfunk-core `client/rumble.rs`) owns every lease,
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
/// the call sites' intent.
struct Policy {
static let session = Policy()
static let manual = Policy()
}
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) the opt-in
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
private let actuator: Actuator
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif
init(policy: Policy = .session, actuator: Actuator = .controller) {
self.policy = policy
init(actuator: Actuator = .controller) {
self.actuator = actuator
}
@@ -459,6 +453,18 @@ final class RumbleRenderer: @unchecked Sendable {
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
// HALF a split is worse than none, and it used to pass silently: only the all-nil case
// below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)`
// announced HEALTHY. What actually rendered was wrong in a direction that depends on
// which handle died lose `high` and `render` falls to the combined branch (selected
// purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined
// sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the
// heavy motor is discarded outright. Tear the survivor down and take the combined path,
// which at least renders both motors somewhere.
if low == nil || high == nil {
log.warning("rumble: only one split-handle engine came up — falling back to combined")
teardown() // disarms handlers, stops the survivor's players + engine, nils both
}
} else {
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
}
@@ -587,7 +593,9 @@ final class RumbleRenderer: @unchecked Sendable {
#if os(macOS)
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
let hid = DualSenseHID()
guard hid.open() else { return false }
// Ask for the device this renderer's controller actually is, so two attached DualSenses
// do not both get driven through whichever one an unordered Set happened to yield first.
guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false }
dualSenseHID = hid
return true
#else
@@ -595,6 +603,24 @@ final class RumbleRenderer: @unchecked Sendable {
#endif
}
#if os(macOS)
/// Correlate a `GCController` with an IOKit location id.
///
/// GameController exposes no location id, so there is no direct mapping. What it does expose is
/// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the
/// two by rank makes each renderer pick a *distinct* device, which is the property that was
/// missing. With one pad attached this is the same device it always was.
static func hidLocationID(for c: GCController) -> UInt32? {
let ids = DualSenseHID.attachedLocationIDs()
guard ids.count > 1 else { return ids.first }
let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad }
guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else {
return ids.first
}
return ids[rank]
}
#endif
/// 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).
@@ -605,8 +631,20 @@ final class RumbleRenderer: @unchecked Sendable {
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
if hid.rumble(low: levels.0, high: levels.1) {
lastHidWrite = (levels, .now())
} else {
// The write did not reach the device. Do NOT stamp the clock that would claim a
// render that never happened, and for a stop there is nothing behind it: the
// keepalive only re-writes non-zero levels and the ticker is cancelled once the
// target is (0, 0), so the motors would keep running with nothing scheduled.
// Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect
// rebuilds it. Health is reported so the state is visible rather than silent.
log.error("rumble: HID write failed — dropping the handle, falling back")
closeHID()
reportHealth("Lost the direct connection to this DualSense; using the system path.")
return false
}
}
return true
#else
@@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase {
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
XCTAssertEqual(crc, 0xCBF4_3926)
}
// MARK: - Device selection (B14)
/// With two DualSenses attached, each renderer must drive its OWN device. The old code took
/// `Set.first` from an unordered set, so the paddevice binding was a coin flip that could
/// point both renderers at the same pad.
func testPreferredIndexHonoursAnExplicitLocation() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0)
}
/// No preference (or one the pad no longer has): fall back to the LOWEST id arbitrary, but
/// stable across calls, which `Set.first` was not.
func testPreferredIndexFallsBackToTheLowestIdDeterministically() {
let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000]
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2)
// A wanted id that is gone (pad unplugged between enumeration and open) must not fail the
// open it degrades to the same stable fallback.
XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2)
}
/// A device IOKit reports no location for must never displace one it can place.
func testPreferredIndexSortsUnplaceableDevicesLast() {
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1)
XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0)
XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil))
}
}
#endif
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
renderer.apply(low: 0x8000, high: 0x8000)
Thread.sleep(forTimeInterval: 0.1)
+356 -10
View File
@@ -302,6 +302,21 @@ fn set_valve_hidapi(enabled: bool) {
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other
/// pre-`SDL_Init` hints, not after a subsystem is up.
///
/// The damage these drivers do happens at *enumeration*, which is part of initialising the
/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after
/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the
/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores
/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right
/// order; the caller-pumped path could not, because by the time it receives a
/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point
/// its callers can put in the right place.
pub fn preinit_disable_valve_hidapi() {
set_valve_hidapi(false);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T;
@@ -412,9 +427,12 @@ impl GamepadService {
/// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's
/// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback).
///
/// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their
/// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled
/// for the duration of an attached session only.
/// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only
/// place it happens**: the `subsystem` argument means enumeration is already done, and that
/// is when the Deck driver kills the trackpad-mouse. The caller must also call
/// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still
/// earns its place — it re-asserts "off" for a process that ran a session earlier — but on
/// its own it only detaches a driver that has already done the damage.
pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) {
set_valve_hidapi(false);
let pads = Arc::new(Mutex::new(Vec::new()));
@@ -609,6 +627,38 @@ impl GamepadPump {
self.worker.menu_poll();
self.worker.render_feedback();
}
/// Close every forwarded slot — flush its held wire state, tell the host to remove the pad,
/// and physically silence it. Call once on the way out of the caller's event loop.
///
/// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side
/// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens
/// when the pump next drains it. An exit path that detached and then left the loop without
/// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots
/// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing.
///
/// This closes the slots directly rather than draining the queued `Ctl::Detach` that would
/// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs
/// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock
/// would panic — during an unwind that aborts the process. Closing a slot touches no lock.
///
/// Idempotent, and safe with nothing attached.
pub fn shutdown(&mut self) {
self.worker.close_all_slots();
}
}
/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay
/// or present error — several paths do — and those would skip an explicit
/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out.
///
/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad
/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it.
/// Doing both is free — `shutdown` is idempotent.
impl Drop for GamepadPump {
fn drop(&mut self) {
self.shutdown();
}
}
/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held
@@ -682,13 +732,27 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
///
/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading
/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and
/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is
/// host-side and neither crate depends on the other, and a DualSense report layout has no business
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `1` relationship rather than
/// leaving it to a comment.
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
/// values so the subtraction is visible at the point of definition.
const REPORT_ID_LEN: usize = 1;
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
const LED_RGB: usize = 45 - Self::REPORT_ID_LEN;
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors
/// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary.
const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
@@ -698,7 +762,7 @@ impl Ds5Feedback {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
let n = effect.len().min(Self::TRIGGER_LEN);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
@@ -1160,6 +1224,7 @@ impl Worker {
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
// expected for an already-unplugged pad.
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
Self::reset_slot_feedback(&mut self.slots[i]);
if let Some(c) = self.attached.clone() {
Self::flush_slot(&c, &mut self.slots[i]);
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
@@ -1175,6 +1240,35 @@ impl Worker {
);
}
/// Hand the physical controller back in a neutral state before its handle closes.
///
/// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an
/// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive
/// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance
/// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but
/// another game. Apple's client already resets on teardown; this is the desktop half.
///
/// Best-effort throughout: the pad may already be gone (that is one of the ways we get here).
fn reset_slot_feedback(slot: &mut Slot) {
if matches!(
slot.pref,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
// An all-zero trigger block is mode 0x00 — no effect — which is what releases the
// trigger. Both sides, then the lightbar dark and the player indicator clear.
for which in [0u8, 1] {
let _ = slot
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11]));
}
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0));
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0));
} else {
// Anything else with an LED goes dark through SDL, which owns the per-device details.
let _ = slot.pad.set_led(0, 0, 0);
}
}
fn close_all_slots(&mut self) {
while !self.slots.is_empty() {
self.close_slot_at(0);
@@ -1837,7 +1931,12 @@ impl Worker {
let dur_ms: u32 = if (low, high) == (0, 0) {
100 // a stop takes effect immediately; the duration is irrelevant
} else {
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
// No local floor. There was a `.max(160)` here, and it could never do anything: the
// engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy
// value, so a non-zero command's backstop is never below 500. A floor that belongs to a
// particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine
// already applies — not re-invented per renderer where it can silently disagree.
backstop_ms
};
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
@@ -1892,6 +1991,11 @@ impl Worker {
HidOutput::PlayerLeds { bits, .. } if is_ds => {
let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits));
}
// Every other pad with player LEDs gets them through SDL, which owns the
// per-device pattern. This used to fall through and do nothing at all.
HidOutput::PlayerLeds { bits, .. } => {
let _ = set_player_leds(&slot.pad, bits);
}
HidOutput::Trigger {
which, ref effect, ..
} if is_ds => {
@@ -1899,12 +2003,43 @@ impl Worker {
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
_ => {}
// Deliberately unhandled, listed rather than left to a bare `_` so a new
// variant cannot join them silently: adaptive triggers exist only on a
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
// and carried by `send_effect` above when the pad is one.
HidOutput::Trigger { .. }
| HidOutput::TrackpadHaptic { .. }
| HidOutput::HidRaw { .. } => {}
}
}
}
}
/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player".
///
/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns
/// the per-device pattern. The count bridges them: every convention that reaches this wire spells
/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`)
/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based,
/// so player 1 is index 0; no lit LED means *no* player rather than player 0.
///
/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real
/// device, so nothing that takes one can be.
fn player_index_from_bits(bits: u8) -> Option<u16> {
match (bits & 0x1F).count_ones() {
0 => None,
n => Some((n - 1) as u16),
}
}
/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`.
fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> {
match player_index_from_bits(bits) {
None => pad.unset_player_index(),
Some(i) => pad.set_player_index(i),
}
}
/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`).
fn hidout_pad(h: &HidOutput) -> u8 {
match h {
@@ -2387,3 +2522,214 @@ mod slot_tests {
);
}
}
/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple
/// writer are all pinned by their own suites; this writer had nothing, despite being the one that
/// hand-shifts every offset by the report-id length.
#[cfg(test)]
mod ds5_feedback_tests {
use super::*;
/// The USB output report offsets, written out independently of the implementation. A DS5
/// effects payload is the same block with the leading report id removed, so every offset is
/// exactly one lower — this is the relationship the derived constants encode.
#[test]
fn ds5_offsets_track_the_usb_report() {
for (usb, payload) in [
(11usize, Ds5Feedback::RIGHT_TRIGGER),
(22, Ds5Feedback::LEFT_TRIGGER),
(44, Ds5Feedback::PAD_LIGHTS),
(45, Ds5Feedback::LED_RGB),
] {
assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}");
}
assert_eq!(Ds5Feedback::TRIGGER_LEN, 11);
}
#[test]
fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() {
let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33);
assert_eq!(p.len(), 47);
assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit");
assert_eq!(p[0], 0, "must not claim any valid_flag0 field");
assert_eq!(
(
p[Ds5Feedback::LED_RGB],
p[Ds5Feedback::LED_RGB + 1],
p[Ds5Feedback::LED_RGB + 2]
),
(0x11, 0x22, 0x33)
);
// Everything else stays zero — an over-broad packet would blank the triggers/player LEDs
// it never meant to touch.
let touched = [
1,
Ds5Feedback::LED_RGB,
Ds5Feedback::LED_RGB + 1,
Ds5Feedback::LED_RGB + 2,
];
assert!(p
.iter()
.enumerate()
.all(|(i, &b)| touched.contains(&i) || b == 0));
}
#[test]
fn player_leds_are_masked_to_five_bits() {
let p = Ds5Feedback::player_packet(0xFF);
assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit");
assert_eq!(
p[Ds5Feedback::PAD_LIGHTS],
0x1F,
"high bits are not ours to set"
);
let p = Ds5Feedback::player_packet(0b0000_0101);
assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101);
}
/// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the
/// pairing most likely to be transcribed backwards.
#[test]
fn trigger_which_selects_the_right_flag_and_offset() {
let eff: Vec<u8> = (1..=11).collect();
let r = Ds5Feedback::trigger_packet(1, &eff);
assert_eq!(r[0], 0x04, "valid_flag0 R2 bit");
assert_eq!(
&r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11],
&eff[..]
);
assert_eq!(
r[Ds5Feedback::LEFT_TRIGGER],
0,
"the other trigger is untouched"
);
let l = Ds5Feedback::trigger_packet(0, &eff);
assert_eq!(l[0], 0x08, "valid_flag0 L2 bit");
assert_eq!(
&l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11],
&eff[..]
);
assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0);
}
#[test]
fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() {
let long = vec![0xAAu8; 40];
let p = Ds5Feedback::trigger_packet(1, &long);
assert_eq!(p.len(), 47);
// Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on.
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA);
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0);
}
#[test]
fn a_short_effect_leaves_the_rest_of_the_block_zeroed() {
let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99);
assert!(
p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11]
.iter()
.all(|&b| b == 0)
);
}
/// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert
/// its enable bit, or the pad keeps whatever effect it was holding.
#[test]
fn an_empty_effect_is_a_release_not_a_no_op() {
let p = Ds5Feedback::trigger_packet(1, &[]);
assert_eq!(p[0], 0x04);
assert!(
p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
.iter()
.all(|&b| b == 0)
);
}
}
#[cfg(test)]
mod reset_packet_tests {
use super::*;
/// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this
/// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong
/// enable flag or a non-zero mode byte would silently leave the effect latched, which is the
/// bug this reset exists to prevent.
#[test]
fn reset_packets_release_the_triggers_and_darken_the_lights() {
// Trigger release: mode 0x00 with no parameters, on the side's own enable bit.
let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]);
assert_eq!(l[0], 0x08, "left-trigger enable bit");
assert!(
l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11]
.iter()
.all(|&b| b == 0),
"an all-zero block is mode 0x00 = no effect"
);
let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]);
assert_eq!(r[0], 0x04, "right-trigger enable bit");
assert!(
r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
.iter()
.all(|&b| b == 0)
);
// Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad
// ignores the payload and keeps the game's last colour.
let bar = Ds5Feedback::lightbar_packet(0, 0, 0);
assert_eq!(bar[1], 0x04, "lightbar enable bit");
assert_eq!(
&bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3],
&[0, 0, 0]
);
// Player indicator cleared.
let pl = Ds5Feedback::player_packet(0);
assert_eq!(pl[1], 0x10, "player-LED enable bit");
assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0);
}
}
#[cfg(test)]
mod player_led_tests {
use super::*;
/// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the
/// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is
/// otherwise only obvious once you have seen both patterns side by side.
#[test]
fn player_index_counts_lit_leds_for_both_conventions() {
// DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED.
assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1
assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2
assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3
assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4
assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5
// Switch/XInput style — a contiguous run of low bits, the same count each time.
assert_eq!(player_index_from_bits(0x01), Some(0));
assert_eq!(player_index_from_bits(0x03), Some(1));
assert_eq!(player_index_from_bits(0x07), Some(2));
assert_eq!(player_index_from_bits(0x0F), Some(3));
}
/// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit.
#[test]
fn no_lit_led_is_no_player() {
assert_eq!(player_index_from_bits(0x00), None);
// Only the low 5 bits are player LEDs; junk above them must not invent a player.
assert_eq!(player_index_from_bits(0xE0), None);
}
/// The mask is applied before counting, so out-of-range bits cannot inflate the index past
/// the 5 real LEDs.
#[test]
fn high_bits_are_masked_off_before_counting() {
assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8
assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top
}
}
+195 -16
View File
@@ -5,6 +5,20 @@
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
use punktfunk_core::quic::HidOutput;
use std::time::{Duration, Instant};
/// How often the latched rich state is re-emitted even though nothing changed.
///
/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is
/// forwarded exactly once, so if that datagram is dropped the game will never produce it again —
/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left
/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as
/// long as the game keeps that setting. For a trigger effect that can be the rest of a level.
///
/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update
/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four
/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms.
const RENEW_EVERY: Duration = Duration::from_millis(1000);
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
@@ -18,6 +32,9 @@ pub struct HidoutDedup {
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
/// there is nothing to renew. See [`RENEW_EVERY`].
last_sent: Option<Instant>,
}
impl HidoutDedup {
@@ -29,7 +46,53 @@ impl HidoutDedup {
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
///
/// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so
/// a plane the game is actively changing never pays for a renewal it does not need.
pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool {
let fwd = self.decide(h);
if fwd {
self.last_sent = Some(now);
}
fwd
}
/// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous
/// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything
/// last went out); every one is idempotent, so a client that DID receive the original simply
/// re-applies it.
///
/// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new*
/// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh
/// cadence (see the note in [`Self::decide`]).
pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec<HidOutput> {
if self
.last_sent
.is_none_or(|t| now.duration_since(t) < RENEW_EVERY)
{
return Vec::new();
}
self.last_sent = Some(now);
let mut out = Vec::new();
if let Some((r, g, b)) = self.led {
out.push(HidOutput::Led { pad, r, g, b });
}
if let Some(bits) = self.player_leds {
out.push(HidOutput::PlayerLeds { pad, bits });
}
for (which, effect) in self.trigger.iter().enumerate() {
if let Some(effect) = effect {
out.push(HidOutput::Trigger {
pad,
which: which as u8,
effect: effect.clone(),
});
}
}
out
}
fn decide(&mut self, h: &HidOutput) -> bool {
match h {
HidOutput::Led { r, g, b, .. } => {
let v = Some((*r, *g, *b));
@@ -77,6 +140,7 @@ mod tests {
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
#[test]
fn hidout_dedup_forwards_only_changes() {
let t = Instant::now();
let mut d = HidoutDedup::default();
let led = |r| HidOutput::Led {
pad: 0,
@@ -85,15 +149,15 @@ mod tests {
b: 0,
};
// First value forwards; an exact repeat is dropped; a change forwards again.
assert!(d.should_forward(&led(10)));
assert!(!d.should_forward(&led(10)));
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&led(10), t));
assert!(!d.should_forward(&led(10), t));
assert!(d.should_forward(&led(20), t));
// Player LEDs dedup on their own field, independent of the lightbar.
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
assert!(d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&pl(0b101)));
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
assert!(d.should_forward(&pl(0b101), t));
assert!(!d.should_forward(&pl(0b101), t));
assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
let trig = |which, byte| HidOutput::Trigger {
@@ -101,10 +165,10 @@ mod tests {
which,
effect: vec![byte, 0, 0],
};
assert!(d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1)));
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
assert!(d.should_forward(&trig(0, 1), t));
assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards
assert!(!d.should_forward(&trig(0, 1), t));
assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed
// One-shot haptic pulses are never deduped.
let haptic = HidOutput::TrackpadHaptic {
@@ -114,13 +178,128 @@ mod tests {
period: 2,
count: 3,
};
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic));
assert!(d.should_forward(&haptic, t));
assert!(d.should_forward(&haptic, t));
// `clear` re-arms every kind.
d.clear();
assert!(d.should_forward(&led(20)));
assert!(d.should_forward(&pl(0b101)));
assert!(d.should_forward(&trig(0, 2)));
assert!(d.should_forward(&led(20), t));
assert!(d.should_forward(&pl(0b101), t));
assert!(d.should_forward(&trig(0, 2), t));
}
/// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else
/// would ever carry it. The renewal is what repairs that.
#[test]
fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() {
let t = Instant::now();
let mut d = HidoutDedup::default();
let trig = HidOutput::Trigger {
pad: 3,
which: 1,
effect: vec![0x02, 0x90, 0xA0],
};
assert!(d.should_forward(&trig, t));
assert!(
!d.should_forward(&trig, t),
"the game re-sends it; the dedup swallows it"
);
// Nothing due yet.
assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty());
// Past the window: the latched state goes out again, addressed to the right pad.
let out = d.renewals(3, t + Duration::from_millis(1000));
assert_eq!(out.len(), 1);
assert!(matches!(
&out[0],
HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0]
));
// And it keeps repairing on the same cadence, not just once.
assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty());
assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1);
}
/// Every latched plane is renewed together, and a plane the game is actively driving does not
/// pay for renewals it does not need (a forward resets the clock).
#[test]
fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() {
let t = Instant::now();
let mut d = HidoutDedup::default();
assert!(d.should_forward(
&HidOutput::Led {
pad: 0,
r: 9,
g: 8,
b: 7
},
t
));
assert!(d.should_forward(
&HidOutput::PlayerLeds {
pad: 0,
bits: 0b100
},
t
));
assert!(d.should_forward(
&HidOutput::Trigger {
pad: 0,
which: 0,
effect: vec![1]
},
t
));
assert!(d.should_forward(
&HidOutput::Trigger {
pad: 0,
which: 1,
effect: vec![2]
},
t
));
let out = d.renewals(0, t + Duration::from_millis(1000));
assert_eq!(
out.len(),
4,
"lightbar + player LEDs + both triggers, got {out:?}"
);
// A genuine change re-stamps the clock, so the next renewal is a full window away.
let later = t + Duration::from_millis(1500);
assert!(d.should_forward(
&HidOutput::Led {
pad: 0,
r: 1,
g: 2,
b: 3
},
later
));
assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty());
assert!(!d
.renewals(0, later + Duration::from_millis(1000))
.is_empty());
}
/// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair".
#[test]
fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() {
let t = Instant::now();
let mut d = HidoutDedup::default();
assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty());
let pulse = HidOutput::TrackpadHaptic {
pad: 0,
side: 0,
amplitude: 1,
period: 2,
count: 3,
};
assert!(d.should_forward(&pulse, t));
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
}
}
+5 -21
View File
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::RichInput;
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
DS4_TOUCH_W, DS4_VENDOR,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
0xB1, 0x02, 0xC0,
];
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
pub struct DualShock4Pad {
+195 -27
View File
@@ -254,13 +254,45 @@ fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<
Ok(())
}
/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble.
#[derive(Clone, Copy)]
struct Playback {
/// When the effect starts contributing — `play + replay.delay`. Until then it is armed but
/// silent, which is the whole point of the delay.
starts: Instant,
/// When it stops, or `None` for replay length 0 (until explicitly stopped).
ends: Option<Instant>,
}
/// One FF effect a game uploaded: rumble magnitudes + playback state.
struct Effect {
strong: u16,
weak: u16,
/// `Some(deadline)` while playing (replay length 0 = until stopped).
playing: Option<Option<Instant>>,
/// `Some(window)` while playing.
playing: Option<Playback>,
replay_ms: u16,
/// `replay.delay` — how long after the play command the effect stays silent. Decoded from the
/// upload since forever and, until now, never acted on: the effect started immediately and
/// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under
/// Wine does this routinely) fired early AND finished early by the same amount.
delay_ms: u16,
}
impl Effect {
/// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of
/// rumble (or until stopped, when the length is 0).
///
/// `replay.length` is measured from the END of the delay, not from the play command, so the
/// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler
/// purely so this is testable — the handler itself needs a live uinput fd.
fn window(&self, at: Instant) -> Playback {
let starts = at + Duration::from_millis(self.delay_ms as u64);
Playback {
starts,
ends: (self.replay_ms > 0)
.then(|| starts + Duration::from_millis(self.replay_ms as u64)),
}
}
}
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
@@ -268,7 +300,6 @@ struct Effect {
/// the policy is pure and unit-testable without a live uinput fd.
struct FfState {
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
/// Last `(low, high)` reported, to dedup.
last_mix: (u16, u16),
@@ -284,7 +315,6 @@ impl FfState {
fn new() -> FfState {
FfState {
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
last_activity: Instant::now(),
@@ -299,17 +329,29 @@ impl FfState {
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d);
let plane_stale = quiet_since(self.last_activity);
let (mut strong, mut weak) = (0u32, 0u32);
for e in self.effects.values_mut() {
let Some(deadline) = e.playing else { continue };
match deadline {
let Some(p) = e.playing else { continue };
// Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the
// abandoned-effect force-off — it has not had its turn yet.
if now < p.starts {
continue;
}
match p.ends {
Some(d) if now >= d => e.playing = None,
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
None if stale => {
//
// "Abandoned" needs the effect to have been AUDIBLE for the window too, not just
// the plane quiet: the play command is itself the last activity, so an effect with
// a `replay.delay` longer than the window would otherwise be force-stopped the
// instant it finally started — silent the whole time it waited, then killed on its
// first contributing tick.
None if plane_stale && quiet_since(p.starts) => {
tracing::info!(
strong = e.strong,
weak = e.weak,
@@ -531,11 +573,13 @@ impl VirtualPad {
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
up.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
let mut e = up.effect;
if e.id == -1 {
e.id = self.ff.next_effect_id;
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
}
let e = up.effect;
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
// writes it into the effect BEFORE handing the request to uinput, so what
// arrives here is always an assigned id. The fallback that used to allocate
// one from a local counter could therefore never run, and a local counter is
// the wrong answer anyway — the kernel owns that id space.
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
if e.type_ == FF_RUMBLE {
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
@@ -544,10 +588,12 @@ impl VirtualPad {
weak: 0,
playing: None,
replay_ms: 0,
delay_ms: 0,
});
slot.strong = strong;
slot.weak = weak;
slot.replay_ms = e.replay_length;
slot.delay_ms = e.replay_delay;
}
up.effect.id = e.id; // hand the assigned slot back to the kernel
up.retval = 0;
@@ -574,14 +620,7 @@ impl VirtualPad {
(EV_FF, code) => {
self.ff.note_activity();
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
e.playing = if ev.value != 0 {
Some((e.replay_ms > 0).then(|| {
Instant::now()
+ std::time::Duration::from_millis(e.replay_ms as u64)
}))
} else {
None
};
e.playing = (ev.value != 0).then(|| e.window(Instant::now()));
}
}
_ => {}
@@ -802,15 +841,34 @@ mod ff_state_tests {
ff
}
/// Playing from `at`, no delay, until explicitly stopped.
fn playing(at: Instant) -> Option<Playback> {
Some(Playback {
starts: at,
ends: None,
})
}
/// Playing from `at`, no delay, for `len`.
fn playing_for(at: Instant, len: Duration) -> Option<Playback> {
Some(Playback {
starts: at,
ends: Some(at + len),
})
}
#[test]
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
let now = Instant::now();
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
// Playing since before the window: "abandoned" means audible AND unattended, so an
// effect that only just started is not a candidate however stale the plane is.
playing: playing(now - Duration::from_millis(2600)),
replay_ms: 0,
delay_ms: 0,
});
let now = Instant::now();
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
// The game goes silent on the FF plane past the idle window: cut, exactly once.
@@ -825,8 +883,9 @@ mod ff_state_tests {
let mut ff = ff_with(Effect {
strong: 0x4000,
weak: 0,
playing: Some(Some(now + Duration::from_secs(10))),
playing: playing_for(now, Duration::from_secs(10)),
replay_ms: 10_000,
delay_ms: 0,
});
// FF plane long stale, but the effect declared a finite replay — the declared duration is
// the contract (a real pad honors it too), so it keeps playing…
@@ -842,26 +901,135 @@ mod ff_state_tests {
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
playing: playing(now - Duration::from_millis(3000)),
replay_ms: 0,
delay_ms: 0,
});
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
ff.last_activity = now - Duration::from_millis(3000);
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
ff.last_activity = now;
ff.effects.get_mut(&0).unwrap().playing = Some(None);
ff.effects.get_mut(&0).unwrap().playing = playing(now);
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
}
/// `replay.delay` shifts the whole window: silent until it elapses, then the FULL
/// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both
/// started early and finished early — DirectInput under Wine schedules these routinely.
#[test]
fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() {
let now = Instant::now();
let starts = now + Duration::from_millis(500);
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(Playback {
starts,
ends: Some(starts + Duration::from_secs(1)),
}),
replay_ms: 1000,
delay_ms: 500,
});
// Inside the delay: armed but silent.
assert_eq!(ff.mix(now, IDLE), None);
assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None);
// Delay elapsed: it plays.
assert_eq!(
ff.mix(now + Duration::from_millis(501), IDLE),
Some((scaled(0x8000), 0))
);
// Still playing at 1400 ms — it gets its full second FROM the delay, not from the play.
assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None);
// And ends at delay + length, not at length.
assert_eq!(
ff.mix(now + Duration::from_millis(1600), IDLE),
Some((0, 0))
);
}
/// The window a play opens, straight from the uploaded fields — this is the half that reads
/// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a
/// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely.
#[test]
fn window_offsets_the_whole_playback_by_replay_delay() {
let at = Instant::now();
let delayed = Effect {
strong: 0,
weak: 0,
playing: None,
replay_ms: 1000,
delay_ms: 500,
};
let w = delayed.window(at);
assert_eq!(
w.starts,
at + Duration::from_millis(500),
"delay defers the start"
);
assert_eq!(
w.ends,
Some(at + Duration::from_millis(1500)),
"length runs from the END of the delay, so the effect keeps its full second"
);
// No delay: starts immediately, unchanged from before.
let plain = Effect {
strong: 0,
weak: 0,
playing: None,
replay_ms: 1000,
delay_ms: 0,
};
let w = plain.window(at);
assert_eq!(w.starts, at);
assert_eq!(w.ends, Some(at + Duration::from_millis(1000)));
// Length 0 = until stopped, but the delay still applies.
let infinite = Effect {
strong: 0,
weak: 0,
playing: None,
replay_ms: 0,
delay_ms: 250,
};
let w = infinite.window(at);
assert_eq!(w.starts, at + Duration::from_millis(250));
assert_eq!(w.ends, None);
}
/// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has
/// not had its turn, and the idle window is shorter than a delay can legitimately be.
#[test]
fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() {
let now = Instant::now();
let starts = now + Duration::from_secs(5);
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(Playback { starts, ends: None }),
replay_ms: 0,
delay_ms: 5000,
});
ff.last_activity = now - Duration::from_secs(60); // long stale
assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut
// It still plays when its delay elapses.
assert_eq!(
ff.mix(now + Duration::from_millis(5001), IDLE),
Some((scaled(0x8000), 0))
);
}
#[test]
fn disabled_watchdog_never_cuts() {
let now = Instant::now();
let mut ff = ff_with(Effect {
strong: 0x8000,
weak: 0,
playing: Some(None),
playing: playing(now),
replay_ms: 0,
delay_ms: 0,
});
ff.last_activity = now - Duration::from_secs(600);
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
@@ -23,6 +23,11 @@ use super::steam_proto::{
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::uhid_abi::{
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::RichInput;
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
// /dev/uhid event ABI — same layout as the DualSense backend.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
/// ~450 ms continuous; give margin).
const MODE_ENTER: Duration = Duration::from_millis(650);
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
}
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
/// per-pad `b9.6` pulse + guard handle it instead.
@@ -214,10 +200,13 @@ impl SteamDeckPad {
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
}
UHID_SET_REPORT => {
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
let end = (12 + 16).min(UHID_EVENT_SIZE);
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
let id = request_id(&ev);
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
// declared — this used to read a fixed 16-byte window, which truncated any
// longer report and, for a shorter one, fed the parser whatever the reused
// event buffer still held past the payload. Every sibling backend that parses
// SET_REPORT already read the size field; this one didn't.
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
rumble = Some(r);
}
let _ = self.reply_set_report(id);
@@ -23,6 +23,11 @@ use super::triton_proto::{
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
TRITON_WIRED_PRODUCT,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
}
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
pub struct TritonPad {
fd: File,
@@ -22,6 +22,10 @@ use super::switch_proto::{
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
/// tears down the bound `hid-nintendo` interface).
pub struct SwitchProPad {
@@ -0,0 +1,143 @@
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
//!
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
//! [`set_report_data`]).
//!
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
//! subtly wrong.
/// The character device every backend opens.
pub const UHID_PATH: &str = "/dev/uhid";
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
pub const UHID_DESTROY: u32 = 1;
pub const UHID_OUTPUT: u32 = 6;
pub const UHID_GET_REPORT: u32 = 9;
pub const UHID_GET_REPORT_REPLY: u32 = 10;
pub const UHID_CREATE2: u32 = 11;
pub const UHID_INPUT2: u32 = 12;
pub const UHID_SET_REPORT: u32 = 13;
pub const UHID_SET_REPORT_REPLY: u32 = 14;
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
/// `BUS_USB` from `linux/input.h`.
pub const BUS_USB: u16 = 0x03;
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
const OFF_ID: usize = 4;
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
const OFF_SET_REPORT_SIZE: usize = 10;
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
const OFF_DATA: usize = 12;
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
/// truncation still leaves a NUL terminator.
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
pub fn request_id(ev: &[u8]) -> u32 {
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
}
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
///
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
/// report means acting on numbers the game never wrote.
pub fn set_report_data(ev: &[u8]) -> &[u8] {
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
&ev[OFF_DATA.min(end)..end]
}
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
pub fn output_data(ev: &[u8]) -> &[u8] {
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
&ev[4.min(end)..end]
}
#[cfg(test)]
mod tests {
use super::*;
fn blank() -> Vec<u8> {
vec![0u8; UHID_EVENT_SIZE]
}
#[test]
fn set_report_data_honours_the_events_own_size() {
let mut ev = blank();
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
ev[OFF_DATA + i] = *b;
}
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
ev[OFF_DATA + 5] = 0xAA;
ev[OFF_DATA + 15] = 0xBB;
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
}
#[test]
fn set_report_data_is_not_truncated_at_sixteen() {
let mut ev = blank();
let n = 40usize;
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
for i in 0..n {
ev[OFF_DATA + i] = i as u8;
}
let d = set_report_data(&ev);
assert_eq!(
d.len(),
n,
"a report longer than 16 bytes must survive whole"
);
assert_eq!(d[39], 39);
}
#[test]
fn oversized_and_empty_sizes_stay_in_bounds() {
let mut ev = blank();
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
let ev0 = blank(); // size = 0
assert!(set_report_data(&ev0).is_empty());
assert!(output_data(&ev0).is_empty());
}
#[test]
fn output_data_reads_its_trailing_size_field() {
let mut ev = blank();
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
ev[4] = 0x02;
ev[5] = 0x11;
ev[6] = 0x22;
ev[7] = 0x33; // past the declared size
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
}
#[test]
fn request_id_round_trips() {
let mut ev = blank();
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
}
}
@@ -250,11 +250,19 @@ impl DsState {
use punktfunk_core::input::gamepad as gs;
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
let on = |bit: u32| buttons & bit != 0;
// Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`.
// 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below
// it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick
// one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use.
// Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent
// sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by
// construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is
// that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel.
let mut s = DsState {
lx: to_u8(lx),
ly: 255 - to_u8(ly),
ly: to_u8(ly.saturating_neg()),
rx: to_u8(rx),
ry: 255 - to_u8(ry),
ry: to_u8(ry.saturating_neg()),
l2: lt,
r2: rt,
..DsState::neutral()
@@ -471,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
/// `(low, high)` motor levels, if a report carried them.
///
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
/// the type that sees both.
pub rumble: Option<(u16, u16)>,
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
@@ -479,64 +494,101 @@ pub struct DsFeedback {
pub resync: bool,
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
/// down; index off these rather than repeating the numbers.
///
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
/// lays out one common block; what changes is how much header precedes it:
///
/// | base | where | first payload byte |
/// |---|---|---|
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
/// | `1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
///
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
pub mod out_report {
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
pub const VALID_FLAG0: usize = 1;
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
pub const VALID_FLAG1: usize = 2;
/// High-frequency (small / right) motor.
pub const MOTOR_RIGHT: usize = 3;
/// Low-frequency (big / left) motor.
pub const MOTOR_LEFT: usize = 4;
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
pub const RIGHT_TRIGGER: usize = 11;
/// First byte of the LEFT trigger's parameter block.
pub const LEFT_TRIGGER: usize = 22;
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
pub const TRIGGER_LEN: usize = 11;
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
pub const VALID_FLAG2: usize = 39;
/// Lit player-indicator bits (low 5).
pub const PLAYER_LEDS: usize = 44;
/// Lightbar red; green and blue follow.
pub const LED_RGB: usize = 45;
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
/// surfaced — adaptive-trigger blocks are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
/// broadcast.
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
use out_report as o;
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
// that never read the version, stay on flag0. Both conventions must land here: a
// rumble dropped on either — including stops — is silently ignored, and a missed
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
// forever).
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
// kernel and SDL write the v2 flag — while older writers, and any that never read the
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
// the session (the 500 ms refresh re-sends stale state forever).
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
let low = (data[o::MOTOR_LEFT] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
bits: data[o::PLAYER_LEDS] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
});
}
}
@@ -783,6 +835,29 @@ mod tests {
assert_eq!(r[53], 0x0A);
}
/// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised
/// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent
/// sub-deadzone tilt. Extremes must stay exact either way.
#[test]
fn centred_sticks_encode_as_neutral_on_every_axis() {
let n = DsState::neutral();
let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre");
assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre");
// Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact.
let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0);
assert_eq!((up.ly, up.ry), (0, 0), "full up = 0");
let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0);
assert_eq!((down.ly, down.ry), (255, 255), "full down = 255");
// X keeps its existing mapping.
let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0);
assert_eq!((right.lx, right.rx), (255, 255));
let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0);
assert_eq!((left.lx, left.rx), (0, 0));
}
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
/// `buttons[2]`.
#[test]
@@ -183,8 +183,9 @@ impl SteamState {
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
/// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the
/// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately
/// ([`apply_rich`], the M3 wire).
pub fn from_gamepad(
buttons: u32,
lx: i16,
@@ -200,8 +201,8 @@ impl SteamState {
ly,
rx,
ry,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
lt: trigger_u16(lt),
rt: trigger_u16(rt),
..SteamState::neutral()
};
let mut b = 0u64;
@@ -375,8 +376,8 @@ pub fn sc_from_gamepad(
ly,
rx: 0,
ry: 0,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
lt: trigger_u16(lt),
rt: trigger_u16(rt),
// The wire right stick becomes a right-pad contact (see the doc above).
rpad_x: rx,
rpad_y: ry,
@@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
}
/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`).
///
/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top
/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a
/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic.
///
/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both
/// ends against this: `32767 >> 7 == 255`.
fn trigger_u16(v: u8) -> u16 {
((v as u32 * 32767) / 255) as u16
}
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
@@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
let mut buf = [0u8; STEAM_REPORT_LEN];
let bytes = serial.as_bytes();
let len = bytes.len().clamp(1, 21);
// `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a
// zero-byte slice for one byte, which panics — on the service thread, for an input the kernel
// already has a graceful answer to. Reporting the true length lets its own validation
// (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the
// documented behaviour for a reply it does not like.
let len = bytes.len().min(21);
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
buf[1] = ID_GET_STRING_ATTRIBUTE;
buf[2] = len as u8;
@@ -704,7 +722,7 @@ mod tests {
assert_ne!(s.buttons & btn::STEAM, 0);
assert_ne!(s.buttons & btn::LB, 0);
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
assert_eq!(s.lt, 255 * 128);
assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range
assert_eq!(s.lx, 1000);
assert_eq!(s.ly, -2000);
@@ -730,6 +748,30 @@ mod tests {
assert_eq!(s.accel, [16384, -8192, 0]);
}
/// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which
/// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by
/// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer.
#[test]
fn empty_serial_reply_does_not_panic() {
let r = serial_reply("");
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE);
assert_eq!(
r[2], 0,
"length the kernel will reject, rather than a panic"
);
// Normal and over-long serials still behave.
let r = serial_reply("ABC123");
assert_eq!(r[2], 6);
assert_eq!(&r[4..10], b"ABC123");
let long = "X".repeat(40);
assert_eq!(
serial_reply(&long)[2],
21,
"clamped to the protocol maximum"
);
}
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
+30 -2
View File
@@ -18,7 +18,12 @@ 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 (0..=0xFF00), if the pass saw a rumble report.
/// `(low, high)` 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)>,
pub hidout: Vec<HidOutput>,
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
@@ -159,6 +164,22 @@ impl OverflowWarn {
/// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive
/// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a
/// rumble write, so its any-activity keying is already rumble-keyed by construction).
///
/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game
/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one
/// output report when an effect starts and one when it stops, with nothing in between, so a finite
/// effect longer than this window is cut in half here. The uinput path
/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an
/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor
/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all
/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a
/// long finite effect and letting an abandoned residual drone forever, and the residual is the one
/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is
/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's
/// HD-rumble decays faster than this window regardless.
///
/// Do not "fix" this by widening or disabling the window without evidence about which failure real
/// titles actually hit; the hatch below exists for exactly that experiment.
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
/// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides
@@ -338,10 +359,17 @@ impl<B: PadProto> UhidManager<B> {
for h in fb.hidout {
// Skip rich feedback that repeats the last-forwarded value (a game's output report
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
if self.hidout_dedup[i].should_forward(&h) {
if self.hidout_dedup[i].should_forward(&h, now) {
hidout(h);
}
}
// Re-assert the latched rich state on a slow cadence. Deduping a plane that rides
// unreliable datagrams means a dropped update is never re-derived from the game — it
// keeps sending the same value and the dedup eats every copy — so without this one
// lost datagram leaves the pad on the previous weapon's trigger effect indefinitely.
for h in self.hidout_dedup[i].renewals(i as u8, now) {
hidout(h);
}
}
}
@@ -819,46 +819,77 @@ impl DriverAttach {
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
///
/// Runs on its own thread and returns immediately. The caller is the session's pad service
/// thread — the one feeding input and rumble — and everything below is slow: the driver-store
/// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of
/// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for
/// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the
/// enumeration is still outstanding every pad pays it again), at exactly the moment a session
/// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose.
///
/// Off the hot path the wait also stops being a compromise — it can afford to be patient and
/// report what it actually found rather than "still enumerating".
fn diagnose(&self) {
let store = match driver_store_has(self.inf) {
Some(true) => "driver package present in the driver store",
Some(false) => {
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
}
None => "driver store could not be queried (pnputil failed or still enumerating)",
};
let devnode = match &self.instance_id {
Some(id) => devnode_status_line(id),
None => {
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
.to_string()
}
};
tracing::warn!(
driver = self.driver,
shm = %self.shm_name,
grace_secs = ATTACH_GRACE.as_secs(),
store,
devnode = %devnode,
driver_log = self.driver_log,
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
);
let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log);
let shm_name = self.shm_name.clone();
let instance_id = self.instance_id.clone();
std::thread::Builder::new()
.name("pf-driver-diagnose".into())
.spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id))
.ok();
}
}
/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query
/// before reporting without it — [`observe`] runs on the pad service thread, which must keep
/// draining pad slots even when the driver store is wedged.
const INVENTORY_WAIT: Duration = Duration::from_secs(2);
/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into
/// the closure so the blocking calls stay visible as blocking.
fn diagnose_blocking(
driver: &'static str,
inf: &'static str,
driver_log: &'static str,
shm_name: &str,
instance_id: Option<String>,
) {
let store = match driver_store_has(inf) {
Some(true) => "driver package present in the driver store",
Some(false) => {
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
}
None => "driver store could not be queried (pnputil failed or still enumerating)",
};
let devnode = match &instance_id {
Some(id) => devnode_status_line(id),
None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
.to_string(),
};
tracing::warn!(
driver,
shm = %shm_name,
grace_secs = ATTACH_GRACE.as_secs(),
store,
devnode = %devnode,
driver_log,
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)"
);
}
/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting
/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is
/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and
/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread
/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer
/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line.
const INVENTORY_WAIT: Duration = Duration::from_secs(30);
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
/// consulted on the failure path, so the subprocess cost never hits a healthy session. The query
/// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store,
/// and the caller is the pad service thread. `None` = not available yet (query still running) or
/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query
/// still running past [`INVENTORY_WAIT`]) or
/// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports.
fn driver_store_inventory() -> Option<&'static str> {
static INV: OnceLock<String> = OnceLock::new();
+5
View File
@@ -457,6 +457,11 @@ pub mod triton_proto;
#[cfg(target_os = "linux")]
#[path = "inject/linux/triton_usbip.rs"]
pub mod triton_usbip;
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
#[cfg(target_os = "linux")]
#[path = "inject/linux/uhid_abi.rs"]
pub mod uhid_abi;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
+14
View File
@@ -466,6 +466,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
#[cfg(windows)]
crate::win32::set_app_user_model_id();
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's
// digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a
// hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped —
// only detached a driver that had already killed the built-in trackpad-mouse system-wide. The
// symptom was the Deck losing its trackpad cursor at the start of every session until the
// firmware watchdog restored lizard mode. They are still enabled for an attached session.
pf_client_core::gamepad::preinit_disable_valve_hidapi();
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
@@ -1895,6 +1902,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
};
// Every exit from the loop above converges here, which is why the gamepad teardown belongs
// here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the
// close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when
// the pump drains it. Single mode broke out of the loop immediately after detaching and
// Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game
// was rumbling at the time, still buzzing.
pump.shutdown();
// Join the pump BEFORE the device-wide idle: its decode submissions on the shared
// device would race vkDeviceWaitIdle otherwise.
if let Some(st) = stream.take() {
@@ -1764,6 +1764,11 @@ impl VirtualDisplayManager {
if let Some(saved) = inner.group.ccd_saved.take() {
restore_displays_ccd(&saved);
}
// Drop the isolate's crash-recovery marker even when there was no snapshot to restore
// (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd`
// — which clears it itself — then never runs). The group is gone either way, so no
// future host start owes this desk a force-EXTEND.
pf_win_display::win_display::isolate_journal::clear();
// EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason
// `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and
// the isolate can return `None` (its `query_active_config` failed). Nested inside that
+201
View File
@@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec<TargetInventory> {
out
}
/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what
/// a *dead* one did.
///
/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate
/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That
/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped
/// mid-session never restores it. Windows does not restore it either — the isolated topology is
/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout
/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever
/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch`
/// or a reboot).
///
/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the
/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker
/// survived.
///
/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids
/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the
/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very
/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates
/// every connected display from the OS's own database, needs no struct serialization, and stays
/// correct across a reboot — where saved target ids would be stale anyway.
pub mod isolate_journal {
use std::sync::Mutex;
/// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the
/// file every couple of seconds. `None` = "no marker known to be on disk".
static LAST: Mutex<Option<Vec<u32>>> = Mutex::new(None);
fn path() -> std::path::PathBuf {
pf_paths::config_dir().join("display-isolate-active.json")
}
/// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate.
/// Best-effort: a journal we cannot write costs crash recovery, not the session.
pub fn mark(deactivated: &[u32]) {
if deactivated.is_empty() {
return; // nothing was deactivated ⇒ nothing for a later host to put back
}
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
if last.as_deref() == Some(deactivated) {
return;
}
let p = path();
if let Some(dir) = p.parent() {
let _ = pf_paths::create_private_dir(dir);
}
match std::fs::write(
&p,
serde_json::to_vec_pretty(deactivated).unwrap_or_default(),
) {
Ok(()) => *last = Some(deactivated.to_vec()),
Err(e) => tracing::warn!(
error = %e,
"display isolate: could not write the crash-recovery journal — if this host dies \
mid-session the deactivated panels will stay dark"
),
}
}
/// The isolate is over (restored, or there was nothing to restore) — drop the marker.
/// Idempotent; safe to call when no marker exists.
pub fn clear() {
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
let _ = std::fs::remove_file(path());
*last = None;
}
/// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its
/// physical displays are still deactivated. Re-light them with the EXTEND preset.
///
/// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker
/// rather than on "is anything active", so a legitimately headless host is never forced awake.
pub fn startup_recover() {
let Some(targets) = pending() else {
return;
};
tracing::warn!(
deactivated = ?targets,
"display isolate: a previous host exited with the operator's display(s) deactivated for \
an EXCLUSIVE session and never restored them forcing the EXTEND preset so the desk is \
not left dark"
);
super::force_extend_topology();
clear();
}
/// The marker a previous host left behind, if any (its deactivated target ids) — the *decision*
/// half of [`startup_recover`], split out so the recovery rule is testable without driving a
/// real `SetDisplayConfig` against the machine running the test.
pub fn pending() -> Option<Vec<u32>> {
let bytes = std::fs::read(path()).ok()?;
Some(serde_json::from_slice(&bytes).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
/// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both
/// process-global, so these cases must not interleave.
static ENV: Mutex<()> = Mutex::new(());
/// Point the journal at a scratch dir for the duration of one case.
fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) {
let _g = ENV.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
clear(); // reset the LAST cache + any leftover marker from a previous run
f(&dir);
clear();
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
}
/// The crash path: a host marks what it switched off and dies. The next start must see the
/// marker (and which targets), which is what makes it force the desk back on.
#[test]
fn a_mark_survives_for_the_next_host_and_clear_retracts_it() {
with_temp_dir("roundtrip", |_| {
assert_eq!(pending(), None, "a clean box owes no recovery");
mark(&[101, 202]);
assert_eq!(
pending(),
Some(vec![101, 202]),
"a crashed host's marker must be readable by the next start"
);
clear();
assert_eq!(pending(), None, "a clean teardown retracts the marker");
});
}
/// An isolate that deactivated nothing (single-display box: the virtual output is already
/// the only head) owes the next start no force-EXTEND — marking there would re-arrange a
/// desk we never touched.
#[test]
fn deactivating_nothing_writes_no_marker() {
with_temp_dir("empty", |_| {
mark(&[]);
assert_eq!(pending(), None);
});
}
/// The re-assert watchdog re-isolates every couple of seconds while something fights it;
/// that must not mean a disk write per cycle.
#[test]
fn repeating_the_same_mark_does_not_rewrite_the_file() {
with_temp_dir("cached", |dir| {
let file = dir.join("display-isolate-active.json");
mark(&[7]);
// Overwrite behind the journal's back rather than comparing mtimes — a filesystem
// whose timestamp resolution is coarser than two back-to-back writes would let an
// mtime assertion pass without proving anything.
std::fs::write(&file, b"SENTINEL").unwrap();
mark(&[7]);
assert_eq!(
std::fs::read(&file).unwrap(),
b"SENTINEL",
"an unchanged mark must not rewrite the journal"
);
// A CHANGED set still lands — the group grew/shrank and recovery must follow it.
mark(&[7, 8]);
assert_eq!(pending(), Some(vec![7, 8]));
});
}
/// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the
/// signal ("a host left displays off"), its contents are only diagnostics.
#[test]
fn an_unparseable_marker_still_asks_for_recovery() {
with_temp_dir("corrupt", |dir| {
std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap();
assert_eq!(pending(), Some(Vec::new()));
});
}
}
}
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
@@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
return Some(saved);
}
// Journal what we are about to switch off BEFORE the first apply, not after a verified one: the
// window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set
// (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown
// owes the operator back. See `isolate_journal`.
let doomed: Vec<u32> = saved
.0
.iter()
.map(|p| p.targetInfo.id)
.filter(|id| !keep_target_ids.contains(id))
.collect();
isolate_journal::mark(&doomed);
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
@@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex<Vec<(u32, String)>> = std::sync::Mute
/// removed), re-activating the displays we deactivated.
// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
pub fn restore_displays_ccd(saved: &SavedConfig) {
restore_displays_ccd_inner(saved);
// Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run,
// never before: a host that dies part-way through the restore must still leave the marker
// behind so the next start re-lights the desk. `_inner` has several early returns, which is
// why this wraps rather than trailing the body.
isolate_journal::clear();
}
fn restore_displays_ccd_inner(saved: &SavedConfig) {
let (paths, modes) = saved;
if paths.is_empty() {
return;
+161
View File
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
# note above says, a clashing #define silently takes the last definition rather than failing to
# compile. The table above had been doing this by hand for the handful someone noticed; this is
# the rest of them, so the stated rule finally holds for the whole surface.
#
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
# namespaced, just not by us.
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
"PRESETS" = "PUNKTFUNK_PRESETS"
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
[enum]
+9 -4
View File
@@ -698,7 +698,10 @@ pub struct PunktfunkHidOutput {
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
pub effect_len: u8,
/// Trigger: the raw DualSense trigger parameter block (mode + params).
pub effect: [u8; 11],
/// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
/// exported precisely so embedders can size their own buffers against it, and it declaring one
/// number while the struct it describes hardcoded another was the whole hazard.
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
}
#[cfg(feature = "quic")]
@@ -2497,10 +2500,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
/// 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
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
/// actuator.
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
@@ -60,22 +60,28 @@ pub(super) async fn run(
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// A pad index the client cannot represent is dropped outright, before either
// consumer sees it. It used to be waved through: the seq gate was skipped (its
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
// while the policy engine silently discarded it on its own bounds check — so
// "both consumers are fed" below was false for exactly these, and an embedder
// draining the queue could be handed an index it would use to subscript its
// own per-pad array. The host never emits one; this is malformed or hostile.
let idx = u.pad as usize;
if idx >= crate::input::MAX_PADS {
continue;
}
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
true // out-of-range pad (host never sends these): no gate
false // reordered/duplicate — drop, keep the newer state
}
}
None => true,
+237 -31
View File
@@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000;
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
const BACKSTOP_LEGACY_MS: u32 = 2000;
/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of
/// the host's own `RUMBLE_TTL_CEIL_MS`.
///
/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to
/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or
/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection
/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple,
/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose
/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL,
/// Android) already self-terminate at the clamped backstop.
///
/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is
/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the
/// 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
@@ -53,10 +69,25 @@ pub struct RumbleCommand {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ActuatorQuirks {
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
/// `0` = no keepalive (the common case).
/// output decays between wire renewals. `0` = no keepalive (the common case).
///
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
/// command carrying the same levels, and that renderer skips a HID write whose levels are
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
pub keepalive_ms: u16,
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
/// Floor for `backstop_ms` on non-zero commands.
///
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
/// rejects short values. The case it was written for is handled elsewhere: Android's
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
/// duration itself at the call, and that path never declares quirks at all. Kept because it
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
pub min_pulse_ms: u16,
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
@@ -75,8 +106,11 @@ struct PadState {
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
dirty: bool,
next_keepalive: Option<Instant>,
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
jitter: bool,
/// 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),
quirks: ActuatorQuirks,
}
@@ -88,7 +122,7 @@ impl PadState {
legacy_wire: None,
dirty: false,
next_keepalive: None,
jitter: false,
last_emit: (0, 0),
quirks: ActuatorQuirks {
keepalive_ms: 0,
min_pulse_ms: 0,
@@ -112,6 +146,7 @@ impl PadState {
self.legacy_wire = None;
self.next_keepalive = None;
self.dirty = false;
self.last_emit = (0, 0);
RumbleCommand {
pad,
low: 0,
@@ -119,6 +154,40 @@ impl PadState {
backstop_ms: 0,
}
}
/// Build the command for the pad's current level, and record what we handed out.
///
/// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on
/// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than
/// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived
/// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms
/// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with
/// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the
/// 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`.
/// 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.
fn emit(&mut self, pad: u16) -> RumbleCommand {
let (mut low, high) = self.level;
if self.quirks.dedup_jitter && (low, high) == self.last_emit {
let alt = low ^ 1;
low = if (alt, high) == (0, 0) {
low | 0b10
} else {
alt
};
}
self.last_emit = (low, high);
RumbleCommand {
pad,
low,
high,
backstop_ms: self.backstop(),
}
}
}
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
@@ -156,6 +225,8 @@ impl RumbleEngine {
p.dirty = true;
match ttl_ms {
Some(t) => {
// Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims.
let t = t.min(MAX_LEASE_MS);
p.ttl_ms = t;
p.legacy_wire = None;
p.deadline = if (low, high) != (0, 0) {
@@ -214,22 +285,25 @@ impl RumbleEngine {
if p.dirty {
p.dirty = false;
if p.level == (0, 0) {
return (Some(p.silence(pad)), None);
// 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
// `cancel()`. Two senders produce them: the host's deliberate
// `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind
// `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) {
return (Some(p.silence(pad)), None);
}
continue;
}
if p.quirks.keepalive_ms > 0 {
p.next_keepalive =
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
}
let (low, high) = p.level;
return (
Some(RumbleCommand {
pad,
low,
high,
backstop_ms: p.backstop(),
}),
None,
);
return (Some(p.emit(pad)), None);
}
// 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
@@ -239,20 +313,7 @@ impl RumbleEngine {
let due = *p.next_keepalive.get_or_insert(now + ka);
if now >= due {
p.next_keepalive = Some(now + ka);
let (mut low, high) = p.level;
if p.quirks.dedup_jitter {
p.jitter = !p.jitter;
low ^= p.jitter as u16;
}
return (
Some(RumbleCommand {
pad,
low,
high,
backstop_ms: p.backstop(),
}),
None,
);
return (Some(p.emit(pad)), None);
}
merge_wake(&mut wake, due);
}
@@ -357,6 +418,22 @@ pub(crate) struct Closed;
mod tests {
use super::*;
/// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`.
const DECK: ActuatorQuirks = ActuatorQuirks {
keepalive_ms: 40,
min_pulse_ms: 0,
dedup_jitter: true,
};
/// Drain the engine the way an embedder does: poll until nothing is due.
fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> {
let mut out = Vec::new();
while let (Some(c), _) = e.poll(t) {
out.push((c.low, c.high));
}
out
}
fn ms(v: u64) -> Duration {
Duration::from_millis(v)
}
@@ -527,4 +604,133 @@ mod tests {
);
assert_eq!(shared.next_command(ms(10)), Err(Closed));
}
/// A host renewal must not repeat the value the device last took, or an SDL-class layer
/// swallows the write. Before the jitter moved onto every emit path it lived only in the
/// keepalive branch, so each renewal collided with the last jittered write and was deduped.
#[test]
fn renewal_keeps_the_dedupe_jitter_alternating() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(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));
assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]);
assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]);
}
/// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two
/// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence.
#[test]
fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64);
for tick in 0..=360u64 {
let t = t0 + ms(tick);
if tick % 60 == 0 {
e.wire_update(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");
if v != last {
worst = worst.max(tick - last_write);
last_write = tick;
last = v;
}
}
}
assert!(
worst <= 41,
"worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence"
);
}
/// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad
/// would land in Apple's identical-target comparison and Android's one-shot amplitudes.
#[test]
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));
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
e.wire_update(t0 + ms(120), 0, 100, 200, Some(400));
assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800)));
}
/// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up
/// instead, so the phase still alternates and no stop is invented under a live lease.
#[test]
fn jitter_never_synthesizes_the_stop_sentinel() {
let mut e = RumbleEngine::new();
e.set_quirks(0, DECK);
let t0 = Instant::now();
e.wire_update(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)));
}
/// A zero for a pad the engine already believes is silent is dropped: it heals nothing and
/// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a
/// LOST stop leaves the pad buzzing and the re-send therefore does emit.
#[test]
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));
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));
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));
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));
assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]);
e.wire_update(t0 + ms(50), 0, 0, 0, Some(0));
assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]);
}
/// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified
/// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and
/// the Deck buzzing for the whole of it.
#[test]
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));
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());
assert_eq!(
e.poll(t0 + ms(MAX_LEASE_MS as u64)).0,
Some(cmd(0, 0, 0, 0)),
"the lease must end at the ceiling"
);
}
/// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be
/// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check
/// preempts the relay branch — the pad silences on the same poll and never reaches a backstop.
/// Pinned so that ordering stays load-bearing rather than incidental.
#[test]
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));
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"
);
}
}
+13 -1
View File
@@ -107,6 +107,10 @@ pub use stats::Stats;
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
/// unchanged. (Documented late — the bump shipped without its line here.)
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
@@ -120,7 +124,15 @@ pub use stats::Stats;
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 14;
/// v15: versions the shared rumble policy engine's C surface —
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
/// still read 7 and no bump was made, so every core since has exported them while advertising a
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 15;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+104 -3
View File
@@ -401,6 +401,16 @@ impl RichInput {
}
}
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
/// into its report.
///
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
/// been bounded on both ends all along.
pub const TRIGGER_EFFECT_MAX: usize = 11;
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
@@ -431,6 +441,14 @@ pub enum HidOutput {
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
///
/// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host
/// produces this variant and no client renders it; it codes/decodes and round-trips in tests
/// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a
/// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still
/// send it), it would only lose the decoder that keeps such a datagram from being mistaken
/// for something else. The producer is the Steam Controller coil path; the renderer is the
/// client-side coil write. Wire up either half and this becomes live with no format change.
TrackpadHaptic {
pad: u8,
side: u8,
@@ -460,7 +478,7 @@ impl HidOutput {
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
}
HidOutput::TrackpadHaptic {
pad,
@@ -497,10 +515,17 @@ impl HidOutput {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
// truncated datagram could therefore silently cancel the trigger a game was holding.
// A genuine "no effect" is a full-length zero block and still decodes fine.
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
// Bounded like `HidRaw` below: at most the parameter block is kept from the
// (attacker-sized) tail.
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
@@ -981,6 +1006,82 @@ mod tests {
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
/// AND on the way in, and a body with no effect bytes must not decode at all.
#[test]
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
let long = HidOutput::Trigger {
pad: 1,
which: 0,
effect: vec![0xAB; 200],
};
let d = long.encode();
assert_eq!(
d.len(),
4 + TRIGGER_EFFECT_MAX,
"magic + kind + pad + which + at most the parameter block"
);
// Decode clamps independently of encode — a hostile peer does not use our encoder.
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
hostile.extend_from_slice(&[0xCD; 500]);
match HidOutput::decode(&hostile) {
Some(HidOutput::Trigger { effect, .. }) => {
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
}
other => panic!("expected a clamped Trigger, got {other:?}"),
}
// An exact-length effect survives untouched, and round-trips.
let ok = HidOutput::Trigger {
pad: 2,
which: 1,
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
};
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
}
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
/// releases whatever effect the game was holding. A truncated datagram must not do that.
#[test]
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
assert_eq!(HidOutput::decode(&empty), None);
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
assert_eq!(
HidOutput::decode(&one),
Some(HidOutput::Trigger {
pad: 0,
which: 0,
effect: vec![0x02]
})
);
}
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
/// cannot drift apart again.
#[test]
fn hid_raw_stays_bounded_on_both_sides() {
let long = HidOutput::HidRaw {
pad: 0,
kind: HID_RAW_OUTPUT,
data: vec![0x11; 500],
};
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
hostile.extend_from_slice(&[0x22; 900]);
match HidOutput::decode(&hostile) {
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
other => panic!("expected a clamped HidRaw, got {other:?}"),
}
}
#[test]
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
// v2 envelope round-trips seq + ttl.
+169 -46
View File
@@ -16,6 +16,19 @@
//! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units,
//! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`)
//! provide the raw facts; the matching + rendering here is portable and unit-tested.
//!
//! **Not every fingerprint is a conflict.** Only a host that is running, or that will start on its
//! own, can take the ports or load a second virtual-display driver. A leftover `Program Files`
//! folder from an uninstall, a binary on `PATH`, or a service registered but *disabled* clashes
//! with nothing — Sunshine's and Apollo's uninstallers both leave their config/log directories
//! behind, so treating mere presence as a conflict cries wolf on a machine whose other host is long
//! gone. [`Evidence::is_active`] draws that line and [`Detection::is_active`] lifts it to the
//! product; the warning surfaces (startup log, `/local/summary` → the web console's conflicts card,
//! the `detect-conflicts` exit code) report **only** active detections, while the full report still
//! lists the dormant ones as context for support. This matches the installer's own probe
//! (`punktfunk-host.iss`'s `StreamHostEnabled`: service start type <= 2), which was narrowed to
//! exactly this rule after a dormant Sunshine aborted a `winget install` in the field, and the tray,
//! which dropped its always-on warning over a merely-installed Sunshine in `3e782852`.
use std::sync::OnceLock;
@@ -73,17 +86,38 @@ impl Product {
pub enum Evidence {
/// A matching process is running **right now** (process/executable basename).
Running { process: String },
/// An OS service / systemd unit for the product is registered (installed; may be stopped).
Service { name: String },
/// An OS service / systemd unit for the product is registered. `autostart` is the load-bearing
/// bit: a service that comes up on its own (Windows start type boot/system/automatic; an enabled
/// systemd unit) *will* clash, whereas a disabled/manual one is inert until someone starts it by
/// hand — at which point the `Running` evidence catches it on the next scan.
Service { name: String, autostart: bool },
/// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`.
/// Always dormant: files that nothing launches bind no ports.
Installed { at: String },
}
impl Evidence {
/// Does this observation mean a conflicting host will actually take the ports / load a second
/// virtual-display driver? See the module docs — this is the whole false-alarm fix.
pub fn is_active(&self) -> bool {
match self {
Evidence::Running { .. } => true,
Evidence::Service { autostart, .. } => *autostart,
Evidence::Installed { .. } => false,
}
}
fn render(&self) -> String {
match self {
Evidence::Running { process } => format!("running now ({process})"),
Evidence::Service { name } => format!("service {name}"),
Evidence::Service {
name,
autostart: true,
} => format!("service {name} (starts automatically)"),
Evidence::Service {
name,
autostart: false,
} => format!("service {name} (disabled/manual — dormant)"),
Evidence::Installed { at } => format!("installed at {at}"),
}
}
@@ -105,12 +139,24 @@ impl Detection {
.any(|e| matches!(e, Evidence::Running { .. }))
}
/// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`.
/// True when this host is running **or** will start on its own — i.e. the detection is worth
/// warning a user about. A product seen only as files on disk or a disabled service is dormant
/// and reports `false`; see the module docs.
pub fn is_active(&self) -> bool {
self.evidence.iter().any(Evidence::is_active)
}
/// A compact one-line label for the console summary, e.g. `Sunshine (running)`. The qualifier
/// names what was actually observed, so a card built from these labels can never claim a
/// dormant install is running.
pub fn label(&self) -> String {
let name = self.product.label();
if self.is_running() {
format!("{} (running)", self.product.label())
format!("{name} (running)")
} else if self.is_active() {
format!("{name} (starts automatically)")
} else {
self.product.label().to_string()
format!("{name} (installed, not running)")
}
}
}
@@ -225,28 +271,66 @@ pub fn snapshot() -> &'static [Detection] {
SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[])
}
/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`).
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
detections.iter().map(Detection::label).collect()
/// True if any detection is active — the one gate the warning surfaces share (startup log, the
/// `detect-conflicts` exit code, the console card).
pub fn any_active(detections: &[Detection]) -> bool {
detections.iter().any(Detection::is_active)
}
/// A full human-readable report: the blurb + one bullet per detected host with its evidence.
/// Empty string when nothing was detected (callers gate on `is_empty()`).
/// Compact labels for the web-console summary (e.g. `["Sunshine (running)"]`).
///
/// **Active detections only.** A dormant leftover (an uninstalled Sunshine's `Program Files` folder,
/// a disabled service) is deliberately absent: this feeds the console's conflicts card, which exists
/// to explain why clients cannot reach a working-looking host, and files that nothing launches never
/// cause that. The full [`render_report`] still lists them for support.
pub fn summary_labels(detections: &[Detection]) -> Vec<String> {
detections
.iter()
.filter(|d| d.is_active())
.map(Detection::label)
.collect()
}
/// A full human-readable report, split by whether the finding can actually clash. Empty string when
/// nothing was detected at all (callers gate on `is_empty()`).
///
/// The dormant section is why this stays verbose where [`summary_labels`] is quiet: when a user asks
/// "why does Punktfunk think I have Apollo?", the answer is the exact leftover path, and the report
/// says in the same breath that it needs no action.
pub fn render_report(detections: &[Detection]) -> String {
if detections.is_empty() {
return String::new();
}
let mut s = String::from("Detected another game-streaming host on this machine.\n");
s.push_str(UNSUPPORTED_BLURB);
s.push_str("\n\nDetected:\n");
for d in detections {
let bullet = |d: &Detection| {
let ev = d
.evidence
.iter()
.map(Evidence::render)
.collect::<Vec<_>>()
.join("; ");
s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()));
format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label())
};
let (active, dormant): (Vec<_>, Vec<_>) = detections.iter().partition(|d| d.is_active());
let mut s = String::new();
if !active.is_empty() {
s.push_str("Detected another game-streaming host on this machine.\n");
s.push_str(UNSUPPORTED_BLURB);
s.push_str("\n\nDetected:\n");
for d in &active {
s.push_str(&bullet(d));
}
}
if !dormant.is_empty() {
if !active.is_empty() {
s.push('\n');
}
s.push_str(
"Also present but DORMANT — not running and not set to start on its own, so it clashes \
with nothing and needs no action (typically leftovers from an uninstall):\n",
);
for d in &dormant {
s.push_str(&bullet(d));
}
}
s
}
@@ -275,15 +359,19 @@ mod tests {
},
Evidence::Service {
name: "SunshineService".into(),
autostart: true,
},
],
);
assert!(d.is_running());
assert!(d.is_active());
assert_eq!(d.label(), "Sunshine (running)");
}
/// The field case this split exists for: Apollo uninstalled, its `Program Files` folder left
/// behind. Nothing launches it, so it is NOT a conflict and must never reach the console card.
#[test]
fn installed_only_is_not_running() {
fn a_leftover_install_dir_is_dormant_and_never_surfaces() {
let d = det(
Product::Apollo,
vec![Evidence::Installed {
@@ -291,42 +379,77 @@ mod tests {
}],
);
assert!(!d.is_running());
assert_eq!(d.label(), "Apollo");
assert!(!d.is_active(), "files on disk cannot bind a port");
assert_eq!(d.label(), "Apollo (installed, not running)");
assert!(summary_labels(std::slice::from_ref(&d)).is_empty());
assert!(!any_active(&[d]));
}
/// A registered-but-DISABLED service is the other half of the same false alarm: `service_exists`
/// used to count it, which disagreed with the installer's `Start <= 2` probe.
#[test]
fn a_disabled_service_is_dormant_but_an_autostart_one_is_not() {
let disabled = det(
Product::Sunshine,
vec![Evidence::Service {
name: "SunshineService".into(),
autostart: false,
}],
);
assert!(!disabled.is_active());
assert!(summary_labels(&[disabled]).is_empty());
let auto = det(
Product::Sunshine,
vec![Evidence::Service {
name: "SunshineService".into(),
autostart: true,
}],
);
assert!(auto.is_active());
assert!(!auto.is_running(), "registered to start != started");
assert_eq!(auto.label(), "Sunshine (starts automatically)");
assert_eq!(
summary_labels(&[auto]),
vec!["Sunshine (starts automatically)".to_string()]
);
}
#[test]
fn report_lists_every_product_and_the_blurb() {
let report = render_report(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into(),
}],
),
det(
Product::Apollo,
vec![Evidence::Installed {
at: "/usr/bin/apollo".into(),
}],
),
]);
fn report_separates_active_from_dormant_and_keeps_the_blurb() {
let active = det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into(),
}],
);
let dormant = det(
Product::Apollo,
vec![Evidence::Installed {
at: "/usr/bin/apollo".into(),
}],
);
let report = render_report(&[active.clone(), dormant.clone()]);
assert!(report.contains("UNSUPPORTED"));
// The bullets name the PRODUCT and let the evidence speak — `Detection::label`'s qualifier
// would only restate what follows the dash ("Sunshine (running) — running now (sunshine)").
// The qualifier is for `summary_labels`, which has no evidence text beside it.
assert!(report.contains("Sunshine \u{2014} running now (sunshine)"));
assert!(report.contains("DORMANT"));
assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo"));
// Only the live one is offered to the console card.
assert_eq!(
summary_labels(&[
det(
Product::Sunshine,
vec![Evidence::Running {
process: "sunshine".into()
}]
),
det(
Product::Apollo,
vec![Evidence::Installed { at: "x".into() }]
),
]),
vec!["Sunshine (running)".to_string(), "Apollo".to_string()]
summary_labels(&[active, dormant.clone()]),
vec!["Sunshine (running)".to_string()]
);
// A dormant-only machine gets the explanatory listing WITHOUT the "unsupported" alarm — the
// whole point is that this needs no action.
let dormant_only = render_report(&[dormant]);
assert!(dormant_only.contains("DORMANT"));
assert!(
!dormant_only.contains("UNSUPPORTED"),
"a leftover folder must not read as an unsupported dual-host setup:\n{dormant_only}"
);
}
+48 -1
View File
@@ -50,7 +50,11 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
for unit in known.linux_units {
let file = format!("{unit}.service");
if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) {
ev.push(Evidence::Service { name: file });
let autostart = unit_enabled(&file, home.as_deref());
ev.push(Evidence::Service {
name: file,
autostart,
});
}
}
@@ -78,6 +82,49 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
ev
}
/// Is `unit` (a `<name>.service` filename) **enabled** — i.e. will systemd start it on its own?
///
/// `systemctl enable` works by symlinking the unit into a target's `.wants`/`.requires` directory,
/// so the presence of that link is the enablement fact — readable without spawning `systemctl`
/// (this module is deliberately subprocess-free, and the host often runs where `systemctl` output
/// would need a bus connection anyway). A unit file that exists but is linked from no target is
/// installed-but-inert: nothing starts it at boot, so it clashes with nothing.
///
/// Scans the `.wants`/`.requires` subdirectories of the drop-in roots systemd actually reads, rather
/// than hardcoding `multi-user.target` — a unit pulled in by `graphical.target`, a user
/// `default.target`, or any other target is just as enabled.
fn unit_enabled(unit: &str, home: Option<&std::ffi::OsStr>) -> bool {
let mut roots: Vec<String> = vec![
"/etc/systemd/system".into(),
"/run/systemd/system".into(),
"/usr/lib/systemd/system".into(),
"/lib/systemd/system".into(),
"/etc/systemd/user".into(),
"/usr/lib/systemd/user".into(),
];
if let Some(h) = home {
roots.push(format!("{}/.config/systemd/user", h.to_string_lossy()));
}
for root in roots {
let Ok(entries) = std::fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.ends_with(".wants") || name.ends_with(".requires")) {
continue;
}
// `symlink_metadata` so a DANGLING link still counts: a link into a target's .wants is
// what "enabled" means, and a broken one still says the operator enabled it.
if std::fs::symlink_metadata(entry.path().join(unit)).is_ok() {
return true;
}
}
}
false
}
fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option<String> {
let dirs = path.map(std::env::split_paths).into_iter().flatten();
// Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context).
+32 -10
View File
@@ -7,7 +7,7 @@ use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use windows_service::service::ServiceAccess;
use windows_service::service::{ServiceAccess, ServiceStartType};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
/// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp
@@ -49,9 +49,10 @@ pub fn running_processes() -> Vec<String> {
pub fn static_evidence(known: &Known) -> Vec<Evidence> {
let mut ev = Vec::new();
for svc in known.win_services {
if service_exists(svc) {
if let Some(autostart) = service_start_type(svc) {
ev.push(Evidence::Service {
name: (*svc).to_string(),
autostart,
});
}
}
@@ -63,14 +64,35 @@ pub fn static_evidence(known: &Known) -> Vec<Evidence> {
ev
}
/// True if a service by this name is registered with the SCM (running or stopped). Opening it with
/// `QUERY_STATUS` fails cleanly when it doesn't exist.
fn service_exists(name: &str) -> bool {
let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return false;
};
mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok()
/// `Some(autostart)` if a service by this name is registered with the SCM (running or stopped),
/// `None` if it does not exist. Opening it fails cleanly when it doesn't exist.
///
/// `autostart` mirrors the installer's `StreamHostEnabled` (start type <= 2): only boot/system/auto
/// come up on their own, and only a host that comes up can take the GameStream ports. A disabled or
/// manual service is dormant — see the module docs on `super`. When the start type cannot be read
/// (no `QUERY_CONFIG` right) we report the service as dormant rather than guessing it autostarts:
/// the false-alarm this whole split exists to kill is worse than a missed warning, and a host that
/// is genuinely up is caught by the process scan regardless of what its service config says.
fn service_start_type(name: &str) -> Option<bool> {
let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let svc = mgr
.open_service(
name,
ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS,
)
// Fall back to a status-only handle so a service we may not configure still registers as
// present (dormant) instead of vanishing from the report entirely.
.or_else(|_| mgr.open_service(name, ServiceAccess::QUERY_STATUS))
.ok()?;
let autostart = svc.query_config().is_ok_and(|c| {
matches!(
c.start_type,
ServiceStartType::AutoStart
| ServiceStartType::BootStart
| ServiceStartType::SystemStart
)
});
Some(autostart)
}
/// The install directory under any of the Program Files roots, if it exists.
+18 -7
View File
@@ -334,15 +334,26 @@ pub fn serve(
"punktfunk host"
);
// Surface a conflicting Moonlight-compatible host (Sunshine/Apollo/…) as early as possible:
// scan once (cached for `/local/summary` → tray + web console) and warn loudly if found.
// scan once (cached for `/local/summary` → the web console) and warn loudly if one can actually
// clash. A dormant leftover (an uninstalled Sunshine's Program Files folder, a disabled service)
// is logged at INFO instead — it belongs in a support log, not in a warning that reads like a
// fault on every boot.
let conflicts = crate::detect::init();
if !conflicts.is_empty() {
tracing::warn!(
target: "punktfunk::detect",
count = conflicts.len(),
"{}",
crate::detect::render_report(conflicts)
);
let report = crate::detect::render_report(conflicts);
if crate::detect::any_active(conflicts) {
tracing::warn!(
target: "punktfunk::detect",
count = conflicts.len(),
"{report}"
);
} else {
tracing::info!(
target: "punktfunk::detect",
count = conflicts.len(),
"{report}"
);
}
}
if gamestream {
tracing::warn!(
+21 -5
View File
@@ -104,10 +104,14 @@ mod tray;
mod store;
mod stream_marker;
mod update;
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
// The two startup crash-recovery legs (below), both in the `pf-win-display` leaf crate (plan §W6):
// `monitor_devnode::startup_recover()` re-enables PnP monitor devnodes disabled by a prior run, and
// `isolate_journal::startup_recover()` re-lights displays a prior run deactivated for an EXCLUSIVE
// session and never restored.
#[cfg(target_os = "windows")]
use pf_win_display::monitor_devnode;
#[cfg(target_os = "windows")]
use pf_win_display::win_display::isolate_journal;
// Virtual-display orchestration lives in the `pf-vdisplay` subsystem crate (plan §W6); this shim
// keeps every existing `crate::vdisplay::*` path valid (serve/mgmt/native/capture consume the trait,
// registry, and manager through it). The DDC panel control + the KWin zkde protocol moved with it.
@@ -379,6 +383,12 @@ fn real_main() -> Result<()> {
// restored (crash/kill/power loss) — before any new session touches the topology.
#[cfg(target_os = "windows")]
monitor_devnode::startup_recover();
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
// monitors are present again and the EXTEND preset can actually light them.
#[cfg(target_os = "windows")]
isolate_journal::startup_recover();
gamestream::serve(mgmt_opts, native, gamestream)
}
// Report other Moonlight-compatible hosts (Sunshine/Apollo/…) installed or running on this
@@ -388,11 +398,17 @@ fn real_main() -> Result<()> {
let found = detect::scan();
if found.is_empty() {
println!("No conflicting game-streaming host detected.");
Ok(())
} else {
print!("{}", detect::render_report(&found));
return Ok(());
}
print!("{}", detect::render_report(&found));
// Exit 1 ONLY for a host that runs or will start on its own. The installers and support
// scripts gate on this code, and a dormant leftover used to abort them — a `winget
// install` failed in the field on a box whose Sunshine was merely present (see the
// module docs + `punktfunk-host.iss`). Dormant findings print, then exit 0.
if detect::any_active(&found) {
std::process::exit(1);
}
Ok(())
}
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
+172 -143
View File
@@ -45,6 +45,10 @@
// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
// clock offset ongoing latency math must use; the connect-time getter stays frozen by
// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
// unchanged. (Documented late — the bump shipped without its line here.)
// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
@@ -58,7 +62,15 @@
// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
#define ABI_VERSION 14
// v15: versions the shared rumble policy engine's C surface —
// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
// still read 7 and no bump was made, so every core since has exported them while advertising a
// version that never promised them. That cannot be corrected retroactively — a shipped binary
// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
// present, below it an embedder must probe for the symbol. Purely a version statement; no code
// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 15
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -66,7 +78,7 @@
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
#define WIRE_VERSION 2
#define PUNKTFUNK_WIRE_VERSION 2
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
#define PUNKTFUNK_HIDOUT_LED 1
@@ -323,41 +335,41 @@
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
#define LEGACY_STALE_MS 1000
#define PUNKTFUNK_LEGACY_STALE_MS 1000
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a
// cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed
// value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session.
#define CLIP_FETCH_CAP (64 << 20)
#define PUNKTFUNK_CLIP_FETCH_CAP (64 << 20)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned
// outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can
// then be routed to the right table.
#define INBOUND_REQ_FLAG 2147483648
#define PUNKTFUNK_INBOUND_REQ_FLAG 2147483648
#endif
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
// bottom out here instead of producing degenerate confetti-sized shards.
#define MIN_SHARD_PAYLOAD 512
#define PUNKTFUNK_MIN_SHARD_PAYLOAD 512
// 16-byte AEAD authentication tag appended by either session cipher.
#define TAG_LEN 16
#define PUNKTFUNK_TAG_LEN 16
// Wire tag distinguishing an input datagram from a video packet.
#define INPUT_MAGIC 200
#define PUNKTFUNK_INPUT_MAGIC 200
// Fixed serialized size of an [`InputEvent`] on the wire (tag + fields).
#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4)
// The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the
// client's snapshot fold and the host's per-pad accumulators.
#define MAX_PADS 16
#define PUNKTFUNK_MAX_PADS 16
#define PUNKTFUNK_BTN_DPAD_UP 1
@@ -390,16 +402,16 @@
#define PUNKTFUNK_BTN_Y 32768
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
#define BTN_PADDLE1 65536
#define PUNKTFUNK_BTN_PADDLE1 65536
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
#define BTN_PADDLE2 131072
#define PUNKTFUNK_BTN_PADDLE2 131072
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
#define BTN_PADDLE3 262144
#define PUNKTFUNK_BTN_PADDLE3 262144
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
#define BTN_PADDLE4 524288
#define PUNKTFUNK_BTN_PADDLE4 524288
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
@@ -407,7 +419,7 @@
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
#define BTN_MISC1 2097152
#define PUNKTFUNK_BTN_MISC1 2097152
// Axis ids for `InputKind::GamepadAxis`.
#define PUNKTFUNK_AXIS_LS_X 0
@@ -426,16 +438,16 @@
// Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]).
#define PUNKTFUNK_MAGIC 201
#define FLAG_PIC 1
#define PUNKTFUNK_FLAG_PIC 1
#define FLAG_EOF 2
#define PUNKTFUNK_FLAG_EOF 2
#define FLAG_SOF 4
#define PUNKTFUNK_FLAG_SOF 4
// Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes
// the host burst access units carrying this flag so the client measures throughput/loss without
// feeding them to the decoder. Punktfunk/1 only (GameStream never sets it).
#define FLAG_PROBE 8
#define PUNKTFUNK_FLAG_PROBE 8
// Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client
// as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that
@@ -444,7 +456,7 @@
// post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible
// clean point it can honor without forcing a full IDR. Lives above the low nibble because the host
// reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four.
#define USER_FLAG_RECOVERY_POINT 16
#define PUNKTFUNK_USER_FLAG_RECOVERY_POINT 16
// Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike
// [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss
@@ -454,7 +466,7 @@
// already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts
// its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets
// `AV_FRAME_FLAG_KEY` — this host flag is the only signal.
#define USER_FLAG_RECOVERY_ANCHOR 32
#define PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR 32
// `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every
// `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the
@@ -462,7 +474,7 @@
// consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer
// AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a
// COMPLETE frame must be consumed window-by-window (the padding is not part of the stream).
#define USER_FLAG_CHUNK_ALIGNED 64
#define PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED 64
// `user_flags` bit: this AU was packetized as a **slice-streamed** frame (the P2 slice
// pipeline): its sentinel blocks (`block_count == 0`) are SLICE-granularity and carry their
@@ -475,7 +487,7 @@
// [`VIDEO_CAP_STREAMED_AU`](crate::quic::VIDEO_CAP_STREAMED_AU) ∧
// [`VIDEO_CAP_MULTI_SLICE`](crate::quic::VIDEO_CAP_MULTI_SLICE) — the pair whose receivers
// know this contract.
#define USER_FLAG_SLICE_STREAM 128
#define PUNKTFUNK_USER_FLAG_SLICE_STREAM 128
// Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation
// recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH
@@ -484,7 +496,7 @@
// reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range
// from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the
// client-side gap detectors (huge gap → resync + keyframe request, no RFI).
#define RFI_MAX_RANGE 256
#define PUNKTFUNK_RFI_MAX_RANGE 256
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
@@ -498,22 +510,22 @@
// for never having to resize buffers on a mid-session grow. Senders still derive their
// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
// this is the acceptance ceiling, not a transmit size.
#define MAX_DATAGRAM_BYTES 9216
#define PUNKTFUNK_MAX_DATAGRAM_BYTES 9216
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
// once this much has accumulated (~22 KB at the standard shard payload). Small slices simply
// ride with the next one; the wire is never worse than one flush per slice.
#define MIN_STREAM_BLOCK_SHARDS 16
#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
#define VIDEO_CAP_10BIT 1
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
#define VIDEO_CAP_HDR 2
#define PUNKTFUNK_VIDEO_CAP_HDR 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -525,7 +537,7 @@
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
// where the hardware allows).
#define VIDEO_CAP_444 4
#define PUNKTFUNK_VIDEO_CAP_444 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -535,7 +547,7 @@
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
// stage. Purely observability — never changes what the host encodes.
#define VIDEO_CAP_HOST_TIMING 8
#define PUNKTFUNK_VIDEO_CAP_HOST_TIMING 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -550,7 +562,7 @@
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
// reassembler would silently drop as stale.
#define VIDEO_CAP_PROBE_SEQ 16
#define PUNKTFUNK_VIDEO_CAP_PROBE_SEQ 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -565,7 +577,7 @@
// — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this
// bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so
// the fallback is zero-risk.
#define VIDEO_CAP_STREAMED_AU 32
#define PUNKTFUNK_VIDEO_CAP_STREAMED_AU 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -579,7 +591,7 @@
// toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a
// performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS
// control channel, so there is no downgrade surface.
#define VIDEO_CAP_CHACHA20 64
#define PUNKTFUNK_VIDEO_CAP_CHACHA20 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -595,7 +607,7 @@
// bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions);
// every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the
// video_caps byte's last free bit — the next video cap needs a second byte (ABI bump).
#define VIDEO_CAP_MULTI_SLICE 128
#define PUNKTFUNK_VIDEO_CAP_MULTI_SLICE 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -604,7 +616,7 @@
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
#define HOST_CAP_GAMEPAD_STATE 1
#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -614,7 +626,7 @@
// out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled:
// true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing
// trailing `host_caps` byte — no wire-layout change.
#define HOST_CAP_CLIPBOARD 2
#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -626,7 +638,7 @@
// non-Latin scripts, emoji) through `TextInput` instead of lossy VK synthesis; absent the bit it
// keeps the VK fallback. Packs into the existing trailing `host_caps` byte — no wire-layout
// change; an older host ignores the unknown input tag anyway (input is lossy by design).
#define HOST_CAP_TEXT_INPUT 4
#define PUNKTFUNK_HOST_CAP_TEXT_INPUT 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -638,7 +650,7 @@
// (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host
// answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward
// an older or incapable host nothing changes.
#define CLIENT_CAP_CURSOR 1
#define PUNKTFUNK_CLIENT_CAP_CURSOR 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -647,7 +659,7 @@
// capture/send tick to the client's display latch (design/phase-locked-capture.md). Without
// the bit the host never arms the phase controller; toward an older host the reports are
// simply ignored — no behavior change in either direction.
#define CLIENT_CAP_PHASE_LOCK 2
#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -660,7 +672,7 @@
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
#define CLIENT_CAP_AUDIO_RED 4
#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -671,7 +683,7 @@
// host stops blending and ships [`CursorShape`](super::control::CursorShape) +
// [`CursorState`](super::datagram::CursorState) instead. `0x08` — `0x04` is
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
#define HOST_CAP_CURSOR 8
#define PUNKTFUNK_HOST_CAP_CURSOR 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -685,7 +697,7 @@
// wire ships ahead of the backend (P0): no host sets this bit until the P1 injector lands —
// which is exactly why the gate exists. `0x10` — `0x08` is [`HOST_CAP_CURSOR`], `0x04` is
// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
#define HOST_CAP_PEN 16
#define PUNKTFUNK_HOST_CAP_PEN 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -699,25 +711,25 @@
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
#define HOST_CAP_AUDIO_RED 32
#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
// advertise this.
#define CODEC_H264 1
#define PUNKTFUNK_CODEC_H264 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
#define CODEC_HEVC 2
#define PUNKTFUNK_CODEC_HEVC 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode AV1.
#define CODEC_AV1 4
#define PUNKTFUNK_CODEC_AV1 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -731,18 +743,18 @@
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
// version instead (plan §4.2).
#define CODEC_PYROWAVE 8
#define PUNKTFUNK_CODEC_PYROWAVE 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
// default when a peer omits [`Welcome::chroma_format`].
#define CHROMA_IDC_420 1
#define PUNKTFUNK_CHROMA_IDC_420 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
#define CHROMA_IDC_444 3
#define PUNKTFUNK_CHROMA_IDC_444 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -793,195 +805,195 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1
#define PUNKTFUNK_MSG_RECONFIGURE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigured`].
#define MSG_RECONFIGURED 2
#define PUNKTFUNK_MSG_RECONFIGURED 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`RequestKeyframe`].
#define MSG_REQUEST_KEYFRAME 3
#define PUNKTFUNK_MSG_REQUEST_KEYFRAME 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`LossReport`].
#define MSG_LOSS_REPORT 4
#define PUNKTFUNK_MSG_LOSS_REPORT 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`SetBitrate`].
#define MSG_SET_BITRATE 5
#define PUNKTFUNK_MSG_SET_BITRATE 5
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`BitrateChanged`].
#define MSG_BITRATE_CHANGED 6
#define PUNKTFUNK_MSG_BITRATE_CHANGED 6
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`RfiRequest`].
#define MSG_RFI_REQUEST 7
#define PUNKTFUNK_MSG_RFI_REQUEST 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadChanged`].
#define MSG_SHARD_PAYLOAD_CHANGED 8
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadAck`].
#define MSG_SHARD_PAYLOAD_ACK 9
#define PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK 9
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define MSG_PROBE_REQUEST 32
#define PUNKTFUNK_MSG_PROBE_REQUEST 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeResult`].
#define MSG_PROBE_RESULT 33
#define PUNKTFUNK_MSG_PROBE_RESULT 33
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClockProbe`].
#define MSG_CLOCK_PROBE 48
#define PUNKTFUNK_MSG_CLOCK_PROBE 48
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClockEcho`].
#define MSG_CLOCK_ECHO 49
#define PUNKTFUNK_MSG_CLOCK_ECHO 49
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PhaseReport`].
#define MSG_PHASE_REPORT 50
#define PUNKTFUNK_MSG_PHASE_REPORT 50
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this
// session. Idempotent; opt-in is enforced here, not just in UI.
#define MSG_CLIP_CONTROL 64
#define PUNKTFUNK_MSG_CLIP_CONTROL 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates.
#define MSG_CLIP_STATE 65
#define PUNKTFUNK_MSG_CLIP_STATE 65
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes.
#define MSG_CLIP_OFFER 66
#define PUNKTFUNK_MSG_CLIP_OFFER 66
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the
// current offer.
#define MSG_CLIP_FETCH 67
#define PUNKTFUNK_MSG_CLIP_FETCH 67
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response
// header that precedes the data chunks.
#define MSG_CLIP_FETCH_HDR 68
#define PUNKTFUNK_MSG_CLIP_FETCH_HDR 68
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session.
// Absent ⇒ files are filtered out of offers in both directions (text/rich/image only).
#define CLIP_FLAG_FILES 1
#define PUNKTFUNK_CLIP_FLAG_FILES 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set
// while enabled unless a future direction limit clears it.
#define CLIP_POLICY_TEXT 1
#define PUNKTFUNK_CLIP_POLICY_TEXT 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files`
// / `text-only` policy so the client can grey out "Include files".
#define CLIP_POLICY_FILES 2
#define PUNKTFUNK_CLIP_POLICY_FILES 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: normal ack, nothing exceptional.
#define CLIP_REASON_OK 0
#define PUNKTFUNK_CLIP_REASON_OK 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope
// session with no data-control global) — the client shows "not supported in this session type".
#define CLIP_REASON_BACKEND_UNAVAILABLE 1
#define PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this
// one was disabled (last `ClipControl{enabled}` wins).
#define CLIP_REASON_TAKEN_OVER 2
#define PUNKTFUNK_CLIP_REASON_TAKEN_OVER 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard.
#define CLIP_REASON_POLICY_DISABLED 3
#define PUNKTFUNK_CLIP_REASON_POLICY_DISABLED 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` /
// `text-only`) — surfaced so the client greys "Include files" with a footnote.
#define CLIP_REASON_NO_FILES 4
#define PUNKTFUNK_CLIP_REASON_NO_FILES 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN.
#define CLIP_FETCH_OK 0
#define PUNKTFUNK_CLIP_FETCH_OK 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer;
// the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow.
#define CLIP_FETCH_STALE 1
#define PUNKTFUNK_CLIP_FETCH_STALE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No
// chunks follow.
#define CLIP_FETCH_UNAVAILABLE 2
#define PUNKTFUNK_CLIP_FETCH_UNAVAILABLE 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No
// chunks follow.
#define CLIP_FETCH_DENIED 3
#define PUNKTFUNK_CLIP_FETCH_DENIED 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7).
#define CLIP_MAX_KINDS 16
#define PUNKTFUNK_CLIP_MAX_KINDS 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7).
#define CLIP_MAX_MIME 128
#define PUNKTFUNK_CLIP_MAX_MIME 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the
// file *manifest* itself). Real file fetches use `0..n`.
#define CLIP_FILE_INDEX_NONE UINT32_MAX
#define PUNKTFUNK_CLIP_FILE_INDEX_NONE UINT32_MAX
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed.
#define MSG_CURSOR_SHAPE 80
#define PUNKTFUNK_MSG_CURSOR_SHAPE 80
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`CursorRenderMode`] (client → host): who renders the pointer right now.
#define MSG_CURSOR_RENDER 81
#define PUNKTFUNK_MSG_CURSOR_RENDER 81
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -990,7 +1002,7 @@
// overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers
// real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything
// larger before forwarding, so the cap is invisible to clients.
#define CURSOR_SHAPE_MAX_SIDE 120
#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1010,21 +1022,21 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
#define MIC_MAGIC 203
#define PUNKTFUNK_MIC_MAGIC 203
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
// kind-tagged (see [`RichInput`]).
#define RICH_INPUT_MAGIC 204
#define PUNKTFUNK_RICH_INPUT_MAGIC 204
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
// [`HidOutput`].
#define HIDOUT_MAGIC 205
#define PUNKTFUNK_HIDOUT_MAGIC 205
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1064,7 +1076,7 @@
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v1 (legacy, level) rumble datagram.
#define RUMBLE_V1_LEN 7
#define PUNKTFUNK_RUMBLE_V1_LEN 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1072,48 +1084,60 @@
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
// 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.
#define RUMBLE_V2_LEN 10
#define PUNKTFUNK_RUMBLE_V2_LEN 10
#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
// 4654 bytes; feature and output reports are at most 64).
#define HID_REPORT_MAX 64
#define PUNKTFUNK_HID_REPORT_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
// into its report.
//
// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
// been bounded on both ends all along.
#define PUNKTFUNK_TRIGGER_EFFECT_MAX 11
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
// it on the physical device's interrupt-OUT endpoint / GATT write.
#define HID_RAW_OUTPUT 0
#define PUNKTFUNK_HID_RAW_OUTPUT 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
#define HID_RAW_FEATURE 1
#define PUNKTFUNK_HID_RAW_FEATURE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
#define HDR_META_MAGIC 206
#define PUNKTFUNK_HDR_META_MAGIC 206
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body).
#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
#define PUNKTFUNK_HDR_META_BODY_LEN (((12 + 4) + 8) + 4)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
#define HOST_TIMING_MAGIC 207
#define PUNKTFUNK_HOST_TIMING_MAGIC 207
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1124,18 +1148,18 @@
// self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the
// reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte
// datagram only moves/hides the pointer.
#define CURSOR_STATE_MAGIC 208
#define PUNKTFUNK_CURSOR_STATE_MAGIC 208
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`CursorState::flags`] bit: the host cursor is visible.
#define CURSOR_VISIBLE 1
#define PUNKTFUNK_CURSOR_VISIBLE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run
// relative/captured (M3 auto-flip; advisory, user override always wins).
#define CURSOR_RELATIVE_HINT 2
#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1144,7 +1168,7 @@
// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
// reconnect can resume. Shared so host + every client agree on the code.
#define QUIT_CLOSE_CODE 81
#define PUNKTFUNK_QUIT_CLOSE_CODE 81
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1154,107 +1178,107 @@
// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
#define APP_EXITED_CLOSE_CODE 82
#define PUNKTFUNK_APP_EXITED_CLOSE_CODE 82
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
#define HELLO_NAME_MAX 64
#define PUNKTFUNK_HELLO_NAME_MAX 64
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short
// (`steam:<appid>` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field.
#define HELLO_LAUNCH_MAX 128
#define PUNKTFUNK_HELLO_LAUNCH_MAX 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the
// only one pre-cipher builds know).
#define CIPHER_AES_128_GCM 0
#define PUNKTFUNK_CIPHER_AES_128_GCM 0
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via
// [`VIDEO_CAP_CHACHA20`] for clients without hardware AES.
#define CIPHER_CHACHA20_POLY1305 1
#define PUNKTFUNK_CIPHER_CHACHA20_POLY1305 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairRequest`].
#define MSG_PAIR_REQUEST 16
#define PUNKTFUNK_MSG_PAIR_REQUEST 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairChallenge`].
#define MSG_PAIR_CHALLENGE 17
#define PUNKTFUNK_MSG_PAIR_CHALLENGE 17
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairProof`].
#define MSG_PAIR_PROOF 18
#define PUNKTFUNK_MSG_PAIR_PROOF 18
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`PairResult`].
#define MSG_PAIR_RESULT 19
#define PUNKTFUNK_MSG_PAIR_RESULT 19
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by
// [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a
// coherent contact).
#define PEN_IN_RANGE 1
#define PUNKTFUNK_PEN_IN_RANGE 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the tip is in contact with the surface.
#define PEN_TOUCHING 2
#define PUNKTFUNK_PEN_TOUCHING 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held.
#define PEN_BARREL1 4
#define PUNKTFUNK_PEN_BARREL1 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping)
// is held.
#define PEN_BARREL2 8
#define PUNKTFUNK_PEN_BARREL2 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1;
// receivers MUST ignore samples carrying it until a capability negotiates otherwise
// (design/pen-tablet-input.md §8).
#define PEN_PREDICTED 128
#define PUNKTFUNK_PEN_PREDICTED 128
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading.
#define PEN_TILT_UNKNOWN 255
#define PUNKTFUNK_PEN_TILT_UNKNOWN 255
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading.
#define PEN_ANGLE_UNKNOWN 65535
#define PUNKTFUNK_PEN_ANGLE_UNKNOWN 65535
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`PenSample::distance`] sentinel: no hover-distance reading.
#define PEN_DISTANCE_UNKNOWN 65535
#define PUNKTFUNK_PEN_DISTANCE_UNKNOWN 65535
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence
// (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches.
#define PEN_BATCH_MAX 8
#define PUNKTFUNK_PEN_BATCH_MAX 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of one encoded [`PenSample`].
#define PEN_SAMPLE_WIRE_LEN 21
#define PUNKTFUNK_PEN_SAMPLE_WIRE_LEN 21
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1265,13 +1289,13 @@
// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while
// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live
// stationary stroke two heartbeats clear of the deadline.
#define PEN_TOUCH_TIMEOUT_MS 200
#define PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS 200
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds
// (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte.
#define CLIP_STREAM_KIND_FETCH 1
#define PUNKTFUNK_CLIP_STREAM_KIND_FETCH 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -1281,18 +1305,18 @@
// `0x52`), the connection reject code `0x42`, and the pairing-rejection close block
// `0x60``0x67` — stream reset codes and connection close codes are separate QUIC namespaces,
// but the vocabularies stay disjoint on purpose so a captured code is unambiguous.
#define CLIP_CANCELLED_CODE 112
#define PUNKTFUNK_CLIP_CANCELLED_CODE 112
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound).
#define CLIP_CHUNK (64 * 1024)
#define PUNKTFUNK_CLIP_CHUNK (64 * 1024)
#endif
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
// almost immediately instead of never.
#define NO_OUTPUT_KEYFRAME_STREAK 3
#define PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK 3
// How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the
// latest loss before the gate lifts its freeze on an IDR-free stream. TWO, not one: with a continuous
@@ -1304,12 +1328,12 @@
// deliberate "hold longer, never show garbage" trade.
//
// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
#define REANCHOR_MARKS_TO_LIFT 2
#define PUNKTFUNK_REANCHOR_MARKS_TO_LIFT 2
// QUIC application error code the host closes with on a `mode_conflict = reject` admission
// refusal, carrying the human-readable busy reason (live mode + client label). A distinct code
// lets a client tell "host busy" apart from a transport failure. Shared so clients can render it.
#define REJECT_BUSY_CLOSE_CODE 66
#define PUNKTFUNK_REJECT_BUSY_CLOSE_CODE 66
// QUIC application close codes the host sends on **pairing-gate rejections**, so a client can
// tell the user WHY it was turned away instead of collapsing every close into a generic
@@ -1318,44 +1342,44 @@
// their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end
// codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the
// pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`].
#define PAIR_NOT_ARMED_CLOSE_CODE 96
#define PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE 96
// Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not
// consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract.
#define PAIR_BOUND_OTHER_CLOSE_CODE 97
#define PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE 97
// PIN attempt inside the host's global pairing cooldown — retry shortly.
#define PAIR_RATE_LIMITED_CLOSE_CODE 98
#define PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE 98
// Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an
// identity to bind — the PIN flow with a client identity is the way in.
#define PAIR_NO_IDENTITY_CLOSE_CODE 99
#define PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE 99
// The operator explicitly denied this pairing request in the host console.
#define PAIR_DENIED_CLOSE_CODE 100
#define PUNKTFUNK_PAIR_DENIED_CLOSE_CODE 100
// Nobody decided on the parked pairing request before the host's approval wait elapsed.
#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
#define PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101
// This parked knock was superseded by a newer connection from the same device — only the
// newest is admitted on approval.
#define PAIR_SUPERSEDED_CLOSE_CODE 102
#define PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE 102
// The client's wire (protocol) version does not match the host's — one side needs updating.
#define WIRE_VERSION_CLOSE_CODE 103
#define PUNKTFUNK_WIRE_VERSION_CLOSE_CODE 103
// The host admitted the connection but could not stand the stream session up (compositor /
// capture / encoder setup failed host-side). The close reason bytes carry the specific error
// text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this
// code, a setup failure reached the client as a bare dropped connection ("control stream
// finished mid-frame") — indistinguishable from transport trouble.
#define SETUP_FAILED_CLOSE_CODE 104
#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104
// Minimum supported multiplier (renders under native, upscaled on present).
#define MIN_SCALE 0.5
#define PUNKTFUNK_MIN_SCALE 0.5
// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
#define MAX_SCALE 4.0
#define PUNKTFUNK_MAX_SCALE 4.0
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
// test `rc < 0`. Do not renumber existing variants — only append.
@@ -1649,7 +1673,10 @@ typedef struct {
// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
uint8_t effect_len;
// Trigger: the raw DualSense trigger parameter block (mode + params).
uint8_t effect[11];
// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
// exported precisely so embedders can size their own buffers against it, and it declaring one
// number while the struct it describes hardcoded another was the whole hazard.
uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX];
} PunktfunkHidOutput;
#endif
@@ -1901,7 +1928,7 @@ typedef struct {
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
// users reason about. Shared so every client's list stays identical.
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
#define PUNKTFUNK_PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
#ifdef __cplusplus
extern "C" {
@@ -2445,10 +2472,12 @@ PunktfunkStatus punktfunk_connection_next_rumble_cmd(PunktfunkConnection *c,
// 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
// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
// actuator.
// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
//
// # Safety
// `c` is a valid connection handle. Callable from any thread.
@@ -360,9 +360,32 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 {
/// from being coalesced away by a following LED/trigger report inside one host poll window (the
/// confirmed stuck-rumble path).
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
// Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it
// names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two
// can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the
// SAME slot — tearing one report's bytes across the other's — and both store head+1, so the
// cursor advances once for two reports and the host sees a single torn entry.
//
// An atomic `fetch_add` on the head does not fix it. That hands each writer a distinct slot,
// but it advances the cursor BEFORE the slot bytes exist, so the host can read a slot that is
// still being filled — trading a torn slot for a torn slot the host is invited to read. Making
// the head-advance mean "the slot below is complete" is exactly what the lock buys.
//
// Poison-tolerant on purpose. Poison is sticky, so the repo's usual `if let Ok(g) = lock()`
// would skip the publish for the REST OF THE PROCESS after a single panic elsewhere — silently
// ending game output. Recovering the guard is safe here: the protected state is bytes in a
// shared section, not an invariant a panic could have broken.
let _publish = RING_PUBLISH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
view.write_bytes(OFF_OUTPUT, bytes);
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
view.write_u32(OFF_OUT_SEQ, seq);
// Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its
// copy of the report bytes after it (`dualsense_windows.rs`, "Acquire pairs with the driver's
// publish-then-bump store order"). An Acquire load pairs with a Release store and nothing
// else, so as a plain write this promised the host an ordering it never actually established —
// on a weakly-ordered core (ARM64) the fresh seq could arrive ahead of the bytes it announces.
view.store_u32(OFF_OUT_SEQ, seq, Ordering::Release);
let len = ring_len(view);
if len != 0 {
let head = view.read_u32(OFF_RING_HEAD);
@@ -375,6 +398,11 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
}
}
/// Serializes [`publish_output`] against itself — see the note there for why an atomic cursor is
/// not enough. Uncontended in the common case: one output report at a time is the norm, and the
/// critical section is a few dozen bytes of memcpy into an already-mapped view.
static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new();
+29 -1
View File
@@ -358,20 +358,48 @@ fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i1
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
/// the game-visible polling path advance.
fn touch_driver_marks(data: &MappedView) {
let _marks = SECTION_PUBLISH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
}
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
///
/// Serialized and Release-published, because IOCTLs arrive concurrently and neither property held
/// before. `seq` was a read-modify-write across the two motor bytes: two `SET_STATE` calls could
/// both read the same value and both write back `seq + 1`, so the host — which treats an unchanged
/// seq as "nothing new" — saw one bump for two writes and skipped a level entirely. A skipped
/// **stop** is the one that hurts: the pad keeps buzzing until the host's ~2.5 s idle force-off
/// notices the game went quiet, which is where the bound on this bug comes from.
///
/// The seq store is Release for the same reason as `pf-gamepad`'s `out_seq`: the host loads it with
/// Acquire and documents that as ordering its read of the motor bytes ("the driver bumps
/// `rumble_seq` AFTER writing the rumble bytes", `gamepad_windows.rs`). A plain write gives that
/// Acquire nothing to pair with, so the guarantee the host's comment claims did not exist in either
/// direction — the host could read a fresh seq against stale motor levels on a weakly-ordered core.
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
let Some(v) = data else { return };
let _publish = SECTION_PUBLISH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
v.write_u8(OFF_RUMBLE_LARGE, large);
v.write_u8(OFF_RUMBLE_SMALL, small);
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
v.write_u32(OFF_RUMBLE_SEQ, seq);
v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release);
}
/// Serializes the section's read-modify-write publishes ([`publish_rumble`], [`touch_driver_marks`])
/// against each other. One lock rather than one per field: they are all short byte writes into the
/// same mapped view, and the contention is nil compared to the IOCTL round trip that reaches them.
///
/// Poison-tolerant deliberately — poison is sticky, so bailing out on it would silently stop
/// forwarding rumble for the rest of the process. The protected state is bytes in a shared section,
/// not an invariant a panic elsewhere could have violated.
static SECTION_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(());
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
+2 -2
View File
@@ -134,8 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
"host_conflicts_title": "Auf diesem Rechner ist ein weiterer Game-Streaming-Server aktiv",
"host_conflicts_help": "Er läuft oder startet automatisch mit und belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende und deaktiviere den anderen Server und starte Punktfunk neu. Ein Server, der nur installiert ist, stört nicht und wird hier nicht aufgeführt.",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
"display_preset": "Voreinstellung",
+2 -2
View File
@@ -134,8 +134,8 @@
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.",
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_conflicts_title": "Another game-streaming server is running on this machine",
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
"host_conflicts_title": "Another game-streaming server is active on this machine",
"host_conflicts_help": "It is running, or set to start on its own, and listens on the same ports as Punktfunk so whichever one started first answers your clients, which is usually why a working-looking host cannot be connected to. Stop and disable the other server, then restart Punktfunk. A server that is only left installed does not clash and is not listed here.",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
"display_preset": "Preset",
+11 -5
View File
@@ -7,11 +7,17 @@ import { m } from "@/paraglide/messages";
/**
* "Something else is already listening on these ports."
*
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, ) running on the same
* machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it,
* even though it is the single most common reason a punktfunk host looks installed and working but
* no client can reach it two servers fighting over the same ports, with whichever won the bind
* answering the client.
* The host detects other Moonlight-compatible servers (Sunshine, Apollo, ) on the same machine at
* startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, even though
* it is the single most common reason a Punktfunk host looks installed and working but no client can
* reach it two servers fighting over the same ports, with whichever won the bind answering the
* client.
*
* `conflicts` carries only servers that are running or set to start on their own; the host filters
* dormant leftovers out (see `detect.rs`), because an uninstalled Sunshine's `Program Files` folder
* clashes with nothing and this card used to shout about it on every load. Each entry names what was
* observed `Sunshine (running)`, `Apollo (starts automatically)` so the heading never has to
* guess, which it previously did by hardcoding "is running".
*
* Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome.
*/