Compare commits

...
Author SHA1 Message Date
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 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 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
32 changed files with 2117 additions and 259 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
}
}
@@ -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,6 +128,7 @@ class GamepadFeedback(
fun start() {
running = true
rumbleThread = Thread({
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
@@ -136,26 +140,50 @@ class GamepadFeedback(
// 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,
)
// 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(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
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
@@ -269,7 +297,7 @@ class GamepadFeedback(
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 +322,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)
@@ -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
}
}
@@ -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())
}
}
@@ -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() {
@@ -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] {
@@ -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()
@@ -459,6 +459,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 +599,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 +609,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 +637,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
+203 -4
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
@@ -1160,6 +1210,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 +1226,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);
@@ -1892,6 +1972,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 +1984,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 +2503,86 @@ mod slot_tests {
);
}
}
#[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());
}
}
+188 -20
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
@@ -299,17 +331,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,
@@ -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)));
@@ -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()
@@ -783,6 +791,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).
+24 -1
View File
@@ -159,6 +159,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 +354,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();
+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;
+219 -28
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
@@ -75,8 +91,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 +107,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 +131,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 +139,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 +210,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 +270,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 +298,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 +403,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 +589,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"
);
}
}
+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
@@ -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.
*/