Compare commits

...
Author SHA1 Message Date
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 2c03290a5e Merge pull request 'chore(release): bump workspace version to 0.24.0' (#29) from worktree-release-0240 into main
audit / cargo-audit (push) Successful in 50s
apple / swift (push) Successful in 1m19s
audit / bun-audit (plugin-kit) (push) Failing after 24s
audit / bun-audit (sdk) (push) Failing after 21s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 33s
audit / pnpm-audit (push) Successful in 24s
apple / screenshots (push) Successful in 5m40s
audit / license-gate (push) Successful in 5m48s
android-screenshots / screenshots (push) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m0s
decky / build-publish (push) Successful in 36s
linux-client-screenshots / screenshots (push) Successful in 1m42s
android / android (push) Successful in 9m17s
sbom / sbom (push) Successful in 28s
arch / build-publish (push) Successful in 13m16s
web-screenshots / screenshots (push) Successful in 4m26s
flatpak / build-publish (push) Successful in 8m8s
release / apple (push) Successful in 10m44s
ci / rust (push) Successful in 6m48s
ci / web (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 11m53s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 24s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish (push) Successful in 6m25s
deb / build-publish-client-arm64 (push) Successful in 1m19s
docker / deploy-docs (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m58s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m10s
Reviewed-on: #29
2026-08-03 19:43:24 +00:00
enricobuehler b6a370a0fd Merge remote-tracking branch 'origin/main' into worktree-release-0240
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / docs-site (pull_request) Successful in 1m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m14s
android / android (pull_request) Successful in 4m35s
ci / rust (pull_request) Successful in 6m0s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m56s
2026-08-03 21:40:38 +02:00
enricobuehler 7db83445b2 Merge pull request 'fix(host/input): rumble comes back when a controller does' (#25) from worktree-haptics-m1-rumble-seq into main
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 2m9s
ci / docs-site (push) Successful in 1m43s
deb / build-publish-client-arm64 (push) Successful in 1m47s
android / android (push) Successful in 5m55s
deb / build-publish-host (push) Successful in 4m49s
apple / screenshots (push) Successful in 5m54s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 4m53s
ci / rust (push) Successful in 6m22s
windows-host / package (push) Successful in 12m32s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / deploy-docs (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 18m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 19m43s
Reviewed-on: #25
2026-08-03 19:17:49 +00:00
enricobuehler 5582a6ea51 Merge branch 'main' into worktree-haptics-m1-rumble-seq
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m51s
ci / docs-site (pull_request) Successful in 2m5s
ci / rust-arm64 (pull_request) Successful in 2m22s
android / android (pull_request) Successful in 3m25s
ci / rust (pull_request) Successful in 8m4s
2026-08-03 19:17:01 +00:00
enricobuehler f7b85ec1fd Merge pull request 'fix(host/pads): an unplugged controller actually disappears' (#26) from worktree-haptics-m2-pad-slots into main
android / android (push) Canceled after 1m16s
apple / swift (push) Canceled after 1m18s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m21s
arch / build-publish (push) Canceled after 1m33s
ci / rust (push) Canceled after 1m33s
ci / web (push) Canceled after 1m36s
ci / rust-arm64 (push) Canceled after 1m42s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 1m35s
deb / build-publish-client-arm64 (push) Canceled after 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
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 2s
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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 18s
windows-host / package (push) Canceled after 1m48s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #26
2026-08-03 19:16:24 +00:00
enricobuehler 327301e012 docs(release): the 0.24.0 notes cover the two controller fixes
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 5m0s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 6m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m20s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m1s
ci / rust (pull_request) Successful in 12m53s
PRs #25 and #26 are going into this release, and neither was in the notes.

Both are user-visible and easy to have lived with without knowing why:
force-feedback stopping for good after a controller reconnect (roughly half of
reconnects, every platform), and an unplugged pad staying visible to the game
for the rest of the session (every time, if it was your only controller).

The whatsnew line for the rumble fix is Play listing copy and that file has a
500-character ceiling, so "A decoder hiccup no longer snowballs into a burst of
broken frames" loses "snowballs into" for "causes" — same meaning, and the new
line is kept short. 498 of 500 used.
2026-08-03 19:52:05 +02:00
enricobuehler ab4cd06e86 Merge remote-tracking branch 'origin/main' into worktree-haptics-m2-pad-slots
ci / docs-site (pull_request) Successful in 3m0s
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m42s
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Failing after 11m17s
ci / rust (pull_request) Successful in 7m36s
2026-08-03 19:47:34 +02:00
enricobuehler 3eab1e41df Merge remote-tracking branch 'origin/main' into worktree-haptics-m1-rumble-seq
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 3m22s
apple / swift (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / rust (pull_request) Successful in 8m11s
2026-08-03 19:47:32 +02:00
enricobuehlerandClaude Opus 5 62573d2781 docs(release): the 0.24.0 notes cover the ABR sweep
ci / web (pull_request) Successful in 1m11s
android / android (pull_request) Canceled after 0s
apple / swift (pull_request) Canceled after 0s
ci / rust (pull_request) Failing after 3m8s
apple / screenshots (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 1m56s
ci / docs-site (pull_request) Canceled after 32s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
PR #28 merged after the bump commit was written, so the notes described a
release that no longer matched the tree. Merged origin/main and added what it
brings: 45 commits since v0.23.0 now, not 39.

Four user-facing entries, because eleven defects in one path is not one bullet
and the pinning is the headline the field reports have been describing for
months ("my bitrate is stuck at 20"):

- the 20 Mbps pin itself, with the measured escape (150 Mbps in ~16 s against
  ~17 minutes) — the number is the point, since the old behaviour was not "slow
  to climb" but "never arrives"
- the five single-window lessons the controller treated as permanent
- throughput counted with FEC parity, which rose with the loss it was meant to
  detect
- the silent host re-target, which made a client's first climb a request to go
  DOWN

The Under the hood section gets the whole sweep in one bullet rather than
scattering it, and PUNKTFUNK_ABR_MAX_MBPS moves from the probe bullet into it
(it now binds at construction, not only on probe-learned ceilings, so it no
longer belongs to the probe).

Play notes gain an ABR line and now run 459/500 chars; the gate's real logic was
re-run against the file, including the byte-identical check. Voice check over
everything above "Under the hood" is clean of internal vocabulary.

Re-verified after the merge: cargo metadata --locked resolves, cargo fmt --all
--check clean, doc lazy-continuation scanner 0 hits. #28 touched no manifest, so
the version bump and the versions-only lock diff are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:47:01 +02:00
enricobuehler d383fa6103 Merge remote-tracking branch 'origin/main' into worktree-release-0240 2026-08-03 19:44:53 +02:00
enricobuehlerandClaude Opus 5 93608980ae chore(release): bump workspace version to 0.24.0
ci / web (pull_request) Successful in 1m5s
android / android (pull_request) Canceled after 1m19s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 2m50s
ci / rust-arm64 (pull_request) Canceled after 2m0s
ci / docs-site (pull_request) Canceled after 1m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
A minor bump: 39 commits since v0.23.0 across 121 files. Mostly a fix-up of
0.23.0 — the slice wire's reassembler sized every sentinel-opened AU at
max_frame_bytes and lost 9 of 12 in-flight frames on any link that reorders,
which is the freeze field reports were seeing on Android and the session client
— plus the desktop presenter rebuild (intent model, V-Sync/VRR as real settings,
the driver's queue-free vblank mode where it exists), the Decky settings tab
growing from nine rows to the whole store, a "Forward controllers" off switch
for passthrough couches, and plugin output finally reaching the console's log
page. The canary base is already 0.24 — scripts/ci/pf-version.sh derives it as
one minor ahead of the latest stable tag — so this is the version canary has
been publishing against all along.

No wire, ABI or driver-protocol change: wire protocol 2, C ABI 14, virtual-display
driver protocol 6 and the Windows virtual-gamepad channel 3 are all identical to
0.23.0. No new capability bits either — VIDEO_CAP_MULTI_SLICE took the video-caps
byte's last free bit in 0.23.0 and nothing here needed the next one. The only
generated-header change since the tag is documentation (probe elapsed_ms
semantics), already committed and verified by ci.yml's staleness gate on main.

Lock touched for the 32 workspace members only, via `cargo update --workspace`:
diff against origin/main is versions-only, 32 insertions and 32 deletions (the
33rd 0.23.0 line in the lock is the third-party `wasapi` crate, which sits at
0.23.0 itself — same trap as the last cut). `cargo metadata --locked` resolves;
`cargo fmt --all --check` clean in both the main and the packaging/windows/drivers
workspaces.

api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags a
release, as in every prior cut.

Notes at docs/releases/v0.24.0.md, per docs/releases/README.md — authored with the
bump so CI's ensure_release seeds the release body at tag creation. Play's "What's
new" at docs/releases/whatsnew/v0.24.0.txt (409/500 chars), which android.yml now
gates as a hard failure at step 1; the gate's own logic was run locally against
this file, including the byte-identical-to-another-release check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:39:00 +02:00
enricobuehler 9979489b56 fix(host/pads): an unplugged controller actually disappears
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m35s
android / android (pull_request) Successful in 3m11s
ci / rust (pull_request) Successful in 6m3s
Unplug a controller mid-session and the virtual pad it was driving outlives
it: the game keeps seeing a connected, permanently idle device for the rest of
the session. The single-controller session — the common case — hits this every
time.

`PadSlots::sweep` needs two passes to retire a pad. The first pass to see the
mask bit clear only ARMS the 300 ms devnode-churn grace; the drop lands on a
later pass. But sweep runs only from a state frame, and the producer emits
exactly one frame per detach — `native/input.rs` guards the emit on the bit
still being set — so for a pad with no still-changing sibling in the same
manager, the second pass never comes. Nothing periodic reaches sweep:
`heartbeat` and `pump` walk the slots without it.

Split the two halves. `sweep` still folds a frame's mask into the grace
clocks, and `reap` — new — drops whatever has run out, with no frame needed.
Every manager now reaps on the periodic pump it already runs, so the teardown
completes ~300 ms after the detach instead of never.

`reap` deliberately cannot arm a clock: it only reads `inactive_since` and
clears it, so a pad whose bit never went clear has nothing to run out and no
amount of reaping can drop it. That is what makes it safe on a hot loop, and
it keeps the anti-flap guarantee intact — a mask that blips clear and returns
still never churns a devnode.

The two existing tests hand-fed a SECOND removal frame, which production never
sends; they passed while the real path leaked. Both now drive the unplug
through a pump tick, and PadSlots gains three tests pinning the new
invariants. Verified non-vacuous: with the reap neutered, both manager tests
fail with "the pump tick never completed the unplug".

Behaviour notes: this puts UI_DEV_DESTROY on the GameStream control thread's
budget for the first time, and a mask glitch longer than the grace now really
does flap — which is SWEEP_GRACE working as documented, so the constant stays.

Found by the 2026-08-03 force-feedback sweep (B2 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 17:34:47 +02:00
enricobuehler 14502769e0 fix(host/input): rumble comes back when a controller does
android / android (pull_request) Failing after 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / web (pull_request) Successful in 1m9s
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m45s
ci / rust (pull_request) Successful in 6m9s
Unplug a pad mid-session and plug it back in, and roughly half the time it
never rumbles again for the rest of the session.

The removal arm restarted the pad's rumble sequence counter. The client's
reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC
connection and has no reset path, so it still holds whatever the pad reached
before the unplug. Restarting the host counter therefore hands the client a
seq it has already seen, and its wrapping half-space compare drops every
envelope until the counter climbs back past the stored value — up to 128
sends. Since the counter only advances on a level change or a ~120 ms renewal
while a level is non-zero, that spans many separate rumble events, so it reads
as a flaky controller rather than a clean outage.

Whether it bites is decided by how much the pad rumbled beforehand, which is
why it looks intermittent: a pad that never rumbled before the re-plug has
`None` on the client side and always heals.

The counter now survives, matching the sibling pad-state gate — whose comment
eleven lines above already explains that a re-plug must arrive with a still-
newer seq to be accepted. The three clears that actually end the stale lease
move into `clear_pad_feedback`, whose signature deliberately has no seq
parameter so the arm cannot regress by editing.

Covered by a regression test that drives the real wire encoder and the real
client gate, and asserts the pre-fix behaviour is genuinely rejected across
the whole forward window, so it cannot pass vacuously.

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00
13 changed files with 585 additions and 91 deletions
Generated
+32 -32
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2897,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2936,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
@@ -2969,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2995,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3028,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -3058,7 +3058,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"base64",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3115,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3323,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3334,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"android_logger",
"jni",
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3382,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3402,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3434,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3519,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -53,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.23.0"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
@@ -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
@@ -669,6 +669,11 @@ impl GamepadManager {
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the uinput node would outlive the controller. The swept
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
// rumble internally); if that ever changes, consume it like the other two backends do.
self.slots.reap();
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
+96 -20
View File
@@ -62,15 +62,30 @@ impl<P> PadSlots<P> {
self.label
}
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
/// clear for a few frames and returns re-arms nothing.
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
/// frames and returns re-arms nothing.
///
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
/// called `sweep` would keep the detached pad alive for the rest of the session.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
self.sweep_at(active_mask, Instant::now())
}
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
///
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
/// That is what makes it safe to call from a hot pump loop.
pub fn reap(&mut self) -> u16 {
self.reap_at(Instant::now())
}
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
@@ -81,26 +96,37 @@ impl<P> PadSlots<P> {
}
}
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
/// each slot's clock from the mask, then reap whatever has already run out.
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
let mut swept = 0u16;
for (i, slot) in self.pads.iter_mut().enumerate() {
for i in 0..MAX_PADS {
if active_mask & (1 << i) != 0 {
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
self.inactive_since[i] = Some(now); // newly inactive — start the grace
}
}
self.reap_at(now)
}
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
/// and cannot be dropped here.
fn reap_at(&mut self, now: Instant) -> u16 {
let mut swept = 0u16;
for i in 0..MAX_PADS {
let Some(since) = self.inactive_since[i] else {
continue; // active, or never went clear — nothing to complete
};
if self.pads[i].is_none() {
self.inactive_since[i] = None; // the slot went away by some other route
continue;
}
if slot.is_none() {
continue;
}
match self.inactive_since[i] {
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
tracing::info!(index = i, "controller unplugged ({})", self.label);
*slot = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
Some(_) => {} // inside the grace — hold
if now.duration_since(since) >= SWEEP_GRACE {
tracing::info!(index = i, "controller unplugged ({})", self.label);
self.pads[i] = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
}
swept
@@ -161,6 +187,56 @@ mod tests {
PadSlots::new("Test", "test pad", "")
}
#[test]
fn a_single_frame_plus_a_reap_completes_the_unplug() {
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
// with no further frame. Before the arm/reap split the pad survived here forever.
let mut s = slots();
assert!(s.ensure(2, |i| Ok(i as u32)));
assert_eq!(
s.sweep(0b0),
0,
"a frame arms the grace but cannot itself drop"
);
assert!(s.get(2).is_some());
s.expire_grace();
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
assert!(s.get(2).is_none());
assert_eq!(s.reap(), 0, "nothing left to reap");
}
#[test]
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
for _ in 0..10 {
assert_eq!(s.reap(), 0);
s.expire_grace();
}
assert!(
s.get(0).is_some(),
"reap dropped a pad that never went inactive"
);
}
#[test]
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
// comes back must not churn a PnP devnode.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
for _ in 0..5 {
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
}
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
s.expire_grace();
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
assert!(s.get(0).is_some());
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
+52 -14
View File
@@ -217,13 +217,10 @@ impl<B: PadProto> UhidManager<B> {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
@@ -282,6 +279,12 @@ impl<B: PadProto> UhidManager<B> {
mut hidout: impl FnMut(HidOutput),
) {
let now = Instant::now();
// Finish any unplug whose removal frame only armed the grace. The producer emits that
// frame exactly once, so without this a detached pad — the single-pad session being the
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
let swept = self.slots.reap();
self.reset_swept(swept);
for i in 0..MAX_PADS {
let Some(pad) = self.slots.get_mut(i) else {
continue;
@@ -360,6 +363,18 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
}
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
@@ -494,18 +509,36 @@ mod tests {
}
#[test]
fn removal_frame_never_recreates_the_pad_it_swept() {
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
// the bit still being set — so the teardown has to finish on the periodic pump. The
// previous version of this test hand-fed a SECOND removal frame, which is what let the
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
let mut m = mgr();
m.handle(&frame(1, 0b10, 0));
assert!(m.slots.get(1).is_some());
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
// The one removal frame: arms the devnode-churn grace, drops nothing.
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_some(),
"a tick inside the grace dropped it"
);
// Grace elapsed: the next tick completes the unplug, with no further frame.
m.slots.expire_grace();
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_none(),
"the pump tick never completed the unplug"
);
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_none());
assert!(
m.slots.get(1).is_none(),
"a cleared-mask frame recreated the pad"
);
}
#[test]
@@ -551,10 +584,15 @@ mod tests {
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again.
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
m.slots.expire_grace();
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
assert!(
m.slots.get(0).is_none(),
"the pump tick completed the unplug"
);
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
@@ -318,14 +318,10 @@ impl GamepadManager {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return;
}
@@ -345,10 +341,25 @@ impl GamepadManager {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
/// unplug clear the same things.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
}
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the XUSB devnode would outlive the controller.
let swept = self.slots.reap();
self.reset_swept(swept);
for (i, pad) in self.slots.iter_mut() {
if let Some((large, small)) = pad.service() {
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
+96 -5
View File
@@ -629,6 +629,20 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
const RUMBLE_STOP_BURST: u8 = 2;
/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any
/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire
/// index inherits nothing that could buzz the new device.
///
/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the
/// life of the connection because the client gates on it with a wrapping half-space compare and
/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is
/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`].
fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) {
*state = (0, 0);
*seen = false;
*stop_burst = 0;
}
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
@@ -824,11 +838,22 @@ pub(super) fn input_thread(
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
rumble_state[idx] = (0, 0);
rumble_seen[idx] = false;
rumble_seq[idx] = 0;
rumble_stop_burst[idx] = 0;
// stale rumble lease (a lease still ticking would buzz the new pad).
//
// `rumble_seq` deliberately SURVIVES — do not reset it here. The client's
// rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION
// and has no reset path, so restarting this counter strands every later
// envelope for the re-plugged pad until the host climbs back past the
// value the client already stored (up to 128 sends ≈ 15 s of continuous
// rumble, or dozens of separate rumble events). The three clears below are
// what actually kill a stale lease; the sibling `pad_seq` gate keeps its
// value across a removal for exactly the same reason (see the comment at
// the top of this arm).
clear_pad_feedback(
&mut rumble_state[idx],
&mut rumble_seen[idx],
&mut rumble_stop_burst[idx],
);
}
}
InputKind::GamepadArrival => {
@@ -1071,6 +1096,72 @@ mod tests {
}
}
/// A pad re-plug must not strand the client's rumble reorder gate.
///
/// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path
/// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter
/// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do
/// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the
/// counter climbed back past the value the client had already stored — up to 128 sends.
///
/// Drives the real wire encoder and the real gate, so it fails if either side's rule moves.
#[test]
fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() {
use punktfunk_core::input::GamepadSnapshot;
use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2};
// The client half: one per-pad slot, per connection, never reset.
let deliver = |seq: u8, gate: &mut Option<u8>| {
let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400);
let env = decode_rumble_envelope(&d)
.expect("v2 envelope decodes")
.envelope
.expect("v2 tail present");
if GamepadSnapshot::seq_newer(env.seq, *gate) {
*gate = Some(env.seq);
true
} else {
false
}
};
// The host half: one wrapping counter, bumped on every change and every renewal.
let mut gate: Option<u8> = None;
let mut seq = 0u8;
// A long rumble before the unplug pushes the client's stored seq well past zero.
for _ in 0..100 {
seq = seq.wrapping_add(1);
assert!(deliver(seq, &mut gate));
}
assert_eq!(gate, Some(100));
// The pad is unplugged mid-buzz: the lease is cleared, the counter is not.
let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST);
clear_pad_feedback(&mut state, &mut seen, &mut burst);
assert_eq!(
(state, seen, burst),
((0, 0), false, 0),
"lease not cleared"
);
// It returns on the same wire index and the game rumbles again: the very first envelope
// has to reach the actuator.
seq = seq.wrapping_add(1);
assert!(
deliver(seq, &mut gate),
"first envelope after a re-plug was dropped by the client's reorder gate"
);
// Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and
// stays rejected for the whole forward window — this is the bug, reproduced.
let mut stranded = Some(100u8);
assert!(
(1..=100).all(|s| !deliver(s, &mut stranded)),
"test is vacuous — a restarted counter should have been gated out"
);
}
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
#[test]
fn gamepad_accumulator() {
+111
View File
@@ -0,0 +1,111 @@
Wire-compatible with 0.23.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host (or the other way round) streams exactly as it does today.
This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. The other big one is **Automatic bitrate**, which could decide early in a session that your connection was worth 20 Mb/s and then hold you there for the rest of it: a sweep of that path fixed eleven separate faults, and a session that used to need seventeen minutes to climb out now does it in about sixteen seconds. Alongside those: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all.
## New
- **The Linux and Windows app schedules frames onto your screen instead of throwing them at it.** Until now the desktop app showed each frame the instant it finished decoding, so every frame carried whatever jitter the network and the decoder had just added — the same problem the phone and Apple apps had before their rebuilds. It now has the same two-way choice under **Prioritize**: **Lowest latency** (the default, and what you have today) or **Smoothness**, which holds a small buffer of frames and releases one per screen refresh so an uneven stream plays evenly. The buffer is 13 frames, your pick. On screens where the graphics driver holds a queue of pending frames — which is most Windows machines with an AMD card, and any machine under a compositor that does the same — the app now also stops that queue from building up, which is where a whole extra refresh of delay used to come from.
- **V-Sync and "Follow variable refresh rate" are real settings now.** Both rows have existed for a while and neither did anything: the app picked one presentation mode at startup and kept it. **V-Sync** off asks for the tear-capable mode and says in the stats overlay which mode it actually got, because a graphics driver is free to refuse. **Follow variable refresh rate** lets a VRR/FreeSync/G-SYNC display follow the stream's own cadence rather than a fixed grid; where your driver supports the newest tear-free presentation mode it turns itself on, and elsewhere it stays off unless you ask for it, because on those drivers it costs more than it wins. Whether you actually *have* variable refresh is now measured from what your display does rather than believed from what it claims — no platform reports it honestly enough to trust.
- **The Steam Deck plugin covers every setting, in a sidebar that fits on screen.** Nine of the client's settings had a row here and about twenty did not, so a Deck that never sees a desktop could not reach its own decoder, full chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio device. All of it is here now, split across a left rail of categories — the layout SteamOS's own Settings uses — so every page fits without scrolling and nothing is more than one hop away. The categories, their order and the wording match the console's settings screen, because two different orders for one set of settings is how people stop trusting either. Two things are deliberately absent and named as such: which physical controller is player 1, and the remembered window size.
- **The stats overlay has an off switch on the Steam Deck.** Reported from the field: there was no way to turn it off from the plugin, and it defaults to on — so a Deck configured only through this panel streamed with the overlay up and no way here to put it down. The keyboard shortcut and the three-finger tap both existed but neither is discoverable from a couch. The row now sits at the foot of the section, worded the way the console words it.
- **"Forward controllers" — an off switch for setups that hand the pad over another way.** If your controller already reaches the host by USB passthrough (VirtualHere and friends), or is simply plugged into the host, it arrived there twice: once as the real device and once as the virtual pad the client built from the same hands. Games read both, so a stick drifts against the second, centred pad and menus take every input twice. The new per-client setting defaults to on — today's behaviour — and can be set per profile. On Linux and Windows it does more than stop sending: opening a controller is what *claims* it, so with this off the app never opens one at all, leaving the device free for the passthrough tool to bind. The consequence is stated at the setting: the controller escape chord is read off forwarded pads, so it is unavailable while this is off. The Apple and Android apps never claim the pad, so they keep their chords and only stop sending — losing an Apple TV's only way out of a stream would have been the worse bug.
- **Plugin output reaches the web console's log page.** Plugins do not run as children of the host, so nothing they printed ever passed through the host's own logging and the Logs page could not show a single plugin line. The fallback was a terminal on the host box — and on Windows, no log file at all, so a failing plugin was diagnosable only by stopping its task and re-running it by hand. That is exactly what the console exists to avoid, and it left the one question a stuck user asks with no answer. The plugin runner now sends its output to the host, where it joins everything else under one timeline; the Logs page grows a **Host / Plugins** switch beside the level filter, and an empty Plugins view says the thing that is usually actually wrong (the runner isn't running) rather than telling you to adjust the filter.
- **The Windows app can show you where its log lives.** "Check the client log" never said where that was. **Settings ▸ About** grows an **Open log folder** row, and the message you get when a stream fails to start now names the path. It opens the folder rather than the file, so the previous session's rotated log is in reach too.
## Improved
- **The stats overlay's display figure splits into two numbers.** It used to be one number covering everything between "decoded" and "on your screen", which is two very different things stacked: the app's own work, and the wait for the screen to accept the frame. They are now separate, so a high figure diagnoses itself — if the second number dominates it is the refresh rate floor or a queue in the driver, and if the first dominates it is us. The Detailed tier also names the presentation mode actually in use, which answers most "why is my display number a whole refresh" questions on its own.
- **The full-chroma explanation names what it actually needs.** The Windows app's caption said 4:4:4 was "HEVC only, and only where the host can encode it", which sends people hunting through host settings; the web console's explainer was similarly vague. Both now name the real requirement. Host-side, the log line that reports the decision stopped being named after the capture side when it was never about capture — a field report burned real time hunting a capture problem because of it — and a session that asked for full chroma and did not get it now says which end declined.
## Fixed
- **0.23.0 could freeze the picture on Android and on the Linux and Windows app.** 0.23.0 started cutting each frame into pieces and sending them as they were produced. The receiving side then sized every arriving frame at the largest a frame is ever allowed to be — many megabytes — so its budget for frames-in-progress ran out after about three. With normal traffic that meant twelve frames arriving and nine of them thrown away before a single byte could be placed: on any link that reorders packets at all, a loss storm that does not end. Frames never complete, the picture freezes, and the client keeps begging for a fresh one. A second fault in the same path killed one frame in roughly every 1408 outright — about once every twelve seconds at 120 fps, each costing a freeze and a recovery. Only Android and the Linux/Windows desktop app ever took this path, which is why it read as a platform-specific video fault in the field; the Apple apps and the Windows in-process client were never affected. **Updating the client is what fixes it** — a 0.23.0 client still has the bug whatever host it talks to.
- **Automatic bitrate could pin a session at 20 Mb/s for the rest of its life.** Sessions start at 20 Mb/s and climb, and a host refuses a climb while it is briefly behind on encoding. But an ordinary hitch at startup — which arrives while the rate is still at that 20 Mb/s floor — was enough to make the host refuse, and the client cannot tell a momentary refusal apart from an encoder that genuinely cannot go faster: both arrive looking identical. Two of them and the client concluded there was a permanent ceiling. Escaping cost 12.5% per minute, so crossing the gap to what a fast link could actually carry took upwards of twenty minutes, and in practice often never happened — which is why "my bitrate is stuck at 20" has been such a persistent report. Three things changed: the host no longer refuses climbs merely because it is running in its own high-effort mode (that mode exists so it *can* keep up — refusing climbs once it is working refuses the thing that worked), the client re-checks after 12 seconds instead of a minute and backs that off only if the limit proves real, and a request granted in full is taken as proof the limit is gone rather than nudging the guess upward. Measured end to end: a session pinned at 20 Mb/s under a 300 Mb/s connection now reaches 150 Mb/s in about sixteen seconds, where the same case previously needed around seventeen minutes.
- **Automatic bitrate drew permanent conclusions from single moments.** Five more faults of the same shape. The reference points it compares against — network delay, decode time, encode time — could arm off *one* window, and since each is a rolling minimum that one window became the floor; a calm window followed by ordinary motion then read as congestion on a link that was never the problem. Changing resolution or refresh rate re-based only some of what it had learned, so switching *up* a mode was scored against the old mode's easier numbers and cratered the rate instead of raising it. The high-water mark that bounds how far every later climb may step never decayed and was raised by damaged windows — a stall's backlog arriving at once, or a flush's queue — which are exactly the windows that overstate what was delivered. The decoder ceiling latched *at* the rate that had just choked, authorising a climb straight back into the failure, and a network hiccup could be mistaken for a decoder one. And a manual bitrate cap bound only ceilings the speed test had learned, so it did nothing at all if the session already started above it.
- **Your connection was measured including the redundancy sent to protect it.** Two checks compare what actually arrived against what the encoder was asked to produce, and both counted every byte accepted — packet headers, audio, and the extra error-correction data the host adds *in answer to* packet loss. So the measurement rose with the loss it was supposed to detect: at 25% redundancy the check passed while the encoder was emitting barely half its target, and the permanent high-water mark inherited that inflation for good. The signal was weakest on exactly the lossy links it exists for. Only the actual video payload is counted now.
- **A host that re-chose the rate never told the client.** When a host rebuilds its pipeline it can legitimately re-pick an Automatic rate — a 1080p session mirroring a 4K panel needs roughly three times what it negotiated — but that number never reached the client, which kept its own stale copy as the basis for every later step. So a client believing 20 Mb/s while the host encoded 60 would compute its first climb from the stale figure and ask for 40: a request to go *down*, paying for an encoder rebuild to get there. The host now tells the client whenever the applied rate moves, using a message that already meant exactly that and which existing clients already handle arriving unprompted — no wire change, and older clients are unaffected.
- **On KDE hosts, a hidden mouse pointer stayed on screen.** Since 0.22.0 a KDE stream always had a cursor and it never went away again — not in a game, not in Big Picture, not with a controller in hand. The host was blending an arrow onto the picture forever because the signal KDE sends to say "the pointer is not here" was being ignored. It is honoured now, so a game that hides the pointer mid-stream actually hides it. GNOME hosts keep the behaviour they have, because there the same signal means something different and honouring it made the cursor flicker.
- **A KDE host could stream a copy of your monitor instead of its own screen.** KDE remembers display arrangements per set of connected screens, and one of the things it remembers is "this screen mirrors that one". Because the streamed screen carries a stable name, any arrangement that had ever recorded it as a mirror got that re-applied on every later session that reproduced the same set of monitors — which is why it looked so arbitrary: the stream cloned the panel whenever exactly one monitor was live, and behaved normally the moment the others came back. A mirroring screen is not its own desktop; it takes the physical screen's size and viewport instead of the one you negotiated. The streamed screen now says outright that it mirrors nothing, and if it ever finds itself mirroring anyway it says so in the log instead of leaving that as something only you can see.
- **Waking a Windows PC no longer fails the first connection.** A host that had just woken refused connections with a message claiming its virtual-display driver was not installed, on machines where it plainly was. Resuming re-registers that driver while the rest of the wake is still going, and a client reconnecting a second later landed inside the gap. The host checked exactly once, read the gap as a dead driver, and answered a device that was seconds from ready by resetting it — a reset that was then refused, because the host itself was holding the driver open, and reported as a success anyway. It now waits out a driver that is mid-wake instead of resetting it, reports what a reset actually did rather than what the device looks like afterwards, only ever runs one reset when several sessions arrive at once, and says in the log how long it waited and what it saw.
- **AV1 streams quietly decoded in software.** Every AV1 session opened a software decoder no matter what your graphics card could do, because of how the decoder was being looked up by name. Each frame then failed the hardware check and the session walked down its fallback ladder mid-stream — around three seconds of black, with "hardware decode active" already printed and every hardware check green. Decoders are now chosen by what they can actually do. H.264 and HEVC pick exactly what they always did, and every decode log now names the decoder in use, which was the whole diagnosis and no line said it. Separately, a software-decoded HDR stream used to be shown washed out with no warning and an overlay badge claiming a tone-map that never ran; it now warns, and the badge distinguishes the two.
- **A 120 fps session sent 132 frames a second.** The option that runs the virtual display at a multiple of the session's rate promises extra display refreshes without extra frames on the wire, but it only enforced a floor between frames — so content that always had a frame ready settled about ten percent above the rate you negotiated. That is ten percent more bitrate, encode and decode for frames a 120 Hz screen can only drop. The pacing now holds the long-run average at the negotiated rate while keeping the same room for jitter, and a source running at or below the rate is never delayed.
- **On an iPad, Escape handed the mouse back to iPadOS.** iPadOS releases the pointer by itself when you press Escape — its built-in "let me out". But Escape in a stream is a game key, not a request to give the mouse back, so pressing it for an in-game menu silently cost you the capture until you clicked into the video again. An unwanted release is now re-requested, briefly and a bounded number of times. Every deliberate way out (the menu, the two keyboard chords, switching apps) is untouched, and while the re-grab is in flight the local cursor stays hidden and pointer movement is held, so it reads as "Escape did nothing to my mouse" rather than a cursor blinking in and out.
- **The Windows app showed settings something else had already changed.** A field report said a codec setting "changed by itself" between sessions. Nothing writes it back — what they saw was a stale copy. The app read the settings file once at startup, but it is not the file's only writer (the stream itself stores its window size, and the console and the Deck plugin save too), so the page showed values another part of Punktfunk had already replaced — until you touched any row, at which point the value visibly jumped. It re-reads the file on entry now, and on the profile path too. A related one: an older build's save used to *drop* settings a newer client had written, and now carries them through untouched.
- **A Windows host could fight your sound settings.** When no usable playback device was left — a display isolated, the speakers excluded, the microphone holding a virtual device — the host re-ran its whole audio setup every two seconds for as long as it took anyone to notice, including writing your default recording device back each time. That silently undid any recording-device change you made while a stream was up. An impossible arrangement is now recognised as impossible: the host says so once, with the devices it found and why each was rejected, then waits for a device to actually appear or disappear instead of retrying a verdict that cannot change. The default recording device is only asserted when the plan changed or something else moved it.
- **PlayStation Accessories stopped offering a controller update that could never finish.** The emulated DualSense reported a 2021-era firmware version, so Sony's app — and games using their controller library — offered an update that can only ever end in "can't complete the update", since the virtual pad speaks no update protocol. A real pad plugged in directly reads as up to date, which made the prompt look like Punktfunk having corrupted the controller. It now reports a version above anything Sony has shipped, rather than chasing their latest and resurrecting the prompt with every Sony release.
- **Rumble stopped for good after unplugging and plugging a controller back in.** Reconnect a pad mid-session — or have Bluetooth drop it for a moment — and roughly half the time it never rumbled again for the rest of that session. Everything else about the controller kept working, which is what made it look random rather than broken. The host restarts a counter when a controller goes away, and the client uses that counter to throw away force-feedback that arrives out of order; because the client does *not* restart its side, everything sent after the reconnect looked older than what it had already seen, and it dropped the lot until the host counted back past where it left off. How long that took depended on how much the pad had rumbled before you unplugged it, so a quiet session healed at once and a busy one stayed silent. The counter now survives a reconnect, which is what the same code already does for the controller's buttons and sticks.
- **An unplugged controller stayed plugged in as far as the game was concerned.** Unplug a pad mid-session and the game kept seeing a connected controller that never pressed anything again — it simply never went away. If it was your only controller, this happened every time. Tearing the virtual pad down takes a brief settling delay first, so a momentary glitch cannot make a device disappear and reappear, but the second look that finishes the job only ever happened when *another* controller sent something afterwards. With one controller there is nothing left to send it. The teardown now completes on the host's own clock, about a third of a second after the pad goes, whether or not anything else is connected — and a glitch that comes back within the delay still leaves the device alone.
- **The speed test overstated your connection, and Automatic bitrate believed it.** Throughput was worked out by dividing what the client received by how long the *host* spent sending — a window wrong on both ends, since the host's clock stops the moment its send window closes, while the data is still draining through the network toward you. On a gigabit link a test aiming at 2 Gb/s "measured" 1266 Mb/s and set an 886 Mb/s ceiling the link could never carry, permanently, for the whole session. It is now measured over the interval the client actually received across, and video around the test contaminates neither half of the sum. Two guards ride along: a manual bitrate cap now binds no matter what any test concludes, and a decoder that keeps drowning below the link's ceiling has that noticed and remembered, instead of a 3060 second cycle of climbing back into the same wall and flushing — a 1440p120 case cost a dropped-frame burst every cycle.
- **Plugins on Linux could not reach anything else on the machine.** Reported by a user who could not get the VirtualHere plugin to talk to their VirtualHere client, and the reason was ours: the plugin runner was given its own private temporary directory. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk through that directory. So a plugin would launch a vendor program happily and then never be able to reach the service behind it — while the identical command worked perfectly in the operator's own terminal. No setting could fix it. Plugins now see the real one.
- **Android: a decoder hiccup turned into a burst of broken frames.** When the decoder handed back an input slot it could not actually fill, both the slot and the video in it were dropped on the floor — leaking one of the decoder's input buffers each time, until the pipeline ran out of them entirely and the resulting keyframe storm read as a decode fault rather than the bookkeeping mistake it was. The dropped video also left a hole nothing asked to repair, so the damage was free to reach the screen. Both go back now.
- **Android: the app could pin the wrong refresh rate for a whole session.** Asking a phone for 120 Hz is a request the system may refuse — Smooth Display off, battery saver, thermal limits, an OEM's own governor. The app took the answer on faith and could only ever revise it downward, so a refused request left it aiming at screen refreshes that never arrive, for the rest of the session, with no way back. It now corrects in both directions: instantly toward a faster screen, and toward a slower one after eight consecutive agreeing observations, because one slow sample is a missed callback and eight in a row is a display that really did slow down. Two related fixes: the app now holds back when the system stops confirming that frames reached the screen, instead of feeding a queue that has stopped draining until the decoder stalls; and the timing margin it adapts now widens on frames that actually missed the screen rather than on ordinary pacing, which on a healthy phone had been walking it to its ceiling and re-imposing the delay the 0.23.0 work had just measured away.
- **Windows stutter reports blamed the wrong thing.** The host tries to tell you whether frames stopped arriving because the *game* went quiet — a menu, a loading screen, an ordinary hitch — or because the display path did. Its witness for the display path never worked: it was reading timestamps in one unit and comparing them against another, so it saw zero display activity always, and every quiet stretch was reported as the game going quiet. The category the whole thing exists to catch was unreachable. It reads correctly now, and it can tell "the witness was working and saw nothing" from "the witness was not working", which are opposite conclusions. If you have a stall report from an earlier version, its verdict is not evidence.
## Under the hood (for developers)
- **Versions.** All unchanged from 0.23.0: wire protocol 2, C ABI 14, virtual-display driver protocol 6, Windows virtual-gamepad channel 3. No new negotiated capability bits — `VIDEO_CAP_MULTI_SLICE` (`0x80`) was already the video-caps byte's last free bit and nothing needed the next one. The only C-header change is documentation: `PunktfunkProbeResult::elapsed_ms` now means the client-measured receive interval (see the ABR entry), and the probe clamp comment corrects 3 Gbps → 10 Gbps to match `MAX_PROBE_KBPS`.
- **Slice-streamed reassembly.** Every ordinary access unit on the streamed path is now opened by a sentinel header (the block flush at `MIN_STREAM_BLOCK_SHARDS` guarantees it), and the reassembler was sizing those at `max_frame_bytes` — 864 MiB after the QUIC handshake clamp. Each AU therefore allocated and zeroed a multi-megabyte buffer, and `IN_FLIGHT_BUF_FACTOR × max_frame_bytes` was exhausted after ~3 concurrent frames. A sentinel now sizes to its own block extent (a slice sentinel by its wire base, a legacy one by its full-K position) and grows as later blocks or the final block's totals reveal more, with the in-flight budget re-checked on growth. Separately, `flush_block` drained `pending` to empty when the AU length was an exact multiple of the shard payload, leaving `finish_streamed` to seal a final block of one zero-padded filler shard whose derived base overlapped the block flushed a moment earlier — correctly read as a lying header, killing the AU. A flush now retains one whole shard, restoring the invariant `StreamedAu::pending` already documented.
- **Desktop presentation engine.** `pf-presenter` gains `present_pace.rs` (pure state + arithmetic): `FrameStore` (newest-wins slot or smoothing FIFO with preroll-to-capacity, drop-oldest overflow and underflow re-arming the preroll — the Apple/Android semantics, with `qDrop`/`qDry`), `LatchClock` (panel grid from `VK_KHR_present_wait` glass stamps, publishing the host-facing `LatchGrid`), and `PresentGate` (one undisplayed present in flight on FIFO surfaces, 100 ms stale force-open; inert on MAILBOX/IMMEDIATE and without present timing). Settings ride the keys the Apple client already writes into the shared profile catalog — `present_priority` / `smooth_buffer` / `vsync` / `allow_vrr`, now tier-P routed — and `PresentPriority::resolve` mirrors the Android reference exactly, so a profile authored on any client means the same thing everywhere. PyroWave collapses smoothness to latency (its plane-ring retirement assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot).
- **Present-mode ladder and `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.** Mode selection is a preference ladder rather than a constant: V-Sync off → IMMEDIATE, FIFO_RELAXED, then tear-free; V-Sync on + VRR + fullscreen → LATEST_READY first; otherwise MAILBOX then FIFO. The extension postdates ash 0.38's headers (Vulkan 1.3.281), so the mode value, extension name and `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` are hand-declared. **Trap:** the surface advertises the mode even with the extension disabled, and using it on that basis is undefined — the ladder only offers it where the device feature came back true and was enabled. The gate predicate splits in two on purpose: `needs_glass_gate()` is FIFO and FIFO_RELAXED only (gating on a driver that already retires stale images pays the serialisation twice — the ~27 ms an A/B measured), while `vblank_locked()` is the whole FIFO family including LATEST_READY, since the VRR cadence probe's premise still holds there. Measured on .21 (NVIDIA 610.43.03, GNOME/Wayland): `display 2.6 ms (pace 0.6 + latch 2.0)` on the VRR ladder against 1328 ms for FIFO + gate, and 1.4 ms for MAILBOX. ⚠ Unverified off that box: whether Windows (Vulkan WSI through DXGI) or Intel drivers expose the mode at all — `surface present modes` and `fifo_latest_ready=` are logged unconditionally, so one run on any box settles it. `allow_vrr` is automatic only where the device offers the mode, and stays behind `PUNKTFUNK_VRR_FIFO=1` elsewhere.
- **VRR detection is measured, never queried.** No portable query exists (SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan) and the platforms that do answer have been caught lying. The discriminator is quantization: `CadenceProbe` folds each present delta to its distance from the nearest multiple of the *display mode's* period — not the learned one, which is circular when the stream runs below panel rate — and takes the median. Tri-state: Unknown below 24 deltas, after a display change, and outside a FIFO-family mode (MAILBOX decouples presents from scanout, so its stamps are never grid-quantized; the same panel read `no` on FIFO and `yes` on MAILBOX in one minute).
- **`PanelGrid`, shared.** The panel-period learner is extracted to `punktfunk_core::phase::PanelGrid` and adopted by the Android, iOS and desktop presenters, replacing three copies with the same bug: a seed capped at the display mode's refresh, when the mode is only a *claim*. Narrowing is immediate; widening needs eight consecutive agreeing observations and then takes the narrowest of them. `preferredDisplayModeId` on Android is a request the system may refuse, which is the case that made this a session-length failure.
- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches.
- **ABR sweep — eleven defects.** Wire format and ABI untouched throughout; 34 abr tests plus 2 host tests. Host side: `cadence_degraded` was latched true for as long as the session was *escalated* (adaptive capture depth or pipelined retrieve), independent of whether encode was still missing deadlines — and escalation needs only ~20 net behind-frames, which a startup hitch supplies while ABR is still in slow start at the 20 Mbps default. The rule moves into `encode_behind_cadence`: an escalated session is still judged strictly (any net behind-frame keeps it flagged, where an unescalated one gets the full bucket), but escalation alone no longer flags it. `adopt_built_bitrate` now publishes the rate a rebuilt pipeline actually opened at (`build_pipeline` re-resolves an Automatic rate whenever the source delivers an unnegotiated size — the mirrored-panel case — and the encoder's clamp can land below what control already acked), pushed to the control task as the existing 9-byte `BitrateChanged`. Client side: all three rolling baselines (OWD, decode, encode) now require `BASELINE_MIN_WINDOWS` of evidence via one shared `score_baseline` — the three copies had drifted apart, and `on_ack` clears the encode baseline after every self-requested decrease, re-opening the one-sample hole each time. A mode switch rebases decode and OWD as well as encode, and drops `proven_kbps` with them. `proven_kbps` is raised only by *clean* windows (it never decays and holds permanent authority over climb step size, and the windows that overstate delivered throughput are precisely the damaged ones). `decode_cap_kbps` latches just *under* the choke rate, inside the ±1/8 band the evidence already required, and credits a bare jump-to-live flush only where the decode signal is absent. The two throughput-driven gates are fed data-shard payload counted at the reassembler's routing decision rather than `bytes_received` (which includes headers, FEC parity, probe filler and audio — at 25% FEC the utilization gate passed with the encoder emitting ~55% of target). `PUNKTFUNK_ABR_MAX_MBPS` binds at construction, not only on probe-learned ceilings, and a session starting above its cap steps down to it (no congestion signal will ever find that — the link is fine, the cap is policy). Cap escape re-probes after 12 s, doubling each time the lift is immediately re-learned, and a request granted **in full** at or above the cap drops the cap outright rather than nudging it +12.5%. An ack above the current ceiling raises the ceiling to meet it (`set_ceiling` still only ever raises, still clamps to `PUNKTFUNK_ABR_MAX_MBPS`), so a host-initiated re-target is not immediately stepped back down. Finally, a `SetBitrate` dropped by a full control queue no longer counts toward `MAX_UNACKED` — three of those retired the controller for the session while blaming an "older host".
- **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt.
- **Force-feedback lifecycle.** `native/input.rs` no longer resets `rumble_seq[idx]` on `GamepadRemove`: the client's v2 reorder gate is per-connection with no reset path, so restarting the sender's counter stranded every later envelope behind `seq_newer` until it climbed past the stored value (up to 128 sends). The lease clears that actually end a rumble move into `clear_pad_feedback`, whose signature omits the seq so the arm cannot regress. `PadSlots` splits `sweep` (arm the grace from a state frame) from a new `reap` (complete the unplug, no frame required), and all three backends reap from the periodic pump they already run; `reap` never arms a clock, so it cannot invent an unplug however often it runs. Regression tests drive the unplug through a pump tick rather than the hand-fed second frame the old tests used — that second frame is not something the producer ever sends, and its absence is what hid both defects.
- **ETW present witness.** The consumer was opened without `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, so `ProcessTrace` converted every event's `TimeStamp` to FILETIME regardless of `ClientContext=1` — FILETIME ticks are ~4 orders of magnitude above QPC, so every comparison was false, `etw=none` always printed, and `classify()` convicted every compose-silence hole as CONTENT-SILENCE, making FRAME-GENERATION unreachable. Two comments asserted the wrong contract and now state the real one. `summary()` and `window_counts()` merge into one `window_report()` (one ring lock, one anchor — they could previously disagree about the same hole); `present_history`/`queue_history` now mean witness *liveness* within a 5 s lookback ending at the hole's start rather than "an event ever sat in the ring", and the static ring is cleared per session. Conviction thresholds untouched. **Any stall verdict from before this fix is void.**
- **Hardware decoder selection.** `avcodec_find_decoder(id)` returns the registry's first decoder for the id and upstream orders the native AV1 decoder *last* on purpose ("hwaccel hooks only, so prefer external decoders"), so all three hardware backends were opening libdav1d — which ignores `hw_device_ctx` and never calls `get_format`. `find_hw_decoder` walks `av_codec_iterate` and takes the first decoder whose `avcodec_get_hw_config` advertises the backend's surface via `HW_DEVICE_CTX`, so a build without a usable hardware decoder fails at open in milliseconds and the ladder runs there. Registry order still wins among capable decoders; the software path keeps the id lookup deliberately (libdav1d is the fastest CPU AV1, and the native decoder has no software path).
- **KWin mirroring.** `OutputConfigurationStore` persists `replicationSource` per *setup* (the exact connected-output set, matched by EDID/connector), and our virtual output carries a stable name by design — so a stored mirror entry re-applies on every session reproducing that monitor set. `applyMirroring` overrides scale and render offset to the source's, and the protocol states that a mirroring output may not be in the output order, so the primary assertion silently stops meaning anything too. The topology config now includes `set_replication_source(ours, "")`, gated on output-management v13 where the request appeared (wayland-rs does not range-check requests; an out-of-range opcode kills the connection). `extend`/`auto` issue no topology calls by design, so they get `clear_replication_source`, which enumerates and applies only when our output really is mirroring. The device's `replication_source` event is now read and warned on.
- **KWin cursor visibility.** Two producer contracts meet on `SPA_META_Cursor` id 0. KWin rewrites cursor meta on every enqueued buffer and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream — covering both a globally hidden cursor and a client null-cursor surface — so there id 0 *is* the hide. Mutter only rewrites meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames, and honouring those flickered the cursor off between hovers. A flag rides from the backend that created the output through `capture_virtual_output` into the parser's `CursorState`; the portal-monitor path stays on the stale-meta contract (only Mutter's HDR mirror routes through it today).
- **Plugin runner and logging.** `punktfunk-scripting.service` drops `PrivateTmp=yes` and adds `/tmp` to `ReadWritePaths` (which `ProtectSystem=strict` would otherwise make read-only) — VirtualHere's client IPC is the `/tmp/vhclient` + `/tmp/vhclient_response` FIFO pair, and X11 is `/tmp/.X11-unix`. The runner tees stdout to `POST /api/v1/plugins/logs`, joining the host's ring under one cursor with target `plugin:<name>`; stdout stays authoritative, the queue is bounded and drops oldest (then reports how many), the shipper backs off when the host is away and re-sends a batch the host refused. `plugin_may_access` is an exclusion list, so `/plugins/logs` is reachable today only because it does not match `/ui-credential` — now asserted directly, since a change there would silence plugin logs with no other symptom. Two shipper bugs fixed with tests that fail against the previous code: the recursion guard was held across the whole `await fetch` and `enqueue` checked it (so every line logged while a POST was open was dropped — worst exactly when the host is slow), and an explicit `flush()` hit the same guard and returned having sent nothing, which is the shutdown path where the last lines say whether shutdown was clean.
- **Gamepad claiming and the new setting.** `forward_gamepads` is tier-P (profile-routable), default on. On Linux and Windows it prevents the session opening any pad slot and enables no Valve HIDAPI drivers, because opening a controller is what claims the device node and a claimed device cannot be bound by a passthrough tool. Menu navigation is unaffected (the launcher still opens the active pad, and a session supersedes menu mode either way). Apple and Android claim nothing, so they gate only the wire sends; Android does stop its DualSense and Steam Controller 2 USB captures, which do claim.
- **Virtual DualSense firmware version.** The feature report `0x20` update version moves `0x0154``0x0999`, above anything Sony has shipped, in both blobs (host uhid and the Windows driver; the DualSense Edge shares them). The old value existed to keep the kernel and SDL on the flag0 `COMPATIBLE_VIBRATION` convention, but `parse_ds_output` has since learned `COMPATIBLE_VIBRATION2` (firmware ≥ 2.24), so writers that read the version now use the v2 flag; both conventions land in the same rumble plane.
- **Windows client settings store.** `Settings` gains unknown-key passthrough, matching the contract `SettingsOverlay::extra` already gave profiles — additive, empty on every existing store, and an empty map serialises to nothing so no file churns. The page re-bases on the file at entry, and the profile-scope commit arm reloads before cloning (it was diffing overlay absorption against stale globals). `save()` was already temp+rename.
- **Environment.** `PUNKTFUNK_PRESENT_MODE` gains explicit `mailbox` and `fifo_relaxed` arms (both previously folded into the default with every typo) and falls back to the settings rather than to mailbox on an unknown name. New: `PUNKTFUNK_PRESENTER=arrival` (disables the whole desktop pacing engine for a field A/B without a rebuild), `PUNKTFUNK_PRESENT_DEBUG`, `PUNKTFUNK_VRR_FIFO=1`.
- **Android release channel.** A `vX.Y.Z` tag now publishes to Play **production at 100%**, not alpha — production access came through 2026-08-01. Canary is unchanged on `internal`, and its run-number version codes always outrank production so testers keep the newer build. Play's "What's new" gets its own file, `docs/releases/whatsnew/vX.Y.Z.txt`, capped at 500 *characters* per language (`•` is three bytes in UTF-8, so a byte count can reject a legal file). A tag without that file fails the android job at step 1, before the build: Play does not show an empty "What's new" when the file is missing — it carries the previous release's text onto the new version, which is the same shape as the v0.22.3 notes announcing a feature that tag never contained. The gate also rejects a file byte-identical to another release's. `android-promote.yml` is the lever for promoting a tested build, halting a rollout, or rolling production back onto an older version code, with `dry_run` defaulting to true.
+6
View File
@@ -0,0 +1,6 @@
• Automatic bitrate no longer gets stuck at 20 Mbps for a whole session. It now climbs to what your connection really carries in seconds, not minutes.
• Fixes a fault from 0.23.0 that could freeze the picture on a busy connection.
• Fixes a refresh-rate mix-up that left the picture juddering for a whole session.
• A decoder hiccup no longer causes a burst of broken frames.
• Controllers rumble again after a reconnect.
• New "Forward controllers" switch, for pads that reach the PC another way.