Compare commits

...
Author SHA1 Message Date
enricobuehler 8983ec04b9 Merge pull request 'feat(pad-audio): DualSense voice-coil haptics + speaker, host to client' (#23) from feat/android-pad-audio into main
audit / bun-audit (plugin-kit) (push) Failing after 30s
audit / cargo-audit (push) Successful in 35s
apple / swift (push) Successful in 1m20s
audit / bun-audit (sdk) (push) Failing after 23s
audit / bun-audit (web) (push) Failing after 19s
audit / pnpm-audit (push) Successful in 12s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m9s
android / android (push) Successful in 6m30s
audit / license-gate (push) Successful in 6m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
deb / build-publish-client-arm64 (push) Successful in 3m8s
deb / build-publish (push) Successful in 4m48s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
arch / build-publish (push) Failing after 10m5s
ci / rust (push) Failing after 7m23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
deb / build-publish-host (push) Successful in 5m34s
release / apple (push) Successful in 9m30s
apple / screenshots (push) Successful in 5m56s
windows-host / package (push) Successful in 18m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 2m16s
flatpak / build-publish (push) Successful in 18m26s
docker / deploy-docs (push) Successful in 18m42s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 2m10s
2026-08-04 21:56:37 +00:00
enricobuehler d27e62f7c9 fix(pad-audio): close the twelve findings the sweep left open on this branch
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 41s
ci / web (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 1m59s
ci / rust-arm64 (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m37s
android / android (pull_request) Successful in 4m16s
ci / rust (pull_request) Failing after 10m50s
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3).
Four of them are the difference between a feature that works and one that fails silently.

**B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`,
which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the
0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics
silent for the rest of that pad's life — no error, nothing in a log, and the host happily
streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo
(flag0 with both bits clear); written EP0-direct when the stream starts and again after a
rumble stop while a stream is live, because the stop report re-mutes on its way past.

**B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out
inside `plan()` only. The watchdog, Follow mode and the parked default all go through
`judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped
"DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could
ever catch one. It now refuses them by identity.

**B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16
index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire
pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings
lossless by construction. An existing test had pinned the bug in place, asserting that wire
pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically.

**B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade
only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and
`set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the
host emitted nothing. The input task now compares the live registry against what the last
arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic
when nothing changed.

The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is
declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed
provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap`
retries, so a host that started while the audio stack was busy recovers at the next connect
instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one
per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent
arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi
derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than
spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail
in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap
tracker from a foreign sequence space (R14).

Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0
with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20,
punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean.

Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no
pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the
sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound
ephemeral port. Neither is this change.

Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense
since the merge.
2026-08-04 23:55:47 +02:00
enricobuehler 0a72959ef7 Merge main into feat/android-pad-audio
86 commits of main, including the whole M1-M12 haptics sweep. Twelve conflicting files;
three of them were more than textual.

**The capability bits collided.** Both branches allocated the SAME wire bits for DIFFERENT
features: `client_caps 0x04` and `host_caps 0x20` are redundant desktop audio on main and
pad audio here. Merged naively, a peer would negotiate one and get the other. Pad audio
moves to the next free bits — `CLIENT_CAP_PAD_AUDIO = 0x08`, `HOST_CAP_PAD_AUDIO = 0x40` —
and the `abi.rs` mirrors move with them (their compile-time equality assertions caught the
mismatch, which is exactly what they are for).

**Both branches also claimed ABI v15.** Main's shipped (the rumble-policy floor), so the
pad-audio surface becomes **v16**.

**`native/input.rs` would have reintroduced a fixed bug.** This branch resets
`rumble_seq[idx]` on pad removal; M1 established that the client's reorder gate is
per-connection with no reset path, so restarting the host counter strands every later
envelope until it climbs back. Took main's seq-preserving `clear_pad_feedback` and kept only
the branch's `pad_streams.stop(idx)`.

The rest: `wiring_plan::plan` now delegates to main's `plan_with_formats`, so the pad-endpoint
filter moved into that body and the predicate behind it is factored out as `is_pad_render`
(also what B10 needs); `Ds5Feedback::AUDIO` derives from main's `REPORT_ID_LEN` like its
siblings; `AudioCtl` joins the explicitly-listed unhandled variants so the guard-false case is
covered rather than swept up by a `_`; `include/punktfunk_core.h` regenerated rather than
hand-merged.
2026-08-04 23:27:06 +02:00
enricobuehler 2d223274fc Merge pull request 'refactor(haptics): one copy of each thing every rumble path was transcribing' (#51) from worktree-haptics-m12-dry into main
apple / swift (push) Successful in 1m22s
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m59s
android / android (push) Successful in 7m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
deb / build-publish (push) Successful in 4m59s
deb / build-publish-client-arm64 (push) Successful in 3m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 5s
deb / build-publish-host (push) Successful in 4m38s
docker / deploy-docs (push) Successful in 29s
release / apple (push) Successful in 8m57s
ci / rust (push) Successful in 10m43s
arch / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 5m55s
flatpak / build-publish (push) Successful in 8m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m39s
windows-host / package (push) Successful in 17m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m16s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m49s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m59s
2026-08-04 21:11:49 +00:00
enricobuehler 92f617a989 Merge remote-tracking branch 'origin/main' into worktree-haptics-m12-dry
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m28s
ci / docs-site (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 3m58s
ci / rust-arm64 (pull_request) Successful in 5m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 8m47s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m10s
# Conflicts:
#	clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt
#	crates/pf-client-core/src/gamepad.rs
2026-08-04 23:11:22 +02:00
enricobuehler 2f071a9a93 Merge pull request 'fix(clients/settings): controller settings that can't do anything no longer look live' (#50) from worktree-haptics-m11-settings into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 59s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:08:15 +00:00
enricobuehler 62d35bc4b6 Merge pull request 'fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry' (#45) from worktree-haptics-m10-wire into main
android / android (push) Canceled after 7s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m57s
deb / build-publish (push) Canceled after 2m52s
deb / build-publish-host (push) Canceled after 2m24s
deb / build-publish-client-arm64 (push) Canceled after 1m38s
flatpak / build-publish (push) Canceled after 6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-04 21:07:54 +00:00
enricobuehler 5d06ef26ac Merge pull request 'fix(feedback): the pad stops keeping a game's trigger effect after the stream ends' (#44) from worktree-haptics-m9-richfb into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
deb / build-publish (push) Canceled after 4s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-04 21:07:35 +00:00
enricobuehler 42a0dd52be refactor(haptics): one copy of each thing every rumble path was transcribing
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 9m44s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

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

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

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

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

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

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

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00
enricobuehler 9fb41affba fix(clients/settings): a controller setting you can't use no longer looks like one you can
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m41s
ci / web (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m16s
android / android (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 8m4s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m30s
Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is
forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised
them, the touch settings on both mobile clients dimmed them and the console UI refused the
step; the Windows client and BOTH controller-navigable screens left them fully live, so you
could sit there changing settings that did nothing.

Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type,
guide button and hold-Select rows — the same builder the echo-cancellation row already used
to follow the mic switch.

Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides
the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the
glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)`
/ `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change"
on a dimmed row, because advertising them was the same lie the live row told.

Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" —
but it only faded the label: every dimmed row still stepped and still wrote its setting. The
"No profiles yet" placeholder looked inert only because its own closures were empty. Made it
real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap
on the already-focused row), then gated the pad rows on it.

Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings
have carried beside its SC2 twin all along. It was missing exactly where it matters most —
a TV box has no touch interface to fall back to, so there was no way to reach it at all.

Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's
system gesture and powered the controller's IMU. Neither reaches the host, so the first only
took the user's screenshot/Home gestures away for nothing and the second drained the pad's
battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is
read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's
own four buttons keep their claim. A test pins the alias list against the chord mask; if they
drift the symptom is a session nobody can leave, with nothing logged.

Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and
"Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen
was not — both corrected here.

Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean +
full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt
--all --check clean. Each fix probed by reverting it — every probe failed the tests it should.
2026-08-04 22:25:51 +02:00
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

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

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

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

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

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

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

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
enricobuehler 173be61213 fix(android/pad-audio): an unplugged pad comes back whole, and an idle one arrives at all
android / android (pull_request) Successful in 4m24s
ci / web (pull_request) Successful in 2m29s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 36s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 44s
ci / rust-arm64 (pull_request) Successful in 3m33s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m45s
ci / rust (pull_request) Successful in 6m52s
Three faults on the default capture path, all of them silent.

Unplug tore nothing down. onLinkClosed() is the real unplug signal — silence
never is, an idle pad simply stops streaming — but it skipped the pad-audio
teardown that stop() performs, so the render thread went on writing to a
descriptor whose device was gone, the renderer's own UsbDeviceConnection leaked,
and because the started flag stayed set and the native tier-A registry stayed
armed for that wire index, the pad came back with neither pad audio nor wire
rumble: the next occupant of the index inherited a suppression nothing would
lift. The teardown is now one shared step and runs on both paths, before the
slot is released, since the renderer is addressed by the index the release
forgets.

The wire slot was claimed on the first parsed report. A captured pad that
reports nothing then gave the host no arrival, so no virtual pad, no pad-audio
capability, no 0xD1 — a renderer sitting at zero frames, which is exactly what a
broken pipeline looks like, and it took a physical replug to clear. A pad that
reports nothing is still a pad, so the slot is claimed when the capture engages;
the first report stays as the fallback for a claim that found no free index.
This also puts the common claim on the main thread, which is the contract
GamepadRouter.openExternal documents and the link thread was quietly breaking.

And the two settings had no UI. The model and its persistence existed but no
toggle did, so pad_speaker could only be set by hand-editing shared_prefs, and
pad_haptics — which decides whether the pad trades wire rumble at all — could
not be turned off by anyone who hit trouble with it. Both are now rows under the
DualSense passthrough toggle, gated on it, since neither does anything to an
uncaptured pad.

The padHaptics doc no longer describes the arbitration as a selection forced by
a firmware-level mutual exclusion. It is decided on evidence — the coils belong
to haptics only while haptics frames arrive — which is what 2032c48f changed it
to and why a rumble-only title keeps rumbling.
2026-08-04 20:13:45 +02:00
enricobuehler 2032c48ffa fix(android/pad-audio): a game that only rumbles keeps rumbling
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 2m49s
ci / rust-arm64 (pull_request) Successful in 3m9s
android / android (pull_request) Failing after 4m23s
ci / rust (pull_request) Successful in 6m54s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 2m22s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 55s
Three faults that between them silence a wired DualSense.

The trade was committed without asking whether the host can send pad audio at
all. Against every released host — no HOST_CAP_PAD_AUDIO — the renderer claimed
the interface, took the pad off wire rumble, and then rendered nothing, with
`pad_haptics` defaulting on and no UI to turn it off. The capability is now
checked before `sink::open`, so nothing is claimed and nothing is traded.

Arming was unconditional, so a speaker-only setup took the motors away too. The
speaker pair is channels 0/1 and no rumble write can disturb it; only the
haptics lane arms now.

And the suppression itself was wrong for the case that matters most: a title
driving classic rumble and no haptics audio. Suppressing on "a stream is open"
assumed the game's rumble rides the haptics mix, which for such a title is
false — it renders no haptics audio at all, so the host's -60 dBFS gate emits
nothing on 0xD1 and the pad was left with neither. Ownership is now decided by
evidence: the coils belong to haptics only while haptics frames are actually
arriving, and to wire rumble otherwise. Frames are stamped on arrival rather
than after decode, so a decoder hiccup cannot hand the coils back mid-effect,
and concealment does not count as evidence. Liveness is dropped at every
teardown, because wire indices are recycled and a stale stamp would let a fresh
pad inherit the previous occupant's ownership.

Arbitrating on evidence rather than on a prediction about the hardware is
deliberate, and the module doc now says why. It used to assert that the coils
and the rumble motors are the same physical actuators — "a firmware constraint,
not a preference". Nothing establishes that: it traces to one reverse-engineered
comment in SDL, whose own modern path sets HAPTICS_SELECT alone with amplitude
on ucEnableBits3, which reads more like an independent mute than a shared-
actuator interlock. The combination that would settle it — rumble with
HAPTICS_SELECT cleared — is emitted by no code anywhere, and nothing here writes
it either. The evidence rule is correct under either hypothesis.

The liveness clock is 1-based so that 0 stays an unambiguous "never stamped":
without it a frame arriving in the process's first millisecond read as
never-arrived and handed the coils back mid-effect. Its test caught that.

Verified: clippy -p punktfunk-client-android --all-targets --locked -D warnings
= 0; 15 tests pass.

Owed: the desktop twin of the arbiter, and the coil restore — the Android stop
write still asserts HAPTICS_SELECT with zero amplitude, where SDL's all-zero
stop restores the audio path.

From the 2026-08-03 force-feedback sweep (B4, B5; B6 partly).
2026-08-03 19:44:52 +02:00
enricobuehler 9a52c279f1 Merge branch 'main' into feat/android-pad-audio
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 3m55s
apple / swift (pull_request) Canceled after 0s
apple / screenshots (pull_request) Canceled after 0s
ci / rust (pull_request) Canceled after 5m1s
ci / rust-arm64 (pull_request) Canceled after 3m22s
ci / docs-site (pull_request) Canceled after 1m35s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
2026-08-03 17:39:07 +00:00
enricobuehler 5be494f490 merge: bring main into the pad-audio branch
ci / web (pull_request) Successful in 1m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 34s
ci / rust-arm64 (pull_request) Successful in 2m48s
android / android (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 5m40s
Main had moved 34 commits past the merge-base and 13 files had diverged.
Resolving now rather than later, since the force-feedback sweep work is landing
in the same files.

Five conflicts needed hand resolution. Four were "each side added something
different" and keep both: the Forwarding and PadAudioPrefs control variants with
their handlers and setters (pf-client-core/gamepad.rs), both of the session's
pre-attach declarations (forwarding first, so slots still declare their
pad-audio caps at open time), main's WiredPlan/fingerprint alongside the
branch's pad_render_ids (audio_control.rs), and main's judge_default signature
(wasapi_cap.rs).

wiring_plan.rs was not mechanical. Main's 652abeb3 added a flagged last-resort
loopback tier; the branch had added a fifth `plan` parameter excluding pad
endpoints from every role. Taking either side alone loses the other, and
combining them carelessly is worse than both: the new last-resort tier would
happily select the pad's own speaker endpoint, which is stamped "DualSense
Wireless Controller" with no virtual marker precisely so games read it as the
pad's speaker — routing the entire desktop mix into the controller's voice
coils. The branch's exclusion shadows `renders` before any tier runs, so the
last resort inherits it; `a_pad_is_never_the_last_resort` pins that, including
that a pad-only candidate set stays honestly unsatisfiable rather than falling
back onto the coils.

Verified: clippy -p punktfunk-host -p pf-client-core --all-targets --locked
-D warnings = 0; pf-client-core 93/93; punktfunk-host 387 passed with only the
known-environmental gamestream sender_delivers_batches UDP-loopback flake;
wiring_plan 21/21; fmt clean.

NOT verified: audio_control.rs and wasapi_cap.rs are cfg(windows), so neither
the Linux container nor xcheck.sh compiles them. Those two resolutions have had
review only and need the Windows runner before this merges.
2026-08-03 19:11:26 +02:00
enricobuehlerandClaude Opus 5 0d5e5b436b fix(android/pad-audio): pin the uac-host that unmutes the pad
ci / web (pull_request) Successful in 59s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m50s
android / android (pull_request) Successful in 5m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 59s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 54s
ci / rust-arm64 (pull_request) Successful in 9m27s
ci / rust (pull_request) Canceled after 14m9s
The pad rendered nothing — not its speaker, not its voice coils — because
`uac-host` streamed into a device it never unmuted. It set the sample rate and
nothing else; the UAC Feature Unit, where Mute and Volume live, was parsed by
nobody. Every counter stayed green throughout: URBs completed, 0 short bytes,
0 URB errors, 0 short writes here, decoded peak 19345. None of them can observe
mute, so a muted device is indistinguishable from a working one.

Bumps the pin to unom-io/usbfs-iso f3de1fd, which sends SET_CUR Mute=0 and
Volume=0 dB to the Feature Unit before the stream starts.

With this in, Spider-Man Remastered's haptics reach the physical DualSense
through the virtual pad, confirmed by feel on real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:35:27 +02:00
enricobuehlerandClaude Opus 5 3a48cc2470 test(host/pad-audio): drive either channel pair, so the speaker leg can be proven too
`pad-endpoint tone` only ever drove the BACK pair, which meant the pad's speaker
— the FRONT pair, the other half of the 4-channel split — had never carried a
signal end to end. The capture probe's verdict was shaped the same way, and
called a perfectly good front-pair run "silent".

`--pair front|back|both` picks the pair, and the verdict now reports which pair
it SAW rather than judging against an assumed one.

Measured on .173, an exact mirror in both directions and no crosstalk either way:

  --pair back   peak_front=0.0000  peak_back=0.5000   back only, channel-exact
  --pair front  peak_front=0.5000  peak_back=0.0000   front only, channel-exact
  --pair both   peak_front=0.5000  peak_back=0.5000   both

So the host half of the speaker path is proven to the same standard the haptics
path was. What is still unproven is the client rendering the front pair into the
pad's own speaker; that needs the phone unlocked, which it no longer is.

Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:38:06 +02:00
enricobuehlerandClaude Opus 5 64a392634e test(host/pad-audio): prove the endpoint actually carries audio, channel-exact
`pad-endpoint tone` only ever proved a render client could open the endpoint.
Whether anything came back out of the loopback — and in the right channel pair —
was still taken on faith, which is exactly the gap that let a stamped-but-
unservable endpoint look healthy while a client sat on an empty plane.

`pad-endpoint capture [seconds]` opens the real PadLoopbackCapturer and reports
frames plus per-pair peaks, so the two halves together exercise render -> engine
-> loopback -> pair routing with no game and no client attached.

Run against each other on .173:

  pad-endpoint capture: 157920 frames over 7s, peak_front=0.0000 peak_back=0.5000
  VERDICT: PASS - back pair only, front pair silent (channel-exact).

0.5 is the tone's own amplitude and the front pair is dead silent, which is the
signal the 0xD1 framer routes to the voice coils. Same figure the program notes
recorded on 2026-08-01 and nothing has been able to reproduce since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:09:38 +02:00
enricobuehlerandClaude Opus 5 35285afafc fix(host/pad-audio): retire the freed-string endpoint lookup everywhere, and make provisioning converge
Two loose ends from the pad-audio bring-up.

`wasapi 0.23`'s `DeviceEnumerator::get_device` passes `GetDevice` a pointer
into an `HSTRING` temporary that was already dropped, so it resolves whatever
the allocator left behind and misses ids that are perfectly valid. Only the
pad-audio path had been moved off it; the remaining four callers include
desktop loopback capture and the default-endpoint judgement, where a spurious
miss silently downgrades a capturable default to Unknown. The host now resolves
through `open_wasapi_device` (raw COM, buffer kept alive). `pf-client-core`
cannot share that helper — it pins a different `windows` revision than `wasapi`
does, so the two `IMMDevice` types are incompatible — and instead scans the
active collection by id, which touches only safe crate APIs.

Provisioning also stopped latching a transient. A stamp lands, a check run
immediately afterwards reports all seven keys served, and AudioEndpointBuilder
then reverts the three format keys behind us, leaving 4/7 for good. Since
`needs_aeb_kick` is what makes startup restart AudioEndpointBuilder + Audiosrv,
that transient meant bouncing the machine's whole audio stack on every host
start, forever, chasing stamps a re-pass lands. `ensure` now stamps, lets AEB
settle, and only then checks — repeating up to five times.

Before: fresh provisions landed 4/7 with kick=true on 3 of 4 runs. After: 4 of
4 runs settle 7/7 with kick=false in 2.8s, identity intact (Wireless
Controller / DualSense Wireless Controller / PFDS container), 4ch mask 0x33,
render and loopback capture both opening, and `pad-endpoint tone` clean.

Host clippy clean; 360 tests pass, the one mgmt display failure reproduces on a
clean tree. The client-side helper is type-checked against wasapi on Windows in
isolation — pf-client-core itself will not build on .173 (no ffmpeg/SDL3/Vulkan
toolchain there), so its module integration is unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:44:29 +02:00
enricobuehlerandClaude Opus 5 0d0e7e6861 style(host/pad-audio): drop a redundant f32 cast in the tone devtest
clippy's `unnecessary_cast` fires on it, which fails CI's -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:15:48 +02:00
enricobuehlerandClaude Opus 5 143454590f test(host/pad-audio): let a stamp subset be re-provisioned, and confirm the endpoint really is 4ch
`PUNKTFUNK_PAD_AUDIO_STAMPS` narrows `ensure` to a named subset of the seven
stamps (unset keeps all of them, so the shipping path is unchanged). The
MMDevices Properties ACL denies even an elevated `reg delete`, so the only way
to ask "which stamp breaks this endpoint" was to re-provision with subsets.

Using it settled that nothing does. Once the heap corruption is out of the way
and stamping completes in ONE pass, the full set yields an endpoint that is
4ch/48k/mask 0x33 with both directions open — render and the loopback capture
that feeds the 0xD1 plane — and `pad-endpoint tone` renders without error.

The intermediate reading, that the Steam driver was stereo-only and the feature
needed a different carrier, was a confounded A/B: the "stamped" sample had
accumulated its stamps across heap-corrupted runs. Asked properly — in
EXCLUSIVE mode, which reaches the driver instead of the engine's mix format —
that driver reports 2ch, 4ch and 8ch, the same shape a real DualSense reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:14:43 +02:00
enricobuehlerandClaude Opus 5 9409d0a04c fix(host/pad-audio): provisioning stops corrupting the heap, and the endpoint stops being resolved by a freed string
Two defects sat between the pad-audio endpoint and any sound. Neither was
where the symptom pointed.

`windows 0.62` implements `Drop for PROPVARIANT` as `PropVariantClear(self)`.
Every variant `set_store_value` builds borrows memory Rust owns — a `Vec<u16>`,
a `&GUID`, a `&'static [u8]` — so each stamp handed that pointer to
`CoTaskMemFree`. The file said the opposite in a comment, which is why it
looked safe. The damage surfaced late: `pad-endpoint ensure` died with
STATUS_HEAP_CORRUPTION (0xC0000374) partway through stamping, leaving the
endpoint with whatever subset had landed and `needs_aeb_kick` stuck true
forever. With the variants held in `ManuallyDrop`, `ensure` exits 0 and all
seven stamps read back served for the first time.

`wasapi 0.23`'s `DeviceEnumerator::get_device` builds its argument as
`PCWSTR::from_raw(HSTRING::from(id).as_ptr())`; the `HSTRING` is a temporary,
so `GetDevice` reads freed memory. That is where the `IAudioClient: 0x80070002`
came from — not from the endpoint, which activates fine. Resolving through
`open_mmdevice`, which keeps its buffer alive, retires the error in both the
tone devtest and the loopback capture.

Also adds the instrument that separated these: the tone path now reports the
raw `IMMDevice::Activate` result alongside the crate's, and `pad-endpoint tone
--endpoint <id>` can drive any endpoint, so "this process cannot activate
anything" and "this endpoint is broken" stop looking identical.

Verified on .173: ensure exit=0, 7/7 stamps served, needs_aeb_kick=false,
0x80070002 gone. Host clippy clean; 360 tests pass (the one mgmt display
failure reproduces on a clean tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:01:22 +02:00
enricobuehler 212bdc3b08 fix(devtest): resolve the pad endpoint by system lookup, not the service's cache
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m37s
ci / docs-site (pull_request) Successful in 3m8s
ci / web (pull_request) Successful in 3m31s
ci / rust (pull_request) Successful in 8m16s
android / android (pull_request) Successful in 8m43s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 14m18s
2026-08-03 10:09:49 +02:00
enricobuehler 45cb525035 wip(host): pad-endpoint tone devtest
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 2m48s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 3m53s
android / android (pull_request) Canceled after 4m2s
ci / rust (pull_request) Canceled after 4m9s
2026-08-03 10:05:52 +02:00
enricobuehler 6fed1510ba test(android): report renderer stats even when the plane is silent
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m16s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m53s
android / android (pull_request) Successful in 4m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Canceled after 4m44s
The renderer now reports once a second regardless of traffic — frames in,
samples decoded, peak level, frames written, underruns, short bytes.

The first version reported only after a frame arrived, which made the single
most diagnostic state unreportable: an idle plane and a dead renderer looked
identical (both silent). That cost a debugging round on real hardware, where the
absence of any line had to be triangulated against usbfs interface claims and
`dumpsys input` to work out which of the two it was.

The peak is of the decoded PCM, and it is the discriminator that matters: frames
arriving with peak=0 means the host's capture is hearing silence — a routing
problem upstream — whereas a non-zero peak means real signal is reaching the pad
and anything still wrong is downstream of the write.
2026-08-03 10:01:05 +02:00
enricobuehler 4fd240deab test(android): make the pad-audio self test reachable without a host
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m46s
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 3m58s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
ci / rust (pull_request) Successful in 4m6s
The self test shipped in the previous commit was gated behind a capture, which
needs a stream, which needs a host — so it depended on precisely the thing it
exists to rule out. It could not have been run in the situation that motivated
it.

It is now a "Test haptics" button on the DualSense passthrough card in
Settings → Controllers → Connected controllers, which is reachable with no
session at all. It opens its OWN connection to the pad — the same rule the
renderer follows, and the rule whose violation caused the fault this test looks
for — runs the tone on a worker thread, and reports a plain-language result:
which of open / write / no-data failed, or how many frames reached the pad.

The debug-property trigger stays for the in-session case; this is the one that
answers "can this phone drive this pad at all" before a host is even involved.
2026-08-03 09:38:46 +02:00
enricobuehler e32bd30c85 fix(android): give the renderer its own USB connection, and add a real-world self test
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m17s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m26s
ci / rust-arm64 (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 4m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m21s
ci / rust (pull_request) Successful in 13m11s
**The bug.** The renderer was handed `HidUsbLink`'s file descriptor. That link's
own comment states the hazard exactly — "only one thread may drive a
connection's UsbRequests (requestWait() returns ANY completed request; a second
waiter would steal the reader's completions)" — and it is just as true of the
usbfs reap underneath: the isochronous ring and the HID reader were reaping each
other's URB completions. The standalone harness works because it owns its
descriptor by construction, which is precisely why it could never have caught
this. `DsCapture` now opens a dedicated connection via `openAuxConnection()` and
closes it only after the render thread is joined.

**The test.** Nothing exercised the CLIENT path without a host, so the two things
most likely to be wrong were invisible: whether the descriptor handed over is
exclusively ours, and whether the claim succeeds on this kernel. Neither is
unit-testable and a harness proves neither.

`nativePadAudioSelfTest` drives the voice coils with a tone through the real
path — the same aux connection, claim, sink and write loop the renderer uses —
and is triggered by `adb shell setprop debug.punktfunk.pad_audio_selftest 3`,
matching this repo's existing debug.punktfunk.* convention. It runs INSTEAD of
the renderer for that capture, never alongside it: two engines on one descriptor
is the fault being tested for, and I nearly shipped it into the test itself.

Underruns are deliberately not a failure condition — that is producer pacing.
The pass condition is data reaching the bus.
2026-08-03 00:38:58 +02:00
enricobuehler 2f1ef44191 fix(android): commit the tier-A trade only once the USB stream actually opens
ci / web (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m17s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m9s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 3m36s
ci / rust (pull_request) Canceled after 4m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 55s
A real bug, and the worst shape one can take here: it costs the user ALL
haptics rather than degrading.

`pad_audio::start` returned success as soon as the render thread spawned, and
`nativeStartPadAudio` then declared the pad's render capability and took it off
wire rumble. But `sink::open` runs later, on that thread. On a kernel that
refuses the interface claim — the OEM case documented as needing a clean tier-C
fallback — the pad was already suppressed and the host already streaming 0xD1 at
a renderer that never opened. No pad audio, and no rumble either.

The declaration and the suppression now happen inside the renderer, immediately
after a successful open, and are both withdrawn when it stops. A failed open
declares nothing and suppresses nothing, so the session stays on ordinary rumble
— which is what "degrades to tier C" was always supposed to mean. `PadAudio`'s
Drop clears the tier-A bit too, so a thread that dies unexpectedly cannot leave a
pad permanently mute.

The general rule this violated: never give up a working fallback until the thing
replacing it is known to work. Spawning a thread is not evidence that it will.
2026-08-03 00:34:47 +02:00
enricobuehler 8ee224e5db fix(android): advertise CLIENT_CAP_PAD_AUDIO, without which nothing is ever sent
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m35s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 3m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m20s
ci / rust-arm64 (pull_request) Successful in 5m27s
ci / rust (pull_request) Successful in 12m30s
A gap in the previous commits, and the same silent-failure shape as the two they
fixed. There are TWO negotiations, not one: the per-pad render capabilities that
ride a gamepad arrival (bits 8/9), which those commits set, and the SESSION-level
CLIENT_CAP_PAD_AUDIO in the Hello, which they did not. Without the latter the
host never sets HOST_CAP_PAD_AUDIO and emits no 0xD1 at all — so the per-pad bits
would have had nothing to gate, and the renderer would have sat on a permanently
empty plane with every other piece looking correct.

Threaded as an explicit `padAudioOk` on nativeConnect rather than advertised
unconditionally: the cap makes a Windows host provision pad endpoints at startup,
and a user who has pad audio switched off should not pay for that.

Found by tracing what an on-glass run against a real host would actually need,
not by a test — there is no test that could have caught it, since both halves are
individually well-formed.
2026-08-03 00:04:14 +02:00
enricobuehler e8499e6131 feat(android): wire tier-A pad audio through the capture lifecycle and settings
apple / swift (pull_request) Successful in 1m19s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 5m27s
ci / rust (pull_request) Canceled after 3m50s
ci / rust-arm64 (pull_request) Canceled after 3m50s
ci / docs-site (pull_request) Canceled after 31s
windows / build (aarch64-pc-windows-msvc) (pull_request) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Canceled after 0s
The Kotlin half. Turns out Android needs to claim nothing extra: `uac-host`
claims the pad's audio interface itself through usbfs on the fd, and usbfs
claims are per interface, so the HID claim `HidUsbLink` already holds is
untouched. The link therefore surrenders only its file descriptor.

Two orderings carry the whole design, and both are easy to get wrong:

- **Start on the first report, not at claim time.** The wire pad index does not
  exist until the router opens a slot, and the host addresses the 0xD1 stream by
  that index — starting earlier would declare capabilities for a pad that has no
  index yet.
- **Stop before the link closes.** `usb.stop()` closes the connection whose
  descriptor the render thread borrows, so `padAudio.stop()` runs first, at the
  top of `DsCapture.stop()`. `nativeStopPadAudio` does not return until the
  thread is joined, which is what makes the borrow sound rather than merely
  usually-fine.

`DsCapture` decides WHEN (it owns the wire index and the link lifetime);
`StreamScreen` decides WHETHER (it owns the session handle and the settings).
The capture stays ignorant of sessions.

Settings: `padHaptics` defaults on — it is the whole point, and this client's
rumble already drives the same actuators, so tier A is a strict improvement.
`padSpeaker` defaults OFF: it is a small loudspeaker in the user's hands playing
audio they can already hear, and surprising someone with that is worse than
making them opt in.

Verified: APK builds, and both JNI entry points are exported in the shipped
arm64 .so — a missing one would be an UnsatisfiedLinkError only at runtime.
12 Rust tests, 0 clippy findings, fmt clean.
2026-08-02 23:51:28 +02:00
enricobuehler a10bde39bb feat(android): declare pad-audio caps and take tier-A pads off wire rumble
The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.

**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.

**Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio
haptics and selects classic rumble, and `DsDevice` sets it on every rumble write
— as Linux's hid-playstation and SDL both do. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.

Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.

Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.

`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.

12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
2026-08-02 23:44:51 +02:00
enricobuehler b5f91d50bb feat(android): tier-A pad audio — the 0xD1 plane on the pad's USB endpoint (WP9)
The Android twin of `pf-client-core`'s pad_audio: drain the host's per-pad
DualSense streams, Opus-decode haptics (kind 0) and speaker (kind 1), interleave
into the pad's own 4-channel layout, and render on the pad itself.

Every other client hands that stream to the platform's audio graph. Android
cannot: AOSP's UsbAlsaManager denylists the DualSense's output by VID/PID, so
the kernel enumerates the pad's playback node and the framework discards it —
`hasOutput: false`, nothing for setPreferredDevice to target, /dev/snd closed by
SELinux, and UsbRequest rejects non-bulk/interrupt endpoints. So this drives the
pad's isochronous endpoint directly via uac-host on the descriptor Java owns.

That is measured, not assumed. On a Nothing Phone (3): the claim succeeds
unprivileged, the gamepad and the pad's microphone both keep working, and the
underrun-free floor is 4 ms — holding under eight-core load with the SoC in
severe thermal throttling. The renderer runs at 6 ms, one step of headroom,
because the same measurement found transient events that are not depth-dependent.

Structured to the crate's own convention: the mixer and PLC are ungated so they
compile and unit-test in the host workspace (8 tests), while everything touching
an Android-only dependency is cfg'd to android. Two details worth review:

- The kinds arrive on different cadences (5 ms vs 10 ms), so each has its own
  write cursor and both shift together on overflow — a haptics-only session
  renders with a silent speaker pair instead of stalling on a kind that will
  never arrive, and the two can never skew.
- An unrecognised kind is dropped rather than folded into the coil pair. A
  `min(1)` clamp would have rendered a future kind straight into the actuators.

Lifecycle mirrors MicCapture: dropping the handle joins the thread, and
nativeStopPadAudio returns only once it has, so Kotlin may close the
UsbDeviceConnection as soon as it returns and not before.

usbfs-iso/uac-host enter as git dependencies pinned by revision — a transport
under a real-time deadline should move when we choose. They become version
dependencies once published to crates.io.
2026-08-02 23:39:25 +02:00
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00
79 changed files with 8630 additions and 515 deletions
Generated
+19
View File
@@ -2893,6 +2893,7 @@ dependencies = [
"ureq",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
]
[[package]]
@@ -3346,6 +3347,8 @@ dependencies = [
"opus",
"punktfunk-core",
"tracing",
"uac-host",
"usbfs-iso",
]
[[package]]
@@ -4985,6 +4988,14 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uac-host"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"usbfs-iso",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5064,6 +5075,14 @@ dependencies = [
"serde",
]
[[package]]
name = "usbfs-iso"
version = "0.1.0"
source = "git+https://github.com/unom-io/usbfs-iso?rev=f3de1fd62cec271d07f45664dc464f23e423e721#f3de1fd62cec271d07f45664dc464f23e423e721"
dependencies = [
"libc",
]
[[package]]
name = "usbip-sim"
version = "0.8.0"
@@ -410,17 +410,68 @@ private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pad-audio self test. Deliberately reachable WITHOUT a stream: it exists to
// answer "can this phone drive this pad's audio endpoint at all", and gating
// that behind a live session would make it depend on the very thing one wants
// to rule out when a session misbehaves. DualSense only — the DS4 has no
// 4-channel haptics device.
if (model != DsDevice.Model.DUALSHOCK4) {
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
result?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
enabled = !testing,
onClick = {
testing = true
result = null
Thread({
// Its OWN connection: the renderer's descriptor must never be
// shared with another transfer engine, and that applies to
// this test as much as to the real path.
val conn = runCatching { usbManager.openDevice(usbDev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) {
io.unom.punktfunk.kit.NativeBridge.nativePadAudioSelfTest(fd, 3, 60)
} else {
-1
}
conn?.close()
val msg = when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 -> "Could not open the pad's audio interface. " +
"Some kernels refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
result = msg
testing = false
}
}, "pf-pad-selftest-ui").start()
},
) {
Text(if (testing) "Testing…" else "Test haptics")
}
}
}
}
}
}
@@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
private class GpRow(
internal class GpRow(
val id: String,
val header: String?,
val label: String,
@@ -78,6 +78,15 @@ private class GpRow(
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
/**
* The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of
* [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the
* already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing
* else, and every dimmed row still stepped its setting.
*/
internal fun liveRow(rows: List<GpRow>, index: Int): GpRow? =
rows.getOrNull(index)?.takeIf { it.enabled }
@Composable
fun GamepadSettingsScreen(
initial: Settings,
@@ -144,11 +153,13 @@ fun GamepadSettingsScreen(
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) }
// A disabled row is INERT, not just dim — the step is refused instead of writing a
// setting that has nothing to act on (see `liveRow`).
NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) }
NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) }
}
},
onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() },
onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
@@ -186,7 +197,10 @@ fun GamepadSettingsScreen(
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = {
if (focus == index) { adjustDir = 1; row.activate() } else focus = index
// Same inertness as the pad path above — tapping a dimmed row focuses it (so
// its detail explains itself) but never flips it.
if (focus != index) focus = index
else if (row.enabled) { adjustDir = 1; row.activate() }
})
}
}
@@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). */
private fun buildSettingsRows(
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
av1Capable: Boolean,
@@ -348,13 +362,14 @@ private fun buildSettingsRows(
): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
options: List<Pair<T, String>>, current: T, enabled: Boolean = true, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
enabled = enabled,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
@@ -371,11 +386,12 @@ private fun buildSettingsRows(
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
enabled = enabled,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
toggled = value,
@@ -478,22 +494,26 @@ private fun buildSettingsRows(
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
// Everything below the master switch follows it — dim and inert while nothing is being
// forwarded, the same relationship the touch settings draw with `enabled =`. This screen
// had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so
// the pad rows kept stepping settings that had nothing to act on.
choice(
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding,
) { update(s.copy(gamepad = it)) },
choice(
"systemButtons", null, "Guide button",
"Where the guide (Xbox/PS) and share presses go while streaming — Automatic " +
"sends them to the host whenever this device delivers them.",
SYSTEM_BUTTON_OPTIONS, s.systemButtons,
SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding,
) { update(s.copy(systemButtons = it)) },
choice(
"guideGesture", null, "Hold Select for guide",
"Hold Select alone to press the host's guide button — keep holding for a " +
"Gaming-Mode host's quick-access menu. A Select tap still goes through.",
GUIDE_GESTURE_OPTIONS, s.guideGesture,
GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding,
) { update(s.copy(guideGesture = it)) },
) + listOfNotNull(
if (hasBodyVibrator) {
@@ -513,8 +533,18 @@ private fun buildSettingsRows(
"sc2", null, "Steam Controller 2 passthrough",
"Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " +
"it as-is — Steam on the host drives it like the physical pad.",
s.sc2Capture,
s.sc2Capture, enabled = s.gamepadForwarding,
) { update(s.copy(sc2Capture = it)) },
// The SC2 row's twin, and missing here until now: the touch settings have carried both
// side by side, so a couch user on a TV box — where there IS no touch interface to fall
// back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate
// reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's.
toggle(
"dsCapture", null, "DualSense / DualShock passthrough (USB)",
"Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " +
"triggers, lightbar and gyro.",
s.dsCapture, enabled = s.gamepadForwarding,
) { update(s.copy(dsCapture = it)) },
)
}
@@ -84,6 +84,9 @@ suspend fun connectToHost(
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
)
}
}
@@ -170,6 +170,26 @@ data class Settings(
*/
val dsCapture: Boolean = true,
/**
* Render the host's DualSense **voice-coil haptics** on a captured USB pad (tier A).
*
* The pad's own 4-channel audio device carries them, driven directly over usbfs — Android's
* audio framework denylists that device by VID/PID, so there is no supported route to it. The
* two kinds are arbitrated rather than mixed, and on evidence: wire rumble is suppressed only
* while haptics frames are actually arriving, so a title that drives classic rumble and sends
* no haptics audio keeps rumbling. Off, or on an uncaptured/Bluetooth pad, the pad stays on
* ordinary rumble (tier C), which on this client already drives the same actuators.
*/
val padHaptics: Boolean = true,
/**
* Render the pad's **built-in speaker** on a captured USB pad. Independent of [padHaptics] —
* the host sends the two as separate streams and either can play alone. Off by default: the
* speaker is a small, easily-startling loudspeaker in the user's hands, and unlike haptics it
* duplicates audio they are already hearing.
*/
val padSpeaker: Boolean = false,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -271,6 +291,8 @@ class SettingsStore(context: Context) {
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
padSpeaker = prefs.getBoolean(K_PAD_SPEAKER, false),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -308,6 +330,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
.putBoolean(K_PAD_SPEAKER, s.padSpeaker)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -355,6 +379,8 @@ class SettingsStore(context: Context) {
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
const val K_PAD_SPEAKER = "pad_speaker"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -896,6 +896,22 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
// Both only ever apply to a captured pad, so they follow that row and gate on it.
ToggleRow(
title = "Controller haptics",
subtitle = "Play the host's fine-grained DualSense haptics on the pad itself — " +
"the pad keeps ordinary rumble for games that don't send them",
checked = s.padHaptics,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
)
}
}
}
@@ -507,6 +507,28 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
// Tier-A pad audio: render the host's 0xD1 streams on the pad's own 4-channel USB
// audio device. Bound here rather than inside DsCapture because the session handle
// lives at this layer; DsCapture decides WHEN (it knows the wire index and the link
// lifetime), this decides WHETHER.
if (initialSettings.padHaptics || initialSettings.padSpeaker) {
ds.padAudio = object : DsCapture.PadAudioHook {
override fun start(pad: Int, fd: Int) {
val ok = NativeBridge.nativeStartPadAudio(
handle,
pad,
fd,
initialSettings.padHaptics,
initialSettings.padSpeaker,
)
Log.i("punktfunk", "pad audio on pad $pad: ${if (ok) "started" else "unavailable"}")
}
// Returns only once the render thread is joined — DsCapture calls this before
// closing the connection whose descriptor that thread borrows.
override fun stop(pad: Int) = NativeBridge.nativeStopPadAudio(handle, pad)
}
}
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
@@ -0,0 +1,97 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves — the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable — a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
}
@@ -23,8 +23,9 @@ import android.view.InputDevice
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
* device units, the wire's contract). The wire slot is claimed when the capture engages, with the
* first parsed report as the fallback for a claim that found no free index, and freed on
* unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
@@ -78,6 +79,33 @@ class DsCapture(
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
/**
* Tier-A pad audio, bound by the app layer (which owns the session handle).
*
* [start] is called once the router has assigned this pad a wire index, which the host uses to
* address the `0xD1` stream. [stop] is called **before** the USB link closes — on [stop] and on
* unplug alike — and must not return until nothing is still writing to the descriptor.
*/
interface PadAudioHook {
fun start(pad: Int, fd: Int)
fun stop(pad: Int)
}
@Volatile
var padAudio: PadAudioHook? = null
/** True once [PadAudioHook.start] has run for the current capture, so it fires exactly once. */
@Volatile private var padAudioStarted = false
/**
* The renderer's OWN connection to the pad.
*
* It must not share [usb]'s descriptor: two transfer engines on one usbfs descriptor reap each
* other's completions (see [HidUsbLink.openAuxConnection]), which strands both the HID reader
* and the audio ring. Closed only after the hook's stop has returned.
*/
@Volatile private var padAudioConn: android.hardware.usb.UsbDeviceConnection? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
@@ -105,12 +133,17 @@ class DsCapture(
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
ensureSlot(m)
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
// Before anything touches the link: the pad-audio renderer borrows this connection's
// descriptor, and `usb.stop()` closes it. The hook does not return until its thread is
// joined, so ordering this first is what makes the borrow sound.
stopPadAudio()
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
@@ -136,16 +169,112 @@ class DsCapture(
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
// Normally claimed already, at capture time; this is the retry for a capture that engaged
// while every wire index was taken.
val p = pad ?: ensureSlot(m) ?: return // all 16 taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
/**
* Claim this capture's wire slot and start pad audio on it. Idempotent; null when all 16
* indices are taken.
*
* Claimed when the capture engages rather than on the first report, because a pad that reports
* nothing is still a pad: with the lazy claim, a captured-but-silent pad left the host with no
* arrival, hence no virtual pad, no pad-audio capability and so no `0xD1` — a renderer sitting
* at zero frames, indistinguishable from a broken pipeline (it took a physical replug to
* clear). Callable from the main thread (capture start) and the link thread (the fallback).
*/
@Synchronized
private fun ensureSlot(m: DsDevice.Model): GamepadRouter.ExternalPad? {
pad?.let { return it }
val p = router.openExternal(m.pref) ?: return null
pad = p
Log.i(TAG, "captured $m → wire pad ${p.index}")
// The wire index exists from here on, and the host addresses pad audio by it.
startPadAudio(p.index)
return p
}
/** Hand the renderer its own descriptor. Caller holds the monitor; fires once per capture. */
private fun startPadAudio(index: Int) {
val hook = padAudio ?: return
if (padAudioStarted) return
// A dedicated connection, NOT usb.fileDescriptor — see padAudioConn.
val conn = usb.openAuxConnection()
val fd = conn?.fileDescriptor ?: -1
if (fd < 0) {
conn?.close()
Log.w(TAG, "pad audio: could not open a second USB connection")
return
}
padAudioConn = conn
padAudioStarted = true
// Real-world self test, opt-in: `adb shell setprop debug.punktfunk.pad_audio_selftest 3`
// drives the voice coils for N seconds through the actual client path before the renderer
// takes over — the one check that proves the descriptor, the interface claim and the write
// path all work on THIS device, without needing a host to be streaming. Same convention as
// debug.punktfunk.force_parts.
val secs = runCatching {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java, String::class.java)
.invoke(null, "debug.punktfunk.pad_audio_selftest", "0") as String
}.getOrNull()?.toIntOrNull() ?: 0
if (secs > 0) {
// Diagnostic mode: the self test OWNS this descriptor for the capture, and the renderer
// must not also drive it — two engines on one usbfs descriptor reap each other's
// completions, which is precisely the fault this test exists to expose.
Thread({
val r = NativeBridge.nativePadAudioSelfTest(fd, secs, 60)
Log.i(TAG, "pad audio self-test → ${if (r > 0) "PASS ($r frames)" else "FAIL ($r)"}")
}, "pf-pad-selftest").start()
} else {
// B6: hand the coils back before the first haptics frame. Any rumble earlier in this
// session asserted HAPTICS_SELECT, which firmware-mutes them, and nothing else ever
// clears it — so without this the stream renders into a muted actuator and looks for
// all the world like the host is sending nothing.
restoreAudioHaptics()
hook.start(index, fd)
}
}
/**
* B6: clear the rumble/haptics-select bits so the pad's voice coils answer the audio-haptics
* path again. EP0-direct, like the other out-of-band writes here: this has to land even when
* the interrupt-OUT queue is busy or draining, and it is idempotent.
*/
private fun restoreAudioHaptics() {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no voice coils, no audio-haptics path
if (!usb.writeControl(DsDevice.ds5AudioHapticsReport(m))) {
Log.w(TAG, "pad audio: could not hand the coils back to audio haptics")
}
}
/**
* Stop the renderer, then close the connection whose descriptor it borrows — in that order.
*
* Runs on [stop] and on unplug alike. Skipping it on unplug left the render thread writing to a
* descriptor whose device was gone, leaked the connection, and — because the started flag stayed
* set and the native tier-A registry stayed armed for that index — cost the pad both its pad
* audio and its wire rumble on the way back in.
*/
@Synchronized
private fun stopPadAudio() {
if (!padAudioStarted) return
padAudioStarted = false
// The hook's stop joins the render thread, so nothing is using the descriptor once it
// returns — only then is it safe to close the connection that owns it.
pad?.let { padAudio?.stop(it.index) }
padAudioConn?.close()
padAudioConn = null
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
// Before releaseSlot(), which forgets the wire index the renderer is addressed by.
stopPadAudio()
disarmBackstop()
val wasActive = model != null
model = null
@@ -238,6 +367,10 @@ class DsCapture(
// write — as this used to — meant a discarded stop left the motors running with
// nothing scheduled to try again; a USB pad holds its last level until told zero.
if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS)
// B6: the stop report just re-asserted HAPTICS_SELECT on its way past, so if a
// haptics stream is live the coils it drives were muted by the very write that
// silenced the motors. Give them back.
if (sent && padAudioStarted) restoreAudioHaptics()
}
}
@@ -276,11 +276,26 @@ object DsDevice {
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
*/
/**
* B6: hand the voice coils back to the audio-haptics path.
*
* Every [ds5RumbleReport] asserts `HAPTICS_SELECT` (flag0 bit1), which is SDL's
* "disable audio haptics" bit — the firmware mutes the coils the 0xD1 haptics stream drives.
* Until now NOTHING ever cleared it again, so a single rumble anywhere in a session left tier-A
* haptics silent for the rest of that pad's life, with no error and nothing in a log.
*
* The undo is a report whose flag0 has BOTH bits clear (SDL's own comment: "Leaving emulated
* rumble bits off will restore audio haptics"). No other valid flag is set, so nothing else
* about the pad's state is touched. Mirrors `Ds5Feedback::audio_haptics_packet` on the desktop
* client, which is the same packet one transport over.
*/
fun ds5AudioHapticsReport(model: Model): ByteArray = newDs5(model)
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
it[3] = wireAmplitudeToByte(high).toByte()
it[4] = wireAmplitudeToByte(low).toByte()
}
/**
@@ -324,17 +339,11 @@ object DsDevice {
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[4] = wireAmplitudeToByte(high).toByte()
it[5] = wireAmplitudeToByte(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -131,15 +131,9 @@ class GamepadFeedback(
var failures = 0L
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
// rumble policy engine — it owns every lease/staleness/close decision (uniform
// across all clients; the old 60 s legacy-host exposure is gone) and emits
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
// the backstop (the hardware net under a stalled poll thread).
val pad = ((ev ushr 49) and 0xFL).toInt()
val backstopMs = ((ev ushr 32) and 0xFFFF)
// Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there
// against the Rust packer.
val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed
// Rendering is binder calls into the vibrator service, and every one of them can
// throw unchecked — DeadSystemRuntimeException when system_server goes down, and
// the ordinary RuntimeException a dying service wraps its RemoteException in.
@@ -147,12 +141,7 @@ class GamepadFeedback(
// nothing noticed and nothing restarted it, and rumble was gone for the rest of
// the session. Losing a single command is recoverable; losing the loop is not.
runCatching {
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
backstopMs,
)
renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs)
}.onFailure { failures = noteRenderFailure("rumble", it, failures) }
}
}, "pf-rumble").apply { isDaemon = true; start() }
@@ -292,8 +281,8 @@ class GamepadFeedback(
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
@@ -342,8 +331,8 @@ class GamepadFeedback(
*/
private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) {
val v = deviceVibrator ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val lo = wireAmplitudeToByte(low)
val hi = wireAmplitudeToByte(high)
if (lo == 0 && hi == 0) {
runCatching { v.cancel() } // (0,0) = stop
return
@@ -357,12 +346,6 @@ class GamepadFeedback(
}
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
private fun toAmplitude(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
@@ -98,6 +98,40 @@ class HidUsbLink(
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Open a SECOND connection to the same device, for a consumer that needs its own descriptor.
*
* **Not a convenience — a correctness requirement.** `UsbDeviceConnection.requestWait()`
* returns *any* completed request on that connection, and the same is true of the usbfs reap
* ioctl underneath it: two independent transfer engines sharing one descriptor steal each
* other's completions. This link's reader owns its connection exclusively (see the note on
* [outQueue]), so anything else driving transfers on this device — the isochronous audio
* renderer — must open its own.
*
* usbfs allows the same device to be opened many times, and claims are per (descriptor,
* interface), so a claim made on this connection does not conflict with one made on that.
*
* The caller owns the returned connection and must close it.
*/
fun openAuxConnection(): UsbDeviceConnection? {
val dev = device ?: return null
return usb.openDevice(dev)
}
/**
* The open connection's usbfs file descriptor, or -1 when the link is not running.
*
* Handed to native code that drives interfaces this link deliberately does NOT claim — the
* pad's isochronous audio endpoint (see `pad_audio` on the native side), which Android's own
* USB API cannot reach because `UsbRequest` rejects anything that is not bulk or interrupt.
* usbfs claims are per interface, so a native claim of the audio interface leaves this link's
* HID claim untouched.
*
* **The borrower must stop using it before [stop] runs**: closing the connection while a
* transfer is in flight pulls the descriptor out from under the kernel.
*/
val fileDescriptor: Int get() = connection?.fileDescriptor ?: -1
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
@@ -69,6 +69,10 @@ object NativeBridge {
* list and trust store show for it, same convention as [nativePair]'s `name`. `null`/blank ⇒
* the host falls back to a fingerprint-derived "device abcd1234" label. */
deviceName: String?,
/** Advertise `CLIENT_CAP_PAD_AUDIO` — the SESSION-level negotiation for the 0xD1 per-pad
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -332,6 +336,46 @@ object NativeBridge {
*/
external fun nativeSetMicMuted(handle: Long, muted: Boolean)
/**
* Start tier-A DualSense pad audio: render the host's `0xD1` streams on the pad's own
* 4-channel USB audio device.
*
* [fd] is an open [android.hardware.usb.UsbDeviceConnection]'s file descriptor. Native code
* **borrows** it — it claims the pad's audio interface through usbfs (which leaves any HID
* claim on the same device alone) and never closes the descriptor. The caller must keep the
* connection open until [nativeStopPadAudio] returns.
*
* This also declares the pad's render capability to the host; without it no `0xD1` is sent.
*
* Returns false when there is nothing to render. A kernel that refuses the interface claim is
* NOT reported here — the renderer discovers that on its own thread and the session simply
* carries on without tier A, because some OEM kernels refuse and no app-side fix exists.
*/
external fun nativeStartPadAudio(
handle: Long,
pad: Int,
fd: Int,
haptics: Boolean,
speaker: Boolean,
): Boolean
/**
* Stop tier-A pad audio and join its render thread, and hand the pad back to wire rumble.
*
* Returns only once the thread is joined — so the `UsbDeviceConnection` may be closed as soon
* as this returns, and not before.
*/
external fun nativeStopPadAudio(handle: Long, pad: Int)
/**
* Drive the pad with a test tone through the real render path — no host, no session.
*
* [fd] must come from a connection **nothing else is driving transfers on**: two engines on
* one usbfs descriptor reap each other's completions. Blocks for roughly [seconds]; run it off
* the main thread. Returns sample frames written, or negative on failure.
*/
external fun nativePadAudioSelfTest(fd: Int, seconds: Int, hz: Int): Int
/**
* Is a mic capture actually RUNNING — i.e. did [nativeStartMic] open a stream, and has
* [nativeStopMic] not been called since? Offer the in-stream mute control on THIS rather than
@@ -0,0 +1,47 @@
package io.unom.punktfunk.kit
/**
* The two conversions every rumble path in this module needs, in one place.
*
* Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical,
* in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop
* with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a
* silent divergence between copies would have been hard to notice.
*/
/**
* Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level.
*
* The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100
* would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is
* imperceptibly light, but it moves.
*/
internal fun wireAmplitudeToByte(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */
internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long)
/**
* Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel.
*
* Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`:
* bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high.
* The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a
* compile-time assertion tying the two together, so this can't silently start truncating.
*
* These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every
* lease/staleness/close decision and emits explicit zeros, so apply them verbatim —
* `(0, 0)` = cancel, non-zero = one-shot for the backstop.
*/
internal fun unpackRumbleEvent(ev: Long): RumbleCmd? {
if (ev < 0L) return null // timeout / closed
return RumbleCmd(
pad = ((ev ushr 49) and 0xFL).toInt(),
low = ((ev ushr 16) and 0xFFFF).toInt(),
high = (ev and 0xFFFF).toInt(),
backstopMs = (ev ushr 32) and 0xFFFF,
)
}
@@ -0,0 +1,79 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in
* `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite
* sides, which is the only thing that catches one of them drifting.
*/
class RumbleWireTest {
/** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */
private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long =
((pad and 0xF).toLong() shl 49) or
((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or
(low.toLong() shl 16) or
high.toLong()
@Test
fun `every field round-trips at its extremes`() {
val cases = listOf(
listOf(0, 0, 0, 0),
listOf(15, 0xFFFF, 0xFFFF, 0xFFFF),
listOf(1, 0x1234, 0x5678, 500),
listOf(7, 0, 0xFFFF, 2000),
)
for ((pad, low, high, backstop) in cases) {
val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!!
assertEquals("pad", pad, cmd.pad)
assertEquals("low", low, cmd.low)
assertEquals("high", high, cmd.high)
assertEquals("backstop", backstop.toLong(), cmd.backstopMs)
}
}
/** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */
@Test
fun `all sixteen pad indices are distinct`() {
val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad }
assertEquals((0 until 16).toList(), seen)
}
@Test
fun `the negative sentinel is not a command`() {
assertNull(unpackRumbleEvent(-1L))
assertNull(unpackRumbleEvent(Long.MIN_VALUE))
}
@Test
fun `a stop is distinguishable from a hold`() {
val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!!
val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!!
assertEquals(0, stop.low)
assertEquals(0, stop.high)
assertNotEquals(stop, hold)
}
// --- wireAmplitudeToByte (was two byte-identical private copies) ---
@Test
fun `amplitude takes the high byte`() {
assertEquals(0xFF, wireAmplitudeToByte(0xFFFF))
assertEquals(0x80, wireAmplitudeToByte(0x8000))
assertEquals(0x12, wireAmplitudeToByte(0x1234))
}
@Test
fun `zero stays silent but a weak nonzero never does`() {
assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0))
// Everything below 0x0100 has a zero high byte — without the floor these all vanish.
for (v in listOf(1, 0x0042, 0x00FF)) {
assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v))
}
assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own
}
}
+8
View File
@@ -64,6 +64,14 @@ libc = "0.2"
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
opus = "0.3"
# Tier-A pad audio (WP9). Android's audio framework denylists the DualSense's output by VID/PID,
# so the pad's isochronous endpoint is driven directly on the fd `UsbDeviceConnection` hands over.
# Our own crates, developed openly because the hole they fill — isochronous USB in Rust — is an
# ecosystem-wide one: https://github.com/unom-io/usbfs-iso
# Pinned by revision rather than floating: this is a transport under a real-time deadline and it
# should move when we choose to. Becomes a plain version dependency once the crates are published.
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
[lints]
workspace = true
+99 -6
View File
@@ -18,6 +18,29 @@ use std::time::Duration;
/// observes its `running=false` flag promptly on teardown.
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
const PAD_BITS: u32 = 4;
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
/// rather than failing the build.
const _: () = assert!(
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
);
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
///
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
/// without a live session handle — the shift arithmetic is the part worth pinning.
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(low) << 16)
| jlong::from(high)
}
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
const TAG_LED: u8 = 0x01;
const TAG_PLAYER_LEDS: u8 = 0x02;
@@ -54,12 +77,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
Ok(cmd) => {
(jlong::from(cmd.pad & 0xF) << 49)
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(cmd.low) << 16)
| jlong::from(cmd.high)
}
// A pad whose coils are ACTIVELY being driven by the 0xD1 haptics stream must not see
// wire rumble: `DsDevice` sets `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble
// write, and that bit disables the audio-haptics path — so one replayed command would
// mute the coils the stream is driving. Gating on *arrival of haptics frames* rather
// than on "a stream is open" is what keeps a rumble-only title working: it renders no
// haptics audio, so the host emits nothing on 0xD1 and the pad keeps its rumble.
// Dropping it here rather than in Kotlin keeps the rule next to the reason.
Ok(cmd) if crate::pad_audio::haptics_owns_coils((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
@@ -156,7 +182,74 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
out[3..n].copy_from_slice(&data);
n
}
HidOutput::AudioCtl { .. } => {
// DS5 pad-audio routing/volumes — no Android replay path yet (the 0xD1 sample
// plane isn't rendered here either); drop it like TrackpadHaptic.
return -1;
}
};
n as jint
})
}
#[cfg(test)]
mod pack_rumble_tests {
use super::*;
use punktfunk_core::input::MAX_PADS;
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
/// broken, and nothing else in the build would say so.
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
let pad = ((ev >> 49) & 0xF) as u16;
let backstop = ((ev >> 32) & 0xFFFF) as u32;
let low = ((ev >> 16) & 0xFFFF) as u16;
let high = (ev & 0xFFFF) as u16;
(pad, low, high, backstop)
}
#[test]
fn round_trips_every_field_at_its_extremes() {
for &(pad, low, high, backstop) in &[
(0u16, 0u16, 0u16, 0u32),
(15, 0xFFFF, 0xFFFF, 0xFFFF),
(1, 0x1234, 0x5678, 500),
(7, 0, 0xFFFF, 2000),
] {
let ev = pack_rumble(pad, low, high, backstop);
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
}
}
#[test]
fn every_representable_pad_survives_the_four_bit_field() {
for pad in 0..MAX_PADS as u16 {
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
}
}
#[test]
fn a_packed_command_is_never_negative() {
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
// "no command" and the rumble would simply vanish.
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
assert!(pack_rumble(0, 0, 0, 0) >= 0);
}
#[test]
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
let ev = pack_rumble(3, 0, 0, u32::MAX);
let (pad, _, _, backstop) = unpack(ev);
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
assert_eq!(backstop, 0xFFFF);
}
#[test]
fn a_stop_is_distinguishable_from_a_hold() {
let stop = pack_rumble(2, 0, 0, 0);
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
assert_ne!(stop, hold);
assert_eq!(unpack(stop).1, 0);
assert_eq!(unpack(stop).2, 0);
}
}
+2
View File
@@ -37,6 +37,8 @@ mod discovery;
mod feedback;
#[cfg(target_os = "android")]
mod mic;
/// Tier-A DualSense pad audio: the 0xD1 plane rendered on the pad's own USB endpoint.
mod pad_audio;
mod session;
mod stats;
// Ungated like `discovery`: pure `jni` + `punktfunk_core::wol` (no Android framework), so it links
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -145,6 +145,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
timeout_ms: jint,
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
) -> jlong {
let host: String = match env.get_string(&host) {
Ok(s) => s.into(),
@@ -268,7 +269,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// CLIENT_CAP_PAD_AUDIO is the SESSION-level negotiation, separate from the per-pad
// arrival bits: without it the host never sets HOST_CAP_PAD_AUDIO and never emits 0xD1,
// so declaring a pad's render caps later would have nothing to gate. Gated on the
// settings so a user with pad audio off does not make the host provision endpoints.
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
| if pad_audio_ok != 0 {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
// on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode
@@ -291,6 +301,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio: Mutex::new(None),
#[cfg(target_os = "android")]
mic: Mutex::new(None),
#[cfg(target_os = "android")]
pad_audio: Mutex::new(None),
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
+15
View File
@@ -61,6 +61,11 @@ pub(crate) struct SessionHandle {
audio: Mutex<Option<crate::audio::AudioPlayback>>,
#[cfg(target_os = "android")]
mic: Mutex<Option<crate::mic::MicCapture>>,
/// Tier-A DualSense pad audio (the 0xD1 plane), started by `nativeStartPadAudio` once Kotlin
/// has claimed the pad's audio interface and handed its descriptor over. Session-lifetime and
/// `Option` because a session may have no wired DualSense at all, which is the common case.
#[cfg(target_os = "android")]
pub(crate) pad_audio: Mutex<Option<crate::pad_audio::PadAudio>>,
/// In-stream mic mute, set via `nativeSetMicMuted` and read per 10 ms frame by the mic's
/// encode loop ([`crate::mic`]). Session-lifetime rather than per-[`crate::mic::MicCapture`]
/// for the same reason the stats gate is: the mic stops and restarts across a surface
@@ -99,6 +104,14 @@ impl SessionHandle {
fn stop_mic(&self) {
let _ = self.mic.lock().unwrap().take();
}
/// Stop pad audio. Dropping the [`crate::pad_audio::PadAudio`] joins its render thread, which
/// is what guarantees nothing is still writing to the descriptor when Kotlin closes the
/// `UsbDeviceConnection`. Idempotent.
#[cfg(target_os = "android")]
pub(crate) fn stop_pad_audio(&self) {
let _ = self.pad_audio.lock().unwrap().take();
}
}
impl Drop for SessionHandle {
@@ -108,6 +121,8 @@ impl Drop for SessionHandle {
self.stop_audio();
#[cfg(target_os = "android")]
self.stop_mic();
#[cfg(target_os = "android")]
self.stop_pad_audio();
}
}
@@ -460,6 +460,111 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
/// streaming interface. Kotlin owns that connection and **must keep it open until
/// `nativeStopPadAudio` returns**: the renderer borrows the descriptor and never closes it, so
/// closing early would pull it out from under an in-flight isochronous transfer.
///
/// Returns `false` when there is nothing to render (both kinds disabled) or the thread would not
/// start. A kernel that refuses the interface claim is NOT reported here — the renderer discovers
/// that on its own thread and degrades to tier C, because some OEM kernels refuse and there is no
/// app-side fix worth blocking a session on.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
// Replace any previous renderer first: dropping it joins the old thread, so two of them
// can never hold the same descriptor at once.
h.stop_pad_audio();
// The capability declaration and the rumble suppression are NOT done here: the renderer
// makes both only once its USB stream actually opens (see `pad_audio::render`). Doing them
// at spawn time would, on a kernel that refuses the interface claim, take the pad off wire
// rumble and give it nothing in return — no haptics of any kind.
match crate::pad_audio::start(
std::sync::Arc::clone(&h.client),
pad as u8,
fd,
haptics != 0,
speaker != 0,
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
1
}
None => 0,
}
})
}
/// `NativeBridge.nativePadAudioSelfTest(fd, seconds, hz): Int` — drive the pad directly with a
/// tone through the real client render path, with no host and no session involved.
///
/// The check a standalone harness cannot make: it owns its descriptor by construction, so it can
/// never reveal that the client handed the renderer a descriptor something else was already
/// driving. Returns sample frames written, or negative on failure (see `pad_audio::SelfTest`).
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePadAudioSelfTest(
_env: JNIEnv,
_this: JObject,
fd: jni::sys::jint,
seconds: jni::sys::jint,
hz: jni::sys::jint,
) -> jni::sys::jint {
jni_guard(-1, || {
if fd < 0 {
return -1;
}
// SAFETY: Kotlin holds the owning UsbDeviceConnection open across this call and drives no
// other transfers on it (it opens a dedicated connection for exactly this).
unsafe { crate::pad_audio::self_test(fd, seconds, hz) }
})
}
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
#[no_mangle]
#[cfg(target_os = "android")]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudio(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
if (0..16).contains(&pad) {
// Withdraw the capability and hand the pad back to wire rumble, in that order:
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
h.client.set_pad_audio_caps(pad as u8, 0);
crate::pad_audio::set_tier_a(pad as u8, false);
crate::pad_audio::clear_haptics_liveness(pad as u8);
}
}
})
}
/// `NativeBridge.nativeSetMicMuted(handle, muted)` — mute/unmute the mic uplink mid-stream.
///
/// Muting deliberately does NOT stop the capture: the AAudio input stream, the input-preset rung
@@ -166,6 +166,11 @@ struct GamepadSettingsView: View {
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
@@ -218,7 +223,8 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
@@ -239,9 +245,13 @@ struct GamepadSettingsView: View {
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
.foregroundStyle(
.white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0))
}
}
// Contents only the glass and border below stay at full strength, so a dimmed row
// still reads as a row you can sit on (which you can: its detail is the point).
.opacity(row.enabled ? 1 : 0.45)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
@@ -276,6 +286,13 @@ struct GamepadSettingsView: View {
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
/// Dimmed and inert when false: a row whose meaning depends on another setting that is
/// currently off. It stays in the list and stays FOCUSABLE its `detail` is how the
/// user learns which switch to flip first, and a row that vanished mid-list would
/// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` /
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -286,12 +303,14 @@ struct GamepadSettingsView: View {
/// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool {
lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false }
return row.adjust(delta)
}
private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
guard let row = rows.first(where: { $0.id == id }), row.enabled else { return }
row.activate()
}
private var rows: [Row] {
@@ -391,27 +410,35 @@ struct GamepadSettingsView: View {
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
// The four rows below only mean something while something is being forwarded, so
// they follow the switch above the same relationship the touch settings draw with
// `.disabled(!effective.gamepadForwarding)`. This screen could not express it until
// `Row.enabled` existed, so it alone left them live and steppable.
choiceRow(
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
options: controllers, current: gamepads.preferredID,
enabled: gamepadForwarding
) { gamepads.preferredID = $0 },
choiceRow(
id: "padType", icon: "dpad", label: "Controller type",
detail: "The virtual pad the host creates — Automatic matches this controller.",
options: SettingsOptions.padTypes, current: gamepadType
options: SettingsOptions.padTypes, current: gamepadType,
enabled: gamepadForwarding
) { gamepadType = $0 },
choiceRow(
id: "systemButtons", icon: "house.circle", label: "Guide button",
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
+ "Automatic sends them to the host whenever this device delivers them.",
options: SettingsOptions.systemButtons, current: systemButtons
options: SettingsOptions.systemButtons, current: systemButtons,
enabled: gamepadForwarding
) { systemButtons = $0 },
choiceRow(
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
detail: "Hold Select alone to press the host's guide button — keep holding "
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
options: SettingsOptions.guideGestures, current: guideGesture
options: SettingsOptions.guideGestures, current: guideGesture,
enabled: gamepadForwarding
) { guideGesture = $0 },
choiceRow(
@@ -583,13 +610,15 @@ struct GamepadSettingsView: View {
private func choiceRow<T: Equatable>(
id: String, header: String? = nil, icon: String, label: String, detail: String,
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
options: [(label: String, tag: T)], current: T, enabled: Bool = true,
write: @escaping (T) -> Void
) -> Row {
let index = options.firstIndex { $0.tag == current }
return Row(
id: id, header: header, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
guard let index else {
@@ -610,12 +639,13 @@ struct GamepadSettingsView: View {
private func toggleRow(
id: String, header: String? = nil, icon: String, label: String, detail: String,
value: Binding<Bool>
value: Binding<Bool>, enabled: Bool = true
) -> Row {
Row(
id: id, header: header, icon: icon, label: label,
value: value.wrappedValue ? "On" : "Off",
detail: detail,
enabled: enabled,
adjust: { delta in
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
let target = delta > 0
@@ -12,7 +12,7 @@ import GameController
public final class ControllerTester: ObservableObject {
// `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private let renderer = RumbleRenderer()
private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
@@ -98,8 +98,17 @@ public final class GamepadCapture {
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
/// capture) has no Apple equivalent worth wiring macOS has Q/D, touch has the HUD.
private static let escapeChord: UInt32 =
/// Internal rather than private only so `GamepadEscapeChordTests` can pin it against
/// `escapeChordElements` below the two must not drift.
static let escapeChord: UInt32 =
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// `escapeChord`'s four elements by GameController alias the ONLY system gestures claimed
/// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and
/// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two
/// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV.
static let escapeChordElements = [
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
]
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long the HOST's guide goes
@@ -236,7 +245,17 @@ public final class GamepadCapture {
// gesture attached the press is the system's, not the game's. During capture the remote
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
for element in c.physicalInputProfile.elements.values {
//
// With forwarding OFF none of that applies no press reaches the host, so taking the
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
// is still read off this slot, and on tvOS it is the only controller way out of a
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
// its gesture attached on tvOS and the press is the system's the chord would never
// complete and the session would have no controller exit at all.)
let claimed = forwarding
? Array(c.physicalInputProfile.elements.values)
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
for element in claimed {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
@@ -276,7 +295,11 @@ public final class GamepadCapture {
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
}
}
if let motion = c.motion {
// Motion is wire-only `forwardMotion` has nothing to do with forwarding off, and no
// local feature reads it. Powering the IMU anyway costs the pad real battery (it streams
// gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power
// it back down), so with nothing to forward we simply never turn it on.
if forwarding, let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
@@ -65,7 +65,7 @@ public final class GamepadFeedback {
#if os(iOS)
if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice),
CHHapticEngine.capabilitiesForHardware().supportsHaptics {
deviceRumble = RumbleRenderer(policy: .session, actuator: .device)
deviceRumble = RumbleRenderer(actuator: .device)
} else {
deviceRumble = nil
}
@@ -136,7 +136,7 @@ public final class GamepadFeedback {
replay(slot)
} else {
slots[pad] = Slot(controller: controller)
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(controller)
withRouting { rumbleByPad[pad] = renderer }
}
@@ -43,8 +43,14 @@ enum RumbleTuning {
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Wire amplitude DualSense HID motor byte. A nonzero command never collapses to silence:
/// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as
/// nothing at all on this path. Floored at 1 imperceptibly light, but moving. (Android's
/// `toAmplitude` has always done this; this was the odd one out.)
static func hidByte(_ wire: UInt16) -> UInt8 {
let b = UInt8(wire >> 8)
return wire != 0 && b == 0 ? 1 : b
}
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
@@ -81,10 +87,11 @@ enum RumbleTuning {
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a
/// `sessionStaleSeconds`; both are gone. Every liveness decision lease expiry, legacy-host
/// staleness, session close now belongs to punktfunk-core's shared policy engine
/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it
/// is told and never decides on its own when a level should end.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
@@ -93,17 +100,6 @@ enum RumbleTuning {
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
/// commands verbatim the engine (punktfunk-core `client/rumble.rs`) owns every lease,
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
/// the call sites' intent.
struct Policy {
static let session = Policy()
static let manual = Policy()
}
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
/// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) the opt-in
/// "rumble on this device" mirror for phone-clip pads that ship without rumble motors.
@@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable {
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
private let actuator: Actuator
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
@@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable {
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif
init(policy: Policy = .session, actuator: Actuator = .controller) {
self.policy = policy
init(actuator: Actuator = .controller) {
self.actuator = actuator
}
@@ -0,0 +1,52 @@
import GameController
import XCTest
@testable import PunktfunkKit
/// The escape chord's mask and its GameController alias list have to describe the same four
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
/// is on, but only of `escapeChordElements` while it is off so if the alias list ever stops
/// covering the mask, the missing button's press stays the system's and the chord never completes.
///
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
/// Hence a test on the invariant rather than trusting the comment beside it.
@MainActor
final class GamepadEscapeChordTests: XCTestCase {
/// The intended aliasbit pairing, spelled out independently of the implementation.
private let pairing: [(alias: String, bit: UInt32)] = [
(GCInputLeftShoulder, GamepadWire.leftShoulder),
(GCInputRightShoulder, GamepadWire.rightShoulder),
(GCInputButtonMenu, GamepadWire.start),
(GCInputButtonOptions, GamepadWire.back),
]
func testChordMaskIsExactlyTheFourPairedButtons() {
XCTAssertEqual(
pairing.reduce(UInt32(0)) { $0 | $1.bit },
GamepadCapture.escapeChord,
"the chord mask and the alias pairing describe different buttons")
}
func testEveryChordBitHasAnElementToClaim() {
// One alias per bit a mask that grew a fifth button without a matching alias would
// leave that button's gesture with the OS while forwarding is off.
XCTAssertEqual(
GamepadCapture.escapeChordElements.count,
GamepadCapture.escapeChord.nonzeroBitCount,
"alias list and chord mask differ in size")
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
}
/// The claim list is a strict subset of what a forwarding slot takes it is a NARROWING of
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
/// is the behaviour this deliberately avoids).
func testClaimListIsNonEmptyAndAllDistinct() {
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
XCTAssertEqual(
Set(GamepadCapture.escapeChordElements).count,
GamepadCapture.escapeChordElements.count,
"a repeated alias would mean a chord bit has no element")
}
}
@@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y))
XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT))
XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT))
XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS))
XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS))
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
@@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase {
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
@@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase {
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
let renderer = RumbleRenderer(policy: .session)
let renderer = RumbleRenderer()
renderer.retarget(nil)
renderer.apply(low: 0x8000, high: 0x8000)
Thread.sleep(forTimeInterval: 0.1)
+11
View File
@@ -286,6 +286,12 @@ mod session_main {
// Spawned at first params-build so it exists for --connect AND console launches.
#[cfg(unix)]
crate::ctl_socket::spawn(gamepad.clone());
// Pad-audio prefs to OUR gamepad service (same reasoning as the pin above): tier-A
// slots declare their render caps at open time, which happens on attach — after this.
gamepad.set_pad_audio_prefs(
settings.pad_haptics,
pf_client_core::pad_audio::speaker_active(&settings.pad_speaker),
);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -389,6 +395,11 @@ mod session_main {
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
mic_enabled: settings.mic_enabled,
echo_cancel: settings.echo_cancel,
// Pad audio (0xD1): the DualSense haptics/speaker render settings. The gamepad
// service learns the same prefs below so tier-A slots declare their render caps
// at open; the session pump gates CLIENT_CAP_PAD_AUDIO + the renderer on these.
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
+13 -3
View File
@@ -981,6 +981,13 @@ pub(crate) fn settings_page(
s.forward_pad = key.unwrap_or_default();
s.save();
})
// Dimmed with the master switch above it, like echo cancellation under the mic
// (see that row) — this and the three below have nothing to act on while no
// controller is forwarded at all. Every commit bumps `rev` and re-renders this
// screen, so they follow the toggle live. Brings this client in line with how GTK
// (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the
// console UI (dim + refuse the step) have always drawn the same relationship.
.enabled(s.gamepad_forwarding)
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
@@ -991,7 +998,8 @@ pub(crate) fn settings_page(
});
let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| {
s.gamepad = GAMEPADS[i].0.to_string();
});
})
.enabled(s.gamepad_forwarding);
let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons);
let sysbtn_combo = setting_combo(
ctx,
@@ -1002,7 +1010,8 @@ pub(crate) fn settings_page(
|s, i| {
s.system_buttons = SYSTEM_BUTTONS[i].0.to_string();
},
);
)
.enabled(s.gamepad_forwarding);
let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture);
let gesture_combo = setting_combo(
ctx,
@@ -1013,7 +1022,8 @@ pub(crate) fn settings_page(
|s, i| {
s.guide_gesture = GUIDE_GESTURES[i].0.to_string();
},
);
)
.enabled(s.gamepad_forwarding);
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| {
s.touch_mode = TOUCH_MODES[i].0.to_string();
+4
View File
@@ -57,6 +57,10 @@ sdl3 = { version = "0.18", features = ["hidapi"] }
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
# Pad-audio correlation (pad_audio.rs): the HID devnode's ContainerID and a render endpoint's
# stamped PKEY_Device_ContainerId both live in the registry — read-only, which sidesteps COM
# property stores entirely (the same version the host pins).
winreg = "0.56"
sdl3 = { version = "0.18", features = ["hidapi", "build-from-source"] }
# D3D11VA decode (video_d3d11.rs): device/adapter selection, DXVA probes, and the shared
# NT-handle hand-off ring. Same pinned rev as clients/windows so the workspace builds ONE
+31 -1
View File
@@ -98,13 +98,43 @@ pub fn devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
/// Settings device pickers via session main), or the OS default. A picked device that's
/// gone (unplugged USB DAC, remote session) falls back to the default with a warning —
/// audio keeps working, like the PipeWire twin's `target.object` behavior.
/// Resolve an active endpoint by id WITHOUT `DeviceEnumerator::get_device`.
///
/// That helper builds its argument as `PCWSTR::from_raw(HSTRING::from(id).as_ptr())` — the
/// `HSTRING` is a temporary, dropped at the end of that statement, so `GetDevice` reads freed
/// memory and misses ids that are perfectly valid. Scanning the active collection touches only
/// safe crate APIs, so it cannot regress the same way. (`punktfunk-host` fixes the same bug with
/// raw COM instead; this crate cannot, because it pins a different `windows` revision than
/// `wasapi` does, making the two `IMMDevice` types incompatible.)
pub(crate) fn device_by_id(
enumerator: &DeviceEnumerator,
direction: &Direction,
id: &str,
) -> Result<wasapi::Device> {
let devices = enumerator
.get_device_collection(direction)
.map_err(|e| anyhow!("enumerate {direction:?} endpoints: {e}"))?;
let count = devices
.get_nbr_devices()
.map_err(|e| anyhow!("endpoint count: {e}"))?;
for i in 0..count {
let dev = devices
.get_device_at_index(i)
.map_err(|e| anyhow!("endpoint {i}: {e}"))?;
if dev.get_id().is_ok_and(|got| got == id) {
return Ok(dev);
}
}
anyhow::bail!("no active {direction:?} endpoint with id {id}")
}
fn pick_device(
enumerator: &DeviceEnumerator,
direction: &Direction,
var: &str,
) -> Result<wasapi::Device> {
if let Some(id) = std::env::var(var).ok().filter(|v| !v.is_empty()) {
match enumerator.get_device(&id) {
match device_by_id(enumerator, direction, &id) {
Ok(d) => {
tracing::info!(
var,
+346 -9
View File
@@ -369,8 +369,14 @@ enum Ctl {
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
SystemButtons { forward_raw: bool, gesture: bool },
SystemButtons {
forward_raw: bool,
gesture: bool,
},
TapButton(u32),
/// Which pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
/// speaker) — the settings half of the per-pad tier-A capability declared at slot open.
PadAudioPrefs(u8),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -573,6 +579,18 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1));
}
/// Declare which pad-audio streams this session's settings want rendered (`haptics` =
/// [`Settings::pad_haptics`](crate::trust::Settings::pad_haptics), `speaker` =
/// `pad_speaker == "pad"` via [`crate::pad_audio::speaker_active`]). Drives the per-pad
/// tier-A capability bits declared to the core at slot open — a WIRED DualSense/Edge
/// declares exactly these; every other pad declares 0. Call before [`Self::attach`],
/// like [`Self::set_kind_override`]: slots declare at open time. Defaults to "nothing"
/// for an embedder that never calls it, keeping the wire bytes exactly as before.
pub fn set_pad_audio_prefs(&self, haptics: bool, speaker: bool) {
let bits = (haptics as u8) | ((speaker as u8) << 1);
let _ = self.ctl.send(Ctl::PadAudioPrefs(bits));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -732,13 +750,29 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
/// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim.
/// Enable bits select only the fields each update touches, so rumble (driven separately
/// through SDL) and untouched fields keep their state.
///
/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading
/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and
/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is
/// host-side and neither crate depends on the other, and a DualSense report layout has no business
/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and
/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `1` relationship rather than
/// leaving it to a comment.
struct Ds5Feedback;
impl Ds5Feedback {
const RIGHT_TRIGGER: usize = 10;
const LEFT_TRIGGER: usize = 21;
const PAD_LIGHTS: usize = 43;
const LED_RGB: usize = 44;
/// The USB report offsets these are derived from — see the type doc. Kept beside the derived
/// values so the subtraction is visible at the point of definition.
const REPORT_ID_LEN: usize = 1;
/// The audio-control region (`ucHeadphoneVolume`…`ucAudioMuteBits`): report byte 5.
const AUDIO: usize = 5 - Self::REPORT_ID_LEN;
const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN;
const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN;
const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN;
const LED_RGB: usize = 45 - Self::REPORT_ID_LEN;
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors
/// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary.
const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize;
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
let mut p = [0u8; 47];
@@ -748,7 +782,7 @@ impl Ds5Feedback {
(0x08, Self::LEFT_TRIGGER)
};
p[0] = flag;
let n = effect.len().min(11);
let n = effect.len().min(Self::TRIGGER_LEN);
p[off..off + n].copy_from_slice(&effect[..n]);
p
}
@@ -768,6 +802,29 @@ impl Ds5Feedback {
p[Self::PAD_LIGHTS] = bits & 0x1F;
p
}
/// The one-shot tier-A activation packet — the SDL disable-bit trap undone. `p[0]`
/// (`ucEnableBits1`) bit0 = "enable rumble emulation" and bit1 = "disable audio haptics"
/// (SDL_hidapi_ps5.c); SDL sets BOTH whenever its rumble path runs, which mutes the very
/// voice coils the 0xD1 haptics stream drives. Per SDL's own comment — "Leaving emulated
/// rumble bits off will restore audio haptics" — a packet with those bits CLEARED (and no
/// other valid flag, so nothing else is touched) puts the pad back on audio haptics.
fn audio_haptics_packet() -> [u8; 47] {
[0u8; 47]
}
/// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report
/// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/
/// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags
/// (`flags` bits1..4 = report `flag0` bits 4..7). `flags` bit0 (haptics-select, `flag0`
/// bit1 = SDL's "disable audio haptics") is deliberately NOT replayed: bits 0/1 stay
/// clear so the pad's audio haptics stay live (see [`audio_haptics_packet`]).
fn audio_ctl_packet(flags: u8, raw: &[u8; 6]) -> [u8; 47] {
let mut p = [0u8; 47];
p[0] = (flags & 0x1E) << 3;
p[Self::AUDIO..Self::AUDIO + 6].copy_from_slice(raw);
p
}
}
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
@@ -804,6 +861,14 @@ struct Slot {
/// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's
/// `guide_gesture` policy is on.
gesture: SelectGesture,
/// Pad-audio render capabilities declared for this slot (bit0 = haptics, bit1 = speaker
/// — the [`NativeClient::set_pad_audio_caps`] bits). Nonzero only for a tier-A pad (a
/// WIRED DualSense/Edge, see [`crate::pad_audio::is_tier_a_ds5`]) under matching
/// settings; bit0 set additionally suppresses wire rumble for this slot (the SDL
/// disable-bit trap — see [`Worker::render_feedback`]).
audio_caps: u8,
/// The wire-rumble-suppressed notice fired for this slot (log once, not per command).
rumble_suppressed_logged: bool,
}
impl Slot {
@@ -820,6 +885,8 @@ impl Slot {
held_clicks: [false; 2],
last_accel: [0; 3],
gesture: SelectGesture::default(),
audio_caps: 0,
rumble_suppressed_logged: false,
}
}
@@ -957,6 +1024,10 @@ struct Worker {
/// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the
/// down went out on receipt, the up goes out from the poll once `due` passes.
synthetic_ups: Vec<(u8, u32, Instant)>,
/// Pad-audio streams the session's settings want rendered (bit0 = haptics, bit1 =
/// speaker — [`GamepadService::set_pad_audio_prefs`]). `0` (the default) until an embedder
/// declares some: tier-A detection then never runs and every arrival stays caps-less.
pad_audio_prefs: u8,
attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>,
@@ -1162,11 +1233,18 @@ impl Worker {
Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad);
Self::set_slot_sensors(&mut slot, true);
slot.audio_caps = self.pad_audio_caps_for(id, &slot.pad);
// Declare this pad's kind BEFORE any of its input, so the host builds a matching
// virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind.
if let Some(c) = &self.attached {
// Pad-audio render caps go in FIRST — the core ORs them into this (and
// every re-sent) arrival's flags bits 8/9 toward a capable host. ALWAYS
// set (0 for non-tier-A): wire indices are reused within a connection, so
// a tier-A slot that closes must not leave its bits behind for the next
// pad on the same index (the set_rumble_quirks rule).
c.set_pad_audio_caps(index, slot.audio_caps);
send(
c,
InputKind::GamepadArrival,
@@ -1189,6 +1267,27 @@ impl Worker {
};
c.set_rumble_quirks(index as u16, quirks);
}
if slot.audio_caps != 0 {
if slot.audio_caps & 0x01 != 0 {
// Tier-A haptics activation: the SDL disable-bit trap. SDL's DS5
// driver sets ucEnableBits1 0x01|0x02 ("enable rumble emulation" +
// "disable audio haptics") whenever its rumble path runs — which
// would MUTE the voice coils the 0xD1 stream drives. One effects
// packet with those bits CLEARED puts the pad back on audio haptics
// ("Leaving emulated rumble bits off will restore audio haptics" —
// SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in
// render_feedback so SDL never re-arms them.
let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet());
}
// Hand the pad to the session's renderer worker. Windows correlation
// needs the HID interface path; Linux matches the sink by signature.
crate::pad_audio::register_tier_a(index, slot.pad.path());
tracing::info!(
index,
caps = slot.audio_caps,
"tier-A DualSense: pad-audio render caps declared"
);
}
tracing::info!(
id,
index,
@@ -1202,6 +1301,35 @@ impl Worker {
}
}
/// This pad's pad-audio render capabilities (the bits [`NativeClient::set_pad_audio_caps`]
/// takes): the settings prefs for a tier-A pad — a physical DualSense/Edge (by VID:PID,
/// never the DECLARED kind: the stream renders on the controller in the user's hands) on
/// a WIRED connection — and `0` for everything else (tier B/C are out of scope). Wired
/// comes from `SDL_GetGamepadConnectionState`; when SDL answers Unknown, the pad's 4-ch
/// audio sibling existing is the fallback signal (Bluetooth exposes no audio device).
fn pad_audio_caps_for(&self, id: u32, pad: &sdl3::gamepad::Gamepad) -> u8 {
if self.pad_audio_prefs == 0 {
return 0; // nothing wanted — skip the (possibly probing) wired check entirely
}
let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let vid = self.subsystem.vendor_for_id(jid).unwrap_or(0);
let pid = self.subsystem.product_for_id(jid).unwrap_or(0);
if !crate::pad_audio::is_tier_a_ds5(vid, pid, true) {
return 0; // not a DualSense/Edge — no wired check needed
}
use sdl3::joystick::ConnectionState;
let wired = match pad.connection_state() {
Ok(ConnectionState::Wired) => true,
Ok(ConnectionState::Wireless) => false,
_ => crate::pad_audio::wired_audio_sibling(pad.path().as_deref()),
};
if crate::pad_audio::is_tier_a_ds5(vid, pid, wired) {
self.pad_audio_prefs
} else {
0
}
}
/// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
/// already gone (unplug).
@@ -1219,6 +1347,11 @@ impl Worker {
send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index);
}
let slot = self.slots.remove(i);
if slot.audio_caps != 0 {
// Take the pad back from the pad-audio renderer (its device-gone path then
// re-correlates — and finds nothing until a tier-A pad registers again).
crate::pad_audio::unregister_tier_a(slot.index);
}
tracing::info!(
id = slot.id,
index = slot.index,
@@ -1640,6 +1773,7 @@ impl Worker {
set_valve_hidapi(false);
}
}
Ok(Ctl::PadAudioPrefs(bits)) => self.pad_audio_prefs = bits & 0x03,
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1917,7 +2051,12 @@ impl Worker {
let dur_ms: u32 = if (low, high) == (0, 0) {
100 // a stop takes effect immediately; the duration is irrelevant
} else {
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
// No local floor. There was a `.max(160)` here, and it could never do anything: the
// engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy
// value, so a non-zero command's backstop is never below 500. A floor that belongs to a
// particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine
// already applies — not re-invented per renderer where it can silently disagree.
backstop_ms
};
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
@@ -1947,6 +2086,20 @@ impl Worker {
// first; the physical silence backstop is in `close_slot_at`).
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
// The SDL disable-bit trap: ANY SDL rumble write sets ucEnableBits1
// 0x01|0x02, muting the very voice coils the 0xD1 haptics stream drives —
// so a slot with tier-A haptics active never issues wire rumble (the stream
// carries the feedback; the game's rumble is in its haptics mix).
if slot.audio_caps & 0x01 != 0 {
if !slot.rumble_suppressed_logged {
slot.rumble_suppressed_logged = true;
tracing::info!(
pad = slot.index,
"wire rumble suppressed — the pad-audio haptics stream carries feedback"
);
}
continue;
}
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
}
}
@@ -1984,13 +2137,27 @@ impl Worker {
.pad
.send_effect(&Ds5Feedback::trigger_packet(which, effect));
}
// The audio-control region of a DS5 output report a game wrote host-side
// (volumes + routing; the SAMPLES ride 0xD1) — folded back into the physical
// pad's effects packet, but only where a tier-A renderer is actually live
// (`audio_caps`): replaying speaker volumes at a pad whose audio device
// nothing streams to would just mute/blast a future session's start state.
// Non-tier-A pads keep dropping it (the pre-pad-audio behaviour).
HidOutput::AudioCtl { flags, raw, .. } if is_ds && slot.audio_caps != 0 => {
let _ = slot
.pad
.send_effect(&Ds5Feedback::audio_ctl_packet(flags, &raw));
}
// Deliberately unhandled, listed rather than left to a bare `_` so a new
// variant cannot join them silently: adaptive triggers exist only on a
// DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific
// and carried by `send_effect` above when the pad is one.
// and carried by `send_effect` above when the pad is one. `AudioCtl` lands here
// only when the guarded arm above declined it — a non-DualSense pad, or one with
// no live tier-A renderer — which is the pre-pad-audio behaviour: drop it.
HidOutput::Trigger { .. }
| HidOutput::TrackpadHaptic { .. }
| HidOutput::HidRaw { .. } => {}
| HidOutput::HidRaw { .. }
| HidOutput::AudioCtl { .. } => {}
}
}
}
@@ -2029,6 +2196,9 @@ fn hidout_pad(h: &HidOutput) -> u8 {
| HidOutput::Trigger { pad, .. }
| HidOutput::TrackpadHaptic { pad, .. }
| HidOutput::HidRaw { pad, .. } => *pad,
// AudioCtl's pad is the plane's only u16. `HidOutput::decode` rejects anything at or
// above MAX_PADS (B27), so by the time one reaches here the narrowing is lossless.
HidOutput::AudioCtl { pad, .. } => *pad as u8,
}
}
@@ -2056,6 +2226,7 @@ impl Worker {
system_forward: true,
guide_gesture: false,
synthetic_ups: Vec::new(),
pad_audio_prefs: 0,
attached: None,
escape_tx,
disconnect_tx,
@@ -2501,6 +2672,172 @@ mod slot_tests {
}),
6
);
// AudioCtl's wire pad is u16; the index space is 0..MAX_PADS end to end.
assert_eq!(
hidout_pad(&HidOutput::AudioCtl {
pad: 7,
flags: 0,
raw: [0; 6]
}),
7
);
}
/// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct
/// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as
/// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1)
/// stay CLEAR — setting either would mute the voice coils the 0xD1 stream drives.
#[test]
fn audio_ctl_folds_report_bytes_into_effect_offsets() {
let raw = [0x50, 0x60, 0x70, 0x05, 0x11, 0x22];
// flags 0b1_0111: haptics-select (bit0) + audio-valid bits 1/2/4 of the condensed form.
let p = Ds5Feedback::audio_ctl_packet(0b1_0111, &raw);
assert_eq!(&p[4..10], &raw, "report bytes 5..=10 → struct 4..=9");
// bits1..4 (0b1011) → flag0 bits 4..7.
assert_eq!(p[0], 0b1011_0000);
assert_eq!(
p[0] & 0x03,
0,
"haptics-select must NOT replay into p[0] bits 0/1"
);
// Nothing else is touched: no trigger/LED enable bits, no stray bytes.
assert!(p[1..4].iter().all(|&b| b == 0));
assert!(p[10..].iter().all(|&b| b == 0));
// No audio-valid flags condenses to no enable bits (raw still carried verbatim).
let p = Ds5Feedback::audio_ctl_packet(0b0_0001, &raw);
assert_eq!(p[0], 0);
assert_eq!(&p[4..10], &raw);
// The tier-A activation packet is the all-clear: every enable bit off — per
// SDL_hidapi_ps5.c, leaving the emulated-rumble bits off restores audio haptics.
assert_eq!(Ds5Feedback::audio_haptics_packet(), [0u8; 47]);
}
}
/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple
/// writer are all pinned by their own suites; this writer had nothing, despite being the one that
/// hand-shifts every offset by the report-id length.
#[cfg(test)]
mod ds5_feedback_tests {
use super::*;
/// The USB output report offsets, written out independently of the implementation. A DS5
/// effects payload is the same block with the leading report id removed, so every offset is
/// exactly one lower — this is the relationship the derived constants encode.
#[test]
fn ds5_offsets_track_the_usb_report() {
for (usb, payload) in [
(11usize, Ds5Feedback::RIGHT_TRIGGER),
(22, Ds5Feedback::LEFT_TRIGGER),
(44, Ds5Feedback::PAD_LIGHTS),
(45, Ds5Feedback::LED_RGB),
] {
assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}");
}
assert_eq!(Ds5Feedback::TRIGGER_LEN, 11);
}
#[test]
fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() {
let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33);
assert_eq!(p.len(), 47);
assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit");
assert_eq!(p[0], 0, "must not claim any valid_flag0 field");
assert_eq!(
(
p[Ds5Feedback::LED_RGB],
p[Ds5Feedback::LED_RGB + 1],
p[Ds5Feedback::LED_RGB + 2]
),
(0x11, 0x22, 0x33)
);
// Everything else stays zero — an over-broad packet would blank the triggers/player LEDs
// it never meant to touch.
let touched = [
1,
Ds5Feedback::LED_RGB,
Ds5Feedback::LED_RGB + 1,
Ds5Feedback::LED_RGB + 2,
];
assert!(p
.iter()
.enumerate()
.all(|(i, &b)| touched.contains(&i) || b == 0));
}
#[test]
fn player_leds_are_masked_to_five_bits() {
let p = Ds5Feedback::player_packet(0xFF);
assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit");
assert_eq!(
p[Ds5Feedback::PAD_LIGHTS],
0x1F,
"high bits are not ours to set"
);
let p = Ds5Feedback::player_packet(0b0000_0101);
assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101);
}
/// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the
/// pairing most likely to be transcribed backwards.
#[test]
fn trigger_which_selects_the_right_flag_and_offset() {
let eff: Vec<u8> = (1..=11).collect();
let r = Ds5Feedback::trigger_packet(1, &eff);
assert_eq!(r[0], 0x04, "valid_flag0 R2 bit");
assert_eq!(
&r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11],
&eff[..]
);
assert_eq!(
r[Ds5Feedback::LEFT_TRIGGER],
0,
"the other trigger is untouched"
);
let l = Ds5Feedback::trigger_packet(0, &eff);
assert_eq!(l[0], 0x08, "valid_flag0 L2 bit");
assert_eq!(
&l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11],
&eff[..]
);
assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0);
}
#[test]
fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() {
let long = vec![0xAAu8; 40];
let p = Ds5Feedback::trigger_packet(1, &long);
assert_eq!(p.len(), 47);
// Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on.
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA);
assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0);
}
#[test]
fn a_short_effect_leaves_the_rest_of_the_block_zeroed() {
let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02);
assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99);
assert!(
p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11]
.iter()
.all(|&b| b == 0)
);
}
/// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert
/// its enable bit, or the pad keeps whatever effect it was holding.
#[test]
fn an_empty_effect_is_a_release_not_a_no_op() {
let p = Ds5Feedback::trigger_packet(1, &[]);
assert_eq!(p[0], 0x04);
assert!(
p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11]
.iter()
.all(|&b| b == 0)
);
}
}
+5
View File
@@ -47,6 +47,11 @@ pub mod os;
// Client settings profiles: the override catalog + the one connect-time resolver
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
// the bindings live on.
// Pad audio (the 0xD1 plane): DualSense voice-coil haptics + speaker rendered on the wired
// physical pad's own 4-ch audio device — correlation, the per-session renderer worker, and
// the tier-A pad registry the gamepad worker feeds it through.
#[cfg(any(target_os = "linux", windows))]
pub mod pad_audio;
#[cfg(any(target_os = "linux", windows))]
pub mod profiles;
#[cfg(any(target_os = "linux", windows))]
File diff suppressed because it is too large Load Diff
+35
View File
@@ -44,6 +44,14 @@ pub struct SessionParams {
/// Run the uplink through the platform's echo cancellation ([`Settings::echo_cancel`]).
/// Ignored when `mic_enabled` is false; `PUNKTFUNK_NO_AEC=1` overrides it off.
pub echo_cancel: bool,
/// Render the host's per-pad DualSense voice-coil haptics stream (0xD1 kind 0) on a wired
/// physical DualSense ([`crate::trust::Settings::pad_haptics`]). With `pad_speaker` it
/// gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the pad-audio renderer thread.
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) goes: `"pad"` | `"mix"` |
/// `"off"` ([`crate::trust::Settings::pad_speaker`]; `"mix"` is a TODO that renders as
/// off — see [`crate::pad_audio::speaker_active`]).
pub pad_speaker: String,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
@@ -356,6 +364,11 @@ fn pump(
);
}
}
// Pad audio (0xD1): advertise only when the settings could render a stream — the per-pad
// tier-A detection at slot open (gamepad.rs) still decides which pads declare render caps
// on their arrivals, so this bit alone changes nothing without a wired DualSense.
let pad_speaker_on = crate::pad_audio::speaker_active(&params.pad_speaker);
let pad_audio_on = params.pad_haptics || pad_speaker_on;
let connector = match NativeClient::connect(
&params.host,
params.port,
@@ -379,6 +392,11 @@ fn pump(
0
}) | (if params.phase_lock {
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK
} else {
0
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
}) | (if pad_audio_on {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}),
@@ -501,6 +519,20 @@ fn pump(
// app-lifetime service's job (the UI attaches it on Connected). Audio runs on its own
// thread (one puller per plane), blocking on the audio queue like the Apple client.
let audio_thread = spawn_audio(connector.clone(), stop.clone());
// Pad audio (0xD1): its own drain thread (that plane's single consumer), spawned whenever
// the settings could render. The output device is opened LAZILY once frames actually
// arrive — which only happens after a tier-A pad declared render caps on its arrival — so
// a session without a wired DualSense costs one idle 10 ms poll loop.
let pad_audio_thread = pad_audio_on
.then(|| {
crate::pad_audio::spawn(
connector.clone(),
stop.clone(),
params.pad_haptics,
pad_speaker_on,
)
})
.flatten();
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
@@ -1066,6 +1098,9 @@ fn pump(
if let Some(t) = audio_thread {
let _ = t.join(); // exits within its 100 ms pull timeout once `stop` is set
}
if let Some(t) = pad_audio_thread {
let _ = t.join(); // exits within its 10 ms pull timeout once `stop` is set
}
if let Some(t) = clipboard_thread {
let _ = t.join(); // exits within its next_clip wait once `stop` is set
}
+21
View File
@@ -1024,6 +1024,21 @@ pub struct Settings {
/// `PUNKTFUNK_AUDIO_SOURCE`).
#[serde(default)]
pub mic_device: String,
/// Render the host's per-pad DualSense voice-coil haptics stream (the 0xD1 plane, kind 0)
/// on a WIRED physical DualSense's own audio device (tier A — Bluetooth pads expose no
/// audio device). Gates the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad arrival
/// capability bit; wire rumble is suppressed for a pad whose haptics stream is live (the
/// stream carries the feedback — see `gamepad.rs`, the SDL disable-bit trap). Default ON:
/// the capable-and-agreed negotiation means it changes nothing without a capable host AND
/// a wired DS5. `default` so pre-existing stores load with it on.
#[serde(default = "default_true")]
pub pad_haptics: bool,
/// Where the DualSense built-in-speaker stream (0xD1 kind 1) is rendered: `"pad"` (default
/// — the physical pad's own speaker), `"mix"` (fold it into the main stream audio — a
/// declared TODO that renders as `"off"` today; see `pad_audio::speaker_active`), or
/// `"off"`. `default` so pre-existing stores load as `"pad"`.
#[serde(default = "default_pad_speaker")]
pub pad_speaker: String,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
@@ -1071,6 +1086,10 @@ fn default_true() -> bool {
true
}
fn default_pad_speaker() -> String {
"pad".into()
}
impl Settings {
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
@@ -1179,6 +1198,8 @@ impl Default for Settings {
invert_scroll: false,
speaker_device: String::new(),
mic_device: String::new(),
pad_haptics: true,
pad_speaker: "pad".into(),
match_window: false,
last_window_w: 0,
last_window_h: 0,
+52 -2
View File
@@ -24,14 +24,20 @@ const RENEW_EVERY: Duration = Duration::from_millis(1000);
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger` / `AudioCtl`)
/// is deduped by value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must
/// fire).
#[derive(Clone, Default)]
pub struct HidoutDedup {
led: Option<(u8, u8, u8)>,
player_leds: Option<u8>,
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
trigger: [Option<Vec<u8>>; 2],
/// Last-forwarded audio-control state (`flags` + the raw volume/routing bytes).
audio_ctl: Option<(u8, [u8; 6])>,
/// Once-per-pad-lifetime field-diagnosis flag: set after the first forwarded `AudioCtl`
/// carrying the haptics-select bit was logged (cleared with the rest on (re)plug).
haptics_select_logged: bool,
/// When anything was last put on the wire for this pad. `None` = nothing latched yet, so
/// there is nothing to renew. See [`RENEW_EVERY`].
last_sent: Option<Instant>,
@@ -123,6 +129,25 @@ impl HidoutDedup {
}
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
HidOutput::TrackpadHaptic { .. } => true,
HidOutput::AudioCtl { pad, flags, raw } => {
let v = Some((*flags, *raw));
if self.audio_ctl == v {
false
} else {
// Field-diagnosis signal, once per pad lifetime: a title driving the DS5's
// audio haptics (not plain rumble emulation, whose all-zero audio region
// never reaches here) — the trace that tells "the game does audio haptics"
// apart from "the client just doesn't render them".
if flags & 0x01 != 0 && !self.haptics_select_logged {
self.haptics_select_logged = true;
tracing::info!(
"DS5 title asserted haptics-select (audio haptics) pad={pad}"
);
}
self.audio_ctl = v;
true
}
}
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
@@ -302,4 +327,29 @@ mod tests {
// The pulse stamped the clock but latched no state, so the renewal has nothing to repeat.
assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty());
}
/// `AudioCtl` dedups by value like the other state kinds: an identical repeat (every output
/// report re-sends the unchanged audio region) is dropped, a flags-only or raw-only change
/// forwards again, and `clear` re-arms — including the once-per-pad haptics-select log flag.
#[test]
fn audio_ctl_dedups_by_value() {
let mut d = HidoutDedup::default();
let t = Instant::now();
let audio = |flags, vol| HidOutput::AudioCtl {
pad: 0,
flags,
raw: [vol, 0, 0, 0, 0, 0],
};
// Identical twice → exactly one emission.
assert!(d.should_forward(&audio(0x17, 0x50), t));
assert!(!d.should_forward(&audio(0x17, 0x50), t));
// Either half changing (flags, or the raw region) forwards again.
assert!(d.should_forward(&audio(0x16, 0x50), t));
assert!(d.should_forward(&audio(0x16, 0x60), t));
// The other kinds' state is untouched by audio traffic.
assert!(d.should_forward(&HidOutput::PlayerLeds { pad: 0, bits: 1 }, t));
// `clear` (pad re-plug) re-arms the value dedup.
d.clear();
assert!(d.should_forward(&audio(0x16, 0x60), t));
}
}
+5 -21
View File
@@ -17,6 +17,11 @@ use super::dualsense_proto::{
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::RichInput;
@@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
@@ -18,6 +18,11 @@ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
DS4_TOUCH_W, DS4_VENDOR,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
@@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
@@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[
0xB1, 0x02, 0xC0,
];
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
pub struct DualShock4Pad {
+7 -7
View File
@@ -300,7 +300,6 @@ impl Effect {
/// the policy is pure and unit-testable without a live uinput fd.
struct FfState {
effects: HashMap<i16, Effect>,
next_effect_id: i16,
gain: u32,
/// Last `(low, high)` reported, to dedup.
last_mix: (u16, u16),
@@ -316,7 +315,6 @@ impl FfState {
fn new() -> FfState {
FfState {
effects: HashMap::new(),
next_effect_id: 0,
gain: 0xFFFF,
last_mix: (0, 0),
last_activity: Instant::now(),
@@ -575,11 +573,13 @@ impl VirtualPad {
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
up.request_id = ev.value as u32;
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
let mut e = up.effect;
if e.id == -1 {
e.id = self.ff.next_effect_id;
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
}
let e = up.effect;
// No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and
// writes it into the effect BEFORE handing the request to uinput, so what
// arrives here is always an assigned id. The fallback that used to allocate
// one from a local counter could therefore never run, and a local counter is
// the wrong answer anyway — the kernel owns that id space.
debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id");
if e.type_ == FF_RUMBLE {
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
@@ -23,6 +23,11 @@ use super::steam_proto::{
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::uhid_abi::{
put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2,
UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2,
UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::RichInput;
@@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
// /dev/uhid event ABI — same layout as the DualSense backend.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
/// ~450 ms continuous; give margin).
const MODE_ENTER: Duration = Duration::from_millis(650);
@@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650);
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
}
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
/// per-pad `b9.6` pulse + guard handle it instead.
@@ -214,10 +200,13 @@ impl SteamDeckPad {
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
}
UHID_SET_REPORT => {
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
let end = (12 + 16).min(UHID_EVENT_SIZE);
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
let id = request_id(&ev);
// SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel
// declared — this used to read a fixed 16-byte window, which truncated any
// longer report and, for a shorter one, fed the parser whatever the reused
// event buffer still held past the payload. Every sibling backend that parses
// SET_REPORT already read the size field; this one didn't.
if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble {
rumble = Some(r);
}
let _ = self.reply_set_report(id);
@@ -23,6 +23,11 @@ use super::triton_proto::{
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
TRITON_WIRED_PRODUCT,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT,
UHID_SET_REPORT_REPLY,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
@@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const UHID_SET_REPORT: u32 = 13;
const UHID_SET_REPORT_REPLY: u32 = 14;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372;
const BUS_USB: u16 = 0x03;
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
}
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
pub struct TritonPad {
fd: File,
@@ -22,6 +22,10 @@ use super::switch_proto::{
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
};
use crate::uhid_abi::{
put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE,
UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH,
};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
use punktfunk_core::quic::{HidOutput, RichInput};
@@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
const UHID_PATH: &str = "/dev/uhid";
const UHID_DESTROY: u32 = 1;
const UHID_OUTPUT: u32 = 6;
const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12;
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03;
/// Copy a NUL-padded C string field into the event buffer.
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
/// tears down the bound `hid-nintendo` interface).
pub struct SwitchProPad {
@@ -0,0 +1,143 @@
//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place.
//!
//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam
//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these
//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from
//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend
//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see
//! [`set_report_data`]).
//!
//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is
//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 =
//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own
//! read/write loops; this module owns the numbers and the two field accessors that are easy to get
//! subtly wrong.
/// The character device every backend opens.
pub const UHID_PATH: &str = "/dev/uhid";
// Event types (`enum uhid_event_type`). Only the ones the backends actually use.
pub const UHID_DESTROY: u32 = 1;
pub const UHID_OUTPUT: u32 = 6;
pub const UHID_GET_REPORT: u32 = 9;
pub const UHID_GET_REPORT_REPLY: u32 = 10;
pub const UHID_CREATE2: u32 = 11;
pub const UHID_INPUT2: u32 = 12;
pub const UHID_SET_REPORT: u32 = 13;
pub const UHID_SET_REPORT_REPLY: u32 = 14;
/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event.
pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
/// `size_of::<uhid_event>()`: the `u32` type tag plus the create2 union.
pub const UHID_EVENT_SIZE: usize = 4 + 4372;
/// `BUS_USB` from `linux/input.h`.
pub const BUS_USB: u16 = 0x03;
/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs.
const OFF_ID: usize = 4;
/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`).
const OFF_SET_REPORT_SIZE: usize = 10;
/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs.
const OFF_DATA: usize = 12;
/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`).
const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE;
/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so
/// truncation still leaves a NUL terminator.
pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
let n = s.len().min(cap - 1);
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo.
pub fn request_id(ev: &[u8]) -> u32 {
u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]])
}
/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there.
///
/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in
/// both directions — a longer report is silently truncated, and a shorter one is parsed together
/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble
/// report means acting on numbers the game never wrote.
pub fn set_report_data(ev: &[u8]) -> &[u8] {
let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize;
let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
&ev[OFF_DATA.min(end)..end]
}
/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`).
pub fn output_data(ev: &[u8]) -> &[u8] {
let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize;
let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len());
&ev[4.min(end)..end]
}
#[cfg(test)]
mod tests {
use super::*;
fn blank() -> Vec<u8> {
vec![0u8; UHID_EVENT_SIZE]
}
#[test]
fn set_report_data_honours_the_events_own_size() {
let mut ev = blank();
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes());
for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() {
ev[OFF_DATA + i] = *b;
}
// Stale bytes past the payload — a fixed-window read would hand these to the parser.
ev[OFF_DATA + 5] = 0xAA;
ev[OFF_DATA + 15] = 0xBB;
assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]);
}
#[test]
fn set_report_data_is_not_truncated_at_sixteen() {
let mut ev = blank();
let n = 40usize;
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes());
for i in 0..n {
ev[OFF_DATA + i] = i as u8;
}
let d = set_report_data(&ev);
assert_eq!(
d.len(),
n,
"a report longer than 16 bytes must survive whole"
);
assert_eq!(d[39], 39);
}
#[test]
fn oversized_and_empty_sizes_stay_in_bounds() {
let mut ev = blank();
ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes());
assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE);
assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE);
let ev0 = blank(); // size = 0
assert!(set_report_data(&ev0).is_empty());
assert!(output_data(&ev0).is_empty());
}
#[test]
fn output_data_reads_its_trailing_size_field() {
let mut ev = blank();
ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes());
ev[4] = 0x02;
ev[5] = 0x11;
ev[6] = 0x22;
ev[7] = 0x33; // past the declared size
assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]);
}
#[test]
fn request_id_round_trips() {
let mut ev = blank();
ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes());
assert_eq!(request_id(&ev), 0xDEAD_BEEF);
}
}
@@ -479,7 +479,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
/// `(low, high)` motor levels, if a report carried them.
///
/// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are
/// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The
/// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct:
/// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not
/// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is
/// the type that sees both.
pub rumble: Option<(u16, u16)>,
/// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and
/// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence +
@@ -487,67 +494,119 @@ pub struct DsFeedback {
pub resync: bool,
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e.
/// including the leading report id at `[0]`. This is the one place in Rust the layout is written
/// down; index off these rather than repeating the numbers.
///
/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer
/// lays out one common block; what changes is how much header precedes it:
///
/// | base | where | first payload byte |
/// |---|---|---|
/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` |
/// | `1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` |
/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` |
///
/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin
/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and
/// Bluetooth bases) — carry a pointer back here; keep them in step by hand.
pub mod out_report {
/// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2.
pub const VALID_FLAG0: usize = 1;
/// `valid_flag1`: BIT2 lightbar, BIT4 player indicators.
pub const VALID_FLAG1: usize = 2;
/// High-frequency (small / right) motor.
pub const MOTOR_RIGHT: usize = 3;
/// Low-frequency (big / left) motor.
pub const MOTOR_LEFT: usize = 4;
/// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report.
pub const RIGHT_TRIGGER: usize = 11;
/// First byte of the LEFT trigger's parameter block.
pub const LEFT_TRIGGER: usize = 22;
/// One adaptive-trigger parameter block: a mode byte plus 10 parameters.
pub const TRIGGER_LEN: usize = 11;
/// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal).
pub const VALID_FLAG2: usize = 39;
/// Lit player-indicator bits (low 5).
pub const PLAYER_LEDS: usize = 44;
/// Lightbar red; green and blue follow.
pub const LED_RGB: usize = 45;
}
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off
/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are
/// surfaced — adaptive-trigger blocks and the audio-control region are forwarded raw for the client.
///
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
/// broadcast.
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
use out_report as o;
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA).
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version
// above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater
// quiet), so the kernel and SDL write the v2 flag — while older writers, and any
// that never read the version, stay on flag0. Both conventions must land here: a
// rumble dropped on either — including stops — is silently ignored, and a missed
// stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state
// forever).
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators
// Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second.
// Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same
// (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane.
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
// instead of flag0 BIT0. Our feature report advertises a version above 2.24
// (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the
// kernel and SDL write the v2 flag — while older writers, and any that never read the
// version, stay on flag0. Both conventions must land here: a rumble dropped on either
// — including stops — is silently ignored, and a missed stop buzzes for the rest of
// the session (the 500 ms refresh re-sends stale state forever).
if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 {
let high = (data[o::MOTOR_RIGHT] as u16) << 8;
let low = (data[o::MOTOR_LEFT] as u16) << 8;
fb.rumble = Some((low, high));
}
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
if flag1 & 0x04 != 0 {
let (r, g, b) = (data[45], data[46], data[47]);
let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]);
fb.hidout.push(HidOutput::Led { pad, r, g, b });
}
if flag1 & 0x10 != 0 {
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
bits: data[o::PLAYER_LEDS] & 0x1F,
});
}
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= 33 {
// The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t /
// inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN {
if flag0 & 0x04 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[11..22].to_vec(),
effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(),
});
}
if flag0 & 0x08 != 0 {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[22..33].to_vec(),
effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(),
});
}
}
// The audio-control region (bytes 5..=10: headphone/speaker/mic volumes + routing), for the
// pad-audio path. The wire flags condense the report's audio bits: bit0 = haptics-select
// (flag0 BIT1 — set on every SDL rumble write too, which is why it alone never triggers an
// emission), bits1..4 = flag0 bits 4..7 (the audio-valid flags gating the region). Emitted
// whenever an audio-valid flag is present or the region carries data; downstream dedup
// ([`crate::hidout_dedup`]) reduces the per-report repeats to genuine changes.
let raw: [u8; 6] = data[5..11].try_into().unwrap();
if flag0 & 0xF0 != 0 || raw != [0u8; 6] {
let flags = ((flag0 >> 1) & 0x01) | ((flag0 >> 3) & 0x1E);
fb.hidout.push(HidOutput::AudioCtl {
pad: pad.into(),
flags,
raw,
});
}
}
#[cfg(test)]
@@ -873,6 +932,48 @@ mod tests {
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A 0x02 report driving the pad's audio (haptics-select + audio-valid flags + the volume/
/// routing bytes) surfaces an `AudioCtl` with the exact raw region and the condensed flags;
/// a plain rumble write (haptics-select but a silent audio region — every SDL rumble) does
/// NOT — that is what `parse_output_respects_valid_flags` pins with its `hidout.is_empty()`.
#[test]
fn parse_output_surfaces_audio_ctl() {
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[1] = 0xB2; // flag0: haptics-select (BIT1) + audio-valid bits 4/5/7
data[5] = 0x50; // headphone volume
data[6] = 0x60; // speaker volume
data[7] = 0x70; // mic volume
data[8] = 0x05; // audio routing / enable bits
let mut fb = DsFeedback::default();
parse_ds_output(3, &data, &mut fb);
// flags: bit0 = flag0 bit1, bits1..4 = flag0 bits 4..7 (0b1011 → 0b10110).
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 3,
flags: 0b1_0111,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
}]
);
// A non-zero audio region with NO audio-valid flags still surfaces (dedup collapses the
// repeats downstream) — some writers leave stale volumes gated off; the host side wants
// the honest bytes either way.
let mut data = vec![0u8; 48];
data[0] = 0x02;
data[9] = 0x01;
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
assert_eq!(
fb.hidout,
vec![HidOutput::AudioCtl {
pad: 0,
flags: 0,
raw: [0, 0, 0, 0, 0x01, 0],
}]
);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
+7 -1
View File
@@ -18,7 +18,12 @@ use std::time::{Duration, Instant};
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
#[derive(Default)]
pub struct PadFeedback {
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
/// `(low, high)` motor levels, if the pass saw a rumble report.
///
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
pub rumble: Option<(u16, u16)>,
pub hidout: Vec<HidOutput>,
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
@@ -513,6 +518,7 @@ mod tests {
index: 2,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
assert!(m.slots.get(2).is_some());
}
+5
View File
@@ -457,6 +457,11 @@ pub mod triton_proto;
#[cfg(target_os = "linux")]
#[path = "inject/linux/triton_usbip.rs"]
pub mod triton_usbip;
/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each
/// used to transcribe for itself, plus the field accessors that read a payload's real length.
#[cfg(target_os = "linux")]
#[path = "inject/linux/uhid_abi.rs"]
pub mod uhid_abi;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
+161
View File
@@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and
# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the
# note above says, a clashing #define silently takes the last definition rather than failing to
# compile. The table above had been doing this by hand for the handful someone noticed; this is
# the rest of them, so the stated rule finally holds for the whole surface.
#
# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`,
# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name,
# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are
# namespaced, just not by us.
"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION"
"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE"
"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1"
"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1"
"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2"
"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3"
"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4"
"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420"
"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444"
"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM"
"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305"
"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED"
"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR"
"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK"
"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE"
"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK"
"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP"
"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED"
"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK"
"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE"
"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE"
"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE"
"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES"
"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS"
"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME"
"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES"
"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT"
"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE"
"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES"
"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK"
"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED"
"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER"
"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH"
"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS"
"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1"
"CODEC_H264" = "PUNKTFUNK_CODEC_H264"
"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC"
"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE"
"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020"
"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709"
"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL"
"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709"
"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709"
"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG"
"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ"
"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT"
"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE"
"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC"
"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE"
"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF"
"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC"
"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE"
"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF"
"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN"
"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC"
"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX"
"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX"
"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE"
"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT"
"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX"
"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC"
"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED"
"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD"
"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR"
"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE"
"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN"
"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT"
"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC"
"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG"
"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC"
"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN"
"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS"
"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES"
"MAX_PADS" = "PUNKTFUNK_MAX_PADS"
"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE"
"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC"
"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE"
"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD"
"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS"
"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED"
"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL"
"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH"
"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR"
"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER"
"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE"
"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO"
"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE"
"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER"
"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE"
"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT"
"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE"
"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF"
"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST"
"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT"
"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT"
"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST"
"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT"
"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE"
"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED"
"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME"
"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST"
"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE"
"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK"
"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED"
"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK"
"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE"
"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE"
"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE"
"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE"
"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE"
"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE"
"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE"
"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN"
"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1"
"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2"
"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX"
"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN"
"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE"
"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED"
"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN"
"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN"
"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS"
"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING"
"PRESETS" = "PUNKTFUNK_PRESETS"
"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE"
"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT"
"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE"
"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK"
"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE"
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"
"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED"
"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR"
"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT"
"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM"
"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT"
"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444"
"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20"
"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR"
"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING"
"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE"
"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ"
"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU"
"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION"
"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE"
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
[enum]
+210 -4
View File
@@ -670,6 +670,12 @@ pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
/// `PunktfunkHidOutput::kind` — the audio-control region of a DS5 output report (pad-audio
/// routing/volumes; the audio SAMPLES arrive via [`punktfunk_connection_next_pad_audio`]).
/// `which` = the condensed audio flags (bit0 = haptics-select, bits1..4 = the report's
/// audio-valid flags); `effect[0..6]` = bytes 5..=10 of the report verbatim
/// (headphone/speaker/mic volumes + routing) with `effect_len = 6`. Forwarded change-only.
pub const PUNKTFUNK_HIDOUT_AUDIO_CTL: u8 = 5;
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
@@ -698,7 +704,10 @@ pub struct PunktfunkHidOutput {
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
pub effect_len: u8,
/// Trigger: the raw DualSense trigger parameter block (mode + params).
pub effect: [u8; 11],
/// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is
/// exported precisely so embedders can size their own buffers against it, and it declaring one
/// number while the struct it describes hardcoded another was the whole hazard.
pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize],
}
#[cfg(feature = "quic")]
@@ -759,6 +768,17 @@ impl PunktfunkHidOutput {
out.effect_len = 6;
}
HidOutput::HidRaw { .. } => return None,
HidOutput::AudioCtl { pad, flags, raw } => {
// Same packing idiom as TrackpadHaptic: `which` carries the flags byte,
// `effect[0..6]` the raw audio region. The u16 wire pad narrows losslessly
// because `HidOutput::decode` refuses one at or above `input::MAX_PADS` (B27) —
// it is enforced there, not merely assumed here.
out.kind = PUNKTFUNK_HIDOUT_AUDIO_CTL;
out.pad = *pad as u8;
out.which = *flags;
out.effect[0..6].copy_from_slice(raw);
out.effect_len = 6;
}
}
Some(out)
}
@@ -1172,6 +1192,25 @@ pub const PUNKTFUNK_HOST_CAP_CLIPBOARD: u8 = 0x02;
/// the client keeps its pen-as-touch fallback. (Mirrors `quic::HOST_CAP_PEN`;
/// design/pen-tablet-input.md.)
pub const PUNKTFUNK_HOST_CAP_PEN: u8 = 0x10;
/// Host-capability bit in [`punktfunk_connection_host_caps`]: the host can capture per-gamepad
/// audio (DualSense voice-coil haptics + speaker) and emit it on the 0xD1 plane toward pads
/// declared capable via [`punktfunk_connection_set_pad_audio_caps`]. Set only when the client
/// asked via [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`]. (Mirrors `quic::HOST_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// Pad-audio `kind` ([`punktfunk_connection_next_pad_audio`]): the BACK channel pair — DualSense
/// voice-coil haptics, 5 ms Opus frames. (Mirrors `quic::PAD_AUDIO_KIND_HAPTICS`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// Pad-audio `kind`: the FRONT channel pair — the controller's built-in speaker, 10 ms Opus
/// frames. (Mirrors `quic::PAD_AUDIO_KIND_SPEAKER`.)
pub const PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the HAPTICS
/// stream (a real DualSense's voice coils).
pub const PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS: u8 = 0x01;
/// [`punktfunk_connection_set_pad_audio_caps`] `audio_caps` bit: the pad renders the SPEAKER
/// stream.
pub const PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER: u8 = 0x02;
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
#[cfg(feature = "quic")]
@@ -1186,6 +1225,20 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_GAMEPAD_STATE == crate::quic::HOST_CAP_GAMEPAD_STATE);
assert!(PUNKTFUNK_HOST_CAP_CLIPBOARD == crate::quic::HOST_CAP_CLIPBOARD);
assert!(PUNKTFUNK_HOST_CAP_PEN == crate::quic::HOST_CAP_PEN);
assert!(PUNKTFUNK_HOST_CAP_PAD_AUDIO == crate::quic::HOST_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
// `input::encode_gamepad_arrival` applies).
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS
);
assert!(
(PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER as u32) << 8
== crate::input::ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
assert!(PUNKTFUNK_PEN_IN_RANGE == crate::quic::PEN_IN_RANGE);
assert!(PUNKTFUNK_PEN_TOUCHING == crate::quic::PEN_TOUCHING);
assert!(PUNKTFUNK_PEN_BARREL1 == crate::quic::PEN_BARREL1);
@@ -1768,6 +1821,13 @@ pub const PUNKTFUNK_CLIENT_CAP_CURSOR: u8 = 0x01;
/// forward-compatible.
pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// [`punktfunk_connect_ex9`] `client_caps` bit: the client understands the pad-audio plane
/// (0xD1 — per-gamepad DualSense voice-coil haptics + speaker). The embedder MUST then drain
/// [`punktfunk_connection_next_pad_audio`] and declare each capable pad via
/// [`punktfunk_connection_set_pad_audio_caps`]; the host emits pad audio only when it answers
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
@@ -2312,6 +2372,117 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
})
}
/// Pull the next pad-audio frame (0xD1) — one Opus frame of DualSense voice-coil haptics
/// (`kind` = [`PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `*out_pad` — waiting up to
/// `timeout_ms`. The payload is COPIED into `buf` (no borrow-until-next-call slot); the return
/// value is its length in bytes, `0` = nothing this poll (timeout — or a DTX/oversized frame,
/// both of which an embedder treats the same way), `-1` = the session ended (or an invalid
/// handle/buffer). All pads/kinds share one queue — fan out by `*out_pad`/`*out_kind` to
/// per-actuator Opus decoders. A frame larger than `buf_len` is dropped like the timeout case
/// (the plane is lossy by design; any real Opus frame fits a 1500-byte buffer). Only a session
/// connected with [`PUNKTFUNK_CLIENT_CAP_PAD_AUDIO`] against a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — with the pad declared via
/// [`punktfunk_connection_set_pad_audio_caps`] — ever receives any. Drain from a dedicated
/// thread (one puller, may run alongside the other planes' pullers).
///
/// # Safety
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
out_kind: *mut u8,
out_seq: *mut u32,
out_pts_ns: *mut u64,
buf: *mut u8,
buf_len: usize,
timeout_ms: u32,
) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return -1,
};
if buf.is_null() && buf_len != 0 {
return -1;
}
match c
.inner
.next_pad_audio(std::time::Duration::from_millis(timeout_ms as u64))
{
Some(f) => {
if f.opus.is_empty() || f.opus.len() > buf_len {
// DTX silence (skipped like the audio-PCM path — decoding an empty payload
// as loss would synthesize concealment) or doesn't fit — report "nothing
// this poll" (the next_hidout HidRaw-skip precedent; truncated Opus would
// be undecodable anyway).
return 0;
}
// SAFETY: per the ABI contract - each out-param below is OPTIONAL, so it is null-
// checked before it is written; `buf` is a caller-owned writable region of
// `buf_len` bytes and the copy length was just bounds-checked against it.
unsafe {
if !out_pad.is_null() {
*out_pad = f.pad;
}
if !out_kind.is_null() {
*out_kind = f.kind;
}
if !out_seq.is_null() {
*out_seq = f.seq;
}
if !out_pts_ns.is_null() {
*out_pts_ns = f.pts_ns;
}
std::ptr::copy_nonoverlapping(f.opus.as_ptr(), buf, f.opus.len());
}
f.opus.len() as i32
}
// `None` folds timeout and closed; the shutdown flag tells them apart so the
// embedder's plane loop can exit instead of polling a dead session forever.
None if c.inner.is_session_ended() => -1,
None => 0,
}
}));
r.unwrap_or(-1)
}
/// Declare wire pad `pad`'s pad-audio render capabilities (`audio_caps`: OR of
/// [`PUNKTFUNK_PAD_AUDIO_CAP_HAPTICS`] / [`PUNKTFUNK_PAD_AUDIO_CAP_SPEAKER`]) — how a client
/// tells the host WHICH pads can actually play the 0xD1 streams. Call at controller attach,
/// BEFORE the pad's arrival event is sent (the [`punktfunk_connection_set_rumble_quirks`]
/// timing): the core folds the bits into the arrival's flags (bits 8/9), and only toward a
/// [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`] host — never calling this leaves the wire bytes exactly as
/// before. Latest-wins per pad; unknown bits are masked off.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
audio_caps: u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
c.inner.set_pad_audio_caps(pad, audio_caps);
PunktfunkStatus::Ok
})
}
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
@@ -2497,10 +2668,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
/// shared rumble policy engine instead of forking it (typically called at controller attach).
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user);
/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller
/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`:
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
/// actuator.
/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that
/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own.
///
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
@@ -4412,3 +4585,36 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
PunktfunkStatus::Ok
})
}
#[cfg(all(test, feature = "quic"))]
mod tests {
use super::*;
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19).
#[test]
fn hidout_abi_maps_audio_ctl() {
let out = PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::AudioCtl {
pad: 3,
flags: 0x17,
raw: [0x50, 0x60, 0x70, 0x05, 0, 0],
})
.unwrap();
assert_eq!(out.kind, PUNKTFUNK_HIDOUT_AUDIO_CTL);
assert_eq!(out.pad, 3);
assert_eq!(out.which, 0x17);
assert_eq!(out.effect_len, 6);
assert_eq!(out.effect[..6], [0x50, 0x60, 0x70, 0x05, 0, 0]);
assert_eq!(out.effect[6..], [0; 5]);
// A raw passthrough report still has no C representation (skipped at the pull site).
assert!(
PunktfunkHidOutput::from_hid(&crate::quic::HidOutput::HidRaw {
pad: 0,
kind: 0,
data: vec![0x80],
})
.is_none()
);
}
}
+50 -4
View File
@@ -16,11 +16,13 @@ use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent;
use crate::quic::{
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, ProbeRequest,
RfiRequest, RichInput,
endpoint, ClipControl, ClipKind, ClipOffer, ColorInfo, HdrMeta, HidOutput, PadAudioFrame,
ProbeRequest, RfiRequest, RichInput,
};
use crate::session::Frame;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{
AtomicBool, AtomicI64, AtomicU16, AtomicU32, AtomicU64, AtomicU8, Ordering,
};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -43,7 +45,7 @@ use self::control::{CtrlRequest, Negotiated};
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
use self::planes::{
RumbleUpdate, AUDIO_QUEUE, CLIP_EVENT_QUEUE, CURSOR_SHAPE_QUEUE, CURSOR_STATE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, RUMBLE_QUEUE,
HDR_META_QUEUE, HIDOUT_QUEUE, HOST_TIMING_QUEUE, PAD_AUDIO_QUEUE, RUMBLE_QUEUE,
};
use self::probe::ProbeState;
use self::pump::run_pump;
@@ -122,6 +124,14 @@ pub struct NativeClient {
rumble_sched: Arc<rumble::RumbleShared>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>,
/// Inbound pad audio (DualSense voice-coil haptics + speaker Opus frames) — 0xD1 datagrams.
/// Only a session that advertised [`quic::CLIENT_CAP_PAD_AUDIO`] against a
/// [`quic::HOST_CAP_PAD_AUDIO`] host ever receives any.
pad_audio: Mutex<Receiver<PadAudioFrame>>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing gamepad-arrival flags
/// (bits 8/9) by the worker's input task — toward a `HOST_CAP_PAD_AUDIO` host only.
pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
hdr_meta: Mutex<Receiver<HdrMeta>>,
/// Inbound per-AU host capture→send timings — 0xCF datagrams (the client always advertises
@@ -418,6 +428,10 @@ impl NativeClient {
let rumble_sched = Arc::new(rumble::RumbleShared::new());
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (pad_audio_tx, pad_audio_rx) =
std::sync::mpsc::sync_channel::<PadAudioFrame>(PAD_AUDIO_QUEUE);
let pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]> =
Arc::new(std::array::from_fn(|_| AtomicU8::new(0)));
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
@@ -459,6 +473,7 @@ impl NativeClient {
let clock_offset_w = clock_offset.clone();
let decode_lat_w = decode_lat.clone();
let live_bitrate_w = live_bitrate.clone();
let pad_audio_caps_w = pad_audio_caps.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new()
.name("punktfunk-client".into())
@@ -508,6 +523,8 @@ impl NativeClient {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps: pad_audio_caps_w,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -556,6 +573,8 @@ impl NativeClient {
rumble: Mutex::new(rumble_rx),
rumble_sched,
hidout: Mutex::new(hidout_rx),
pad_audio: Mutex::new(pad_audio_rx),
pad_audio_caps,
hdr_meta: Mutex::new(hdr_meta_rx),
host_timing: Mutex::new(host_timing_rx),
cursor_shape: Mutex::new(cursor_shape_rx),
@@ -1061,6 +1080,33 @@ impl NativeClient {
}
}
/// Pull the next pad-audio frame (0xD1): one Opus frame of DualSense voice-coil haptics
/// ([`quic::PAD_AUDIO_KIND_HAPTICS`], 5 ms) or built-in-speaker audio
/// ([`quic::PAD_AUDIO_KIND_SPEAKER`], 10 ms) for gamepad `pad`. All pads/kinds share the
/// queue — the embedder fans out by `pad`/`kind` to per-actuator Opus decoders. `None` on
/// timeout AND once the session ended ([`is_session_ended`](Self::is_session_ended)
/// distinguishes, and the plane is best-effort either way). Only a session that advertised
/// [`quic::CLIENT_CAP_PAD_AUDIO`] against a [`quic::HOST_CAP_PAD_AUDIO`] host — with the
/// pad's render caps declared via [`set_pad_audio_caps`](Self::set_pad_audio_caps) — ever
/// receives any. Drain on a dedicated thread like [`next_audio`](Self::next_audio); one
/// puller per the plane contract.
pub fn next_pad_audio(&self, timeout: Duration) -> Option<PadAudioFrame> {
self.pad_audio.lock().unwrap().recv_timeout(timeout).ok()
}
/// Declare wire pad `pad`'s pad-audio render capabilities: `audio_caps` bit0 = the pad can
/// play the HAPTICS stream (a real DualSense's voice coils), bit1 = the SPEAKER stream.
/// Call at controller attach, BEFORE the pad's arrival is sent (like
/// [`set_rumble_quirks`](Self::set_rumble_quirks)) — the worker ORs the bits into the
/// arrival's flags (bits 8/9), and only toward a [`quic::HOST_CAP_PAD_AUDIO`] host, so an
/// embedder that never calls this (or a host that can't capture pad audio) leaves the wire
/// bytes exactly as before. Latest-wins per pad; unknown bits are masked off.
pub fn set_pad_audio_caps(&self, pad: u8, audio_caps: u8) {
if let Some(slot) = self.pad_audio_caps.get(pad as usize) {
slot.store(audio_caps & 0x03, Ordering::Relaxed);
}
}
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
/// the host sent for an HDR session; same timeout/closed semantics as
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
@@ -20,6 +20,12 @@ pub(crate) type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
pub(crate) const HIDOUT_QUEUE: usize = 32;
/// Pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker) buffered for the embedder,
/// ALL pads and kinds on one queue (the embedder fans out by `pad`/`kind`): 64 × 5 ms = 320 ms of
/// slack on a haptics-only stream, the [`AUDIO_QUEUE`] discipline. A lagging embedder drops the
/// newest frame (the renderer conceals the gap).
pub(crate) const PAD_AUDIO_QUEUE: usize = 64;
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
pub(crate) const HDR_META_QUEUE: usize = 8;
+13 -2
View File
@@ -50,6 +50,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
pad_audio_caps,
hdr_meta_tx,
host_timing_tx,
cursor_shape_tx,
@@ -92,9 +94,17 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Input task: embedder events → uplink datagrams, with per-transition gamepad events
// folded into idempotent seq-stamped snapshots toward a HOST_CAP_GAMEPAD_STATE host
// (see [`input_task`]).
// (see [`input_task`]). Pad-audio render caps ride arrival flags bits 8/9 ONLY toward a
// HOST_CAP_PAD_AUDIO host — an older host reads the whole flags word as the pad index.
let gamepad_snapshots = host_caps & crate::quic::HOST_CAP_GAMEPAD_STATE != 0;
tokio::spawn(input_task::run(conn.clone(), input_rx, gamepad_snapshots));
let pad_audio_arrivals = host_caps & crate::quic::HOST_CAP_PAD_AUDIO != 0;
tokio::spawn(input_task::run(
conn.clone(),
input_rx,
gamepad_snapshots,
pad_audio_arrivals,
pad_audio_caps,
));
// Mic task: embedder Opus mic frames → 0xCB uplink datagrams (best-effort, dropped on loss).
// Self-healing latency bound: every frame still queued once this task catches up is standing
@@ -166,6 +176,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
rumble_tx,
rumble_feed,
hidout_tx,
pad_audio_tx,
hdr_meta_tx,
host_timing_tx,
encode_lat.clone(),
@@ -12,6 +12,7 @@ pub(super) async fn run(
rumble_tx: std::sync::mpsc::SyncSender<RumbleUpdate>,
rumble_feed: super::super::rumble::RumbleFeed,
hidout_tx: std::sync::mpsc::SyncSender<crate::quic::HidOutput>,
pad_audio_tx: std::sync::mpsc::SyncSender<crate::quic::PadAudioFrame>,
hdr_meta_tx: std::sync::mpsc::SyncSender<crate::quic::HdrMeta>,
host_timing_tx: std::sync::mpsc::SyncSender<crate::quic::HostTiming>,
// The ABR encode signal's accumulator (see [`EncodeLatAcc`]) — fed HERE, not off
@@ -60,22 +61,28 @@ pub(super) async fn run(
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// A pad index the client cannot represent is dropped outright, before either
// consumer sees it. It used to be waved through: the seq gate was skipped (its
// per-pad cursor has no slot for it) and it was handed to the legacy queue,
// while the policy engine silently discarded it on its own bounds check — so
// "both consumers are fed" below was false for exactly these, and an embedder
// draining the queue could be handed an index it would use to subscript its
// own per-pad array. The host never emits one; this is malformed or hostile.
let idx = u.pad as usize;
if idx >= crate::input::MAX_PADS {
continue;
}
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
true // out-of-range pad (host never sends these): no gate
false // reordered/duplicate — drop, keep the newer state
}
}
None => true,
@@ -94,6 +101,11 @@ pub(super) async fn run(
let _ = hidout_tx.try_send(h);
}
}
Some(&crate::quic::PAD_AUDIO_MAGIC) => {
if let Some(f) = crate::quic::decode_pad_audio_datagram(&d) {
let _ = pad_audio_tx.try_send(f);
}
}
Some(&crate::quic::HDR_META_MAGIC) => {
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
let _ = hdr_meta_tx.try_send(m);
@@ -15,8 +15,16 @@ pub(super) async fn run(
conn: quinn::Connection,
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
gamepad_snapshots: bool,
// Whether the host advertised HOST_CAP_PAD_AUDIO: only then do arrivals carry the per-pad
// audio-render bits (flags 8/9) — an older host reads the whole flags word as the pad index,
// so unexpected high bits would make it drop the kind declaration entirely.
pad_audio: bool,
// Per-pad audio-render capabilities (bit0 haptics, bit1 speaker), fed by the embedder via
// [`NativeClient::set_pad_audio_caps`] and by arrival events already carrying the bits.
pad_audio_caps: std::sync::Arc<[std::sync::atomic::AtomicU8; crate::input::MAX_PADS]>,
) {
use crate::input::{GamepadSnapshot, InputKind, MAX_PADS};
use std::sync::atomic::Ordering;
// Touched pads only: an entry appears on the first gamepad event for that index, so the
// refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS];
@@ -37,6 +45,28 @@ pub(super) async fn run(
const ARRIVAL_RESENDS: u8 = 2;
let mut arrival: [Option<u8>; MAX_PADS] = [None; MAX_PADS];
let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS];
// An arrival's outgoing flags word: the pad index, plus the pad's audio-render bits (8/9)
// toward a HOST_CAP_PAD_AUDIO host. With no declared caps (or an older host) this is
// byte-identical to the plain index — the pre-pad-audio wire.
// B7: the caps a pad's LAST arrival actually carried. `set_pad_audio_caps` only stores into
// the registry — it cannot reach this task — so a declaration that lands after the arrival
// burst has drained (the renderer commits the trade only once its sink opens, which is well
// past the two 100 ms ticks) used to never reach the host at all: the client believed it had
// pad audio and the host emitted nothing on 0xD1, silently, forever. Comparing this against
// the live registry on every tick re-arms the burst by itself, with no new plumbing and no
// extra traffic when nothing changed.
let mut arrival_caps_sent: [u8; MAX_PADS] = [0; MAX_PADS];
let caps_now = |idx: usize| -> u8 {
if pad_audio {
pad_audio_caps[idx].load(Ordering::Relaxed)
} else {
0
}
};
let arrival_flags = |idx: usize| -> u32 {
let caps = caps_now(idx);
crate::input::encode_gamepad_arrival(idx as u8, caps)
};
let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
@@ -81,30 +111,56 @@ pub(super) async fn run(
let _ = conn.send_datagram(rem.encode().to_vec().into());
continue;
}
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS {
// Remember the declared kind (`code`) and forward it, arming a re-send burst
// so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
let _ = conn.send_datagram(ev.encode().to_vec().into());
continue;
if gamepad_snapshots && ev.kind == InputKind::GamepadArrival {
// The index is the LOW BYTE only — bits 8/9 may carry the pad's audio-render
// caps (an embedder building raw events; the `set_pad_audio_caps` registry is
// the usual source). Fold event-carried bits into the registry so the re-send
// burst keeps them, then send with the negotiation-gated flags word.
let (pad, ev_caps) = crate::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
if idx < MAX_PADS {
if ev_caps != 0 {
pad_audio_caps[idx].fetch_or(ev_caps, Ordering::Relaxed);
}
// Remember the declared kind (`code`) and forward it, arming a re-send
// burst so the host learns it before the pad's first frame even under loss.
arrival[idx] = Some(ev.code as u8);
arrival_owed[idx] = ARRIVAL_RESENDS;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
flags: arrival_flags(idx),
..ev
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
continue;
}
}
let _ = conn.send_datagram(ev.encode().to_vec().into());
}
_ = refresh.tick() => {
for idx in 0..MAX_PADS {
// B7: caps declared after the burst drained — re-announce this pad's arrival.
// Only for a pad that HAS an arrival (so it is a live, declared controller),
// and only when the value actually moved, so a steady session sends nothing.
if arrival[idx].is_some()
&& arrival_owed[idx] == 0
&& caps_now(idx) != arrival_caps_sent[idx]
{
arrival_owed[idx] = ARRIVAL_RESENDS;
}
// Re-send an owed kind declaration (independent of whether the pad has state
// yet — it may be idle-but-connected). Idempotent on the host.
if arrival_owed[idx] > 0 {
if let Some(kind) = arrival[idx] {
arrival_owed[idx] -= 1;
arrival_caps_sent[idx] = caps_now(idx);
let arr = crate::input::InputEvent {
kind: InputKind::GamepadArrival,
_pad: [0; 3],
code: kind as u32,
x: 0,
y: 0,
flags: idx as u32,
flags: arrival_flags(idx),
};
let _ = conn.send_datagram(arr.encode().to_vec().into());
} else {
+18 -3
View File
@@ -69,10 +69,25 @@ pub struct RumbleCommand {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ActuatorQuirks {
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
/// `0` = no keepalive (the common case).
/// output decays between wire renewals. `0` = no keepalive (the common case).
///
/// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired
/// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this
/// quirk, though it reads like the obvious second example: the Apple client keeps its own
/// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because
/// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a
/// command carrying the same levels, and that renderer skips a HID write whose levels are
/// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat
/// (`dedup_jitter` is the Deck's answer to the same problem one layer up).
pub keepalive_ms: u16,
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
/// Floor for `backstop_ms` on non-zero commands.
///
/// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI
/// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API
/// rejects short values. The case it was written for is handled elsewhere: Android's
/// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the
/// duration itself at the call, and that path never declares quirks at all. Kept because it
/// is exported ABI, and because a floor belongs here rather than re-invented per embedder.
pub min_pulse_ms: u16,
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
+10 -2
View File
@@ -5,8 +5,8 @@ use crate::clipboard::{ClipCommand, ClipEventCore};
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::Result;
use crate::input::InputEvent;
use crate::quic::{HdrMeta, HidOutput};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64};
use crate::quic::{HdrMeta, HidOutput, PadAudioFrame};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicU8};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Mutex};
@@ -43,6 +43,14 @@ pub(crate) struct WorkerArgs {
/// closed, so the command API always observes connection teardown.
pub(crate) rumble_feed: super::rumble::RumbleFeed,
pub(crate) hidout_tx: SyncSender<HidOutput>,
/// Inbound pad-audio frames (`0xD1` — DualSense voice-coil haptics + speaker), drained by
/// [`NativeClient::next_pad_audio`].
pub(crate) pad_audio_tx: SyncSender<PadAudioFrame>,
/// Per-pad pad-audio render capabilities (bit0 haptics, bit1 speaker), written by
/// [`NativeClient::set_pad_audio_caps`] and OR'd into outgoing
/// [`GamepadArrival`](crate::input::InputKind::GamepadArrival) flags (bits 8/9) by the input
/// task — toward a `HOST_CAP_PAD_AUDIO` host only.
pub(crate) pad_audio_caps: Arc<[AtomicU8; crate::input::MAX_PADS]>,
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
pub(crate) cursor_shape_tx: SyncSender<crate::quic::CursorShape>,
+63 -1
View File
@@ -64,7 +64,11 @@ pub enum InputKind {
GamepadRemove = 13,
/// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a
/// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref)
/// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's
/// wire byte, `flags` = pad index in the low byte plus the pad's render capabilities in bits
/// 8/9 ([`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] — sent only
/// toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host, so an older host
/// keeps reading the whole word as the index; hosts decode via [`decode_gamepad_arrival`]).
/// Sent when the client opens a pad slot — before that pad's
/// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The
/// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a
/// pad the client never declares (an older client, or a fully-lost declaration) falls back to
@@ -97,6 +101,34 @@ pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) {
(flags as u8, (flags >> 24) as u8)
}
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio HAPTICS — it is (or
/// forwards to) a real DualSense whose voice-coil actuators can play the
/// [`PAD_AUDIO_KIND_HAPTICS`](crate::quic::PAD_AUDIO_KIND_HAPTICS) stream. Rides above the pad
/// index byte; sent only toward a [`HOST_CAP_PAD_AUDIO`](crate::quic::HOST_CAP_PAD_AUDIO) host
/// (an older host reads the whole `flags` word as the index, so unexpected high bits would make
/// it drop the declaration).
pub const ARRIVAL_FLAG_PAD_AUDIO_HAPTICS: u32 = 1 << 8;
/// [`InputKind::GamepadArrival`] `flags` bit: this pad renders pad-audio SPEAKER — the
/// [`PAD_AUDIO_KIND_SPEAKER`](crate::quic::PAD_AUDIO_KIND_SPEAKER) stream. Same wire discipline
/// as [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`].
pub const ARRIVAL_FLAG_PAD_AUDIO_SPEAKER: u32 = 1 << 9;
/// Pack a [`InputKind::GamepadArrival`] `flags` word: the pad index in the low byte plus
/// `audio_caps` (bit0 = haptics, bit1 = speaker) as bits 8/9. `audio_caps = 0` reproduces the
/// pre-pad-audio wire bytes exactly.
pub fn encode_gamepad_arrival(pad: u8, audio_caps: u8) -> u32 {
(pad as u32) | (((audio_caps & 0x03) as u32) << 8)
}
/// Unpack a [`InputKind::GamepadArrival`] `flags` word into `(pad, audio_caps)`. The pad index
/// is `flags & 0xFF` — hosts MUST mask rather than take the whole word, or a capability bit
/// reads as a phantom index; `audio_caps` is bits 8/9 (bit0 = haptics, bit1 = speaker — the
/// [`ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/[`ARRIVAL_FLAG_PAD_AUDIO_SPEAKER`] bits shifted down).
/// An old-format word (index only) yields `audio_caps = 0`.
pub fn decode_gamepad_arrival(flags: u32) -> (u8, u8) {
(flags as u8, ((flags >> 8) & 0x03) as u8)
}
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
///
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
@@ -348,6 +380,11 @@ pub enum GamepadEvent {
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
/// Pad-audio render capabilities from a NATIVE-plane arrival's `flags` bits 8/9
/// (bit0 = haptics, bit1 = speaker — see [`decode_gamepad_arrival`]). NOT a GameStream
/// LI_CCAP bit (that vocabulary lives in `capabilities`); the GameStream plane cannot
/// express pad audio and always sets `0`, as does an old client.
audio_caps: u8,
},
}
@@ -443,6 +480,31 @@ mod tests {
assert_eq!((pad, seq), (9, 123));
}
#[test]
fn gamepad_arrival_flags_roundtrip() {
// The capability bits ride bits 8/9; the index stays the low byte.
for (pad, caps) in [(0u8, 0u8), (3, 0b01), (15, 0b10), (7, 0b11)] {
let flags = encode_gamepad_arrival(pad, caps);
assert_eq!(decode_gamepad_arrival(flags), (pad, caps));
assert_eq!(flags & 0xFF, pad as u32);
}
assert_eq!(
encode_gamepad_arrival(2, 0b11),
2 | ARRIVAL_FLAG_PAD_AUDIO_HAPTICS | ARRIVAL_FLAG_PAD_AUDIO_SPEAKER
);
// Old-format compat both ways: a caps-less word (an old client, or a new one toward an
// old host) is byte-identical to the plain index, and decodes with caps 0.
assert_eq!(encode_gamepad_arrival(5, 0), 5);
assert_eq!(decode_gamepad_arrival(5), (5, 0));
// Undefined high bits (a future extension) never leak into the index OR the caps.
assert_eq!(
decode_gamepad_arrival(0xFFFF_0000 | (0b01 << 8) | 9),
(9, 1)
);
// encode masks unknown caps bits, so a sloppy embedder can't corrupt the index space.
assert_eq!(encode_gamepad_arrival(1, 0xFF), 1 | (0b11 << 8));
}
#[test]
fn gamepad_snapshot_roundtrip() {
let s = GamepadSnapshot {
+19 -1
View File
@@ -107,6 +107,10 @@ pub use stats::Stats;
/// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced)
/// clock offset ongoing latency math must use; the connect-time getter stays frozen by
/// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged.
/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield
/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it
/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is
/// unchanged. (Documented late — the bump shipped without its line here.)
/// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip
/// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who
/// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which
@@ -120,7 +124,21 @@ pub use stats::Stats;
/// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive;
/// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a
/// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 14;
/// v15: versions the shared rumble policy engine's C surface —
/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the
/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant
/// still read 7 and no bump was made, so every core since has exported them while advertising a
/// version that never promised them. That cannot be corrected retroactively — a shipped binary
/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is
/// present, below it an embedder must probe for the symbol. Purely a version statement; no code
/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged.
/// v16: added the pad-audio client surface — `punktfunk_connection_next_pad_audio` (the 0xD1
/// per-gamepad DualSense haptics/speaker plane) + `punktfunk_connection_set_pad_audio_caps` and
/// the `PUNKTFUNK_CLIENT_CAP_PAD_AUDIO` / `PUNKTFUNK_HOST_CAP_PAD_AUDIO` mirrors. Additive and
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 16;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+41
View File
@@ -121,6 +121,15 @@ pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
/// [`Hello::client_caps`] bit: the client understands the pad-audio plane
/// ([`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC), `0xD1`) — per-gamepad DualSense
/// voice-coil haptics + speaker Opus frames, plus the [`HidOutput::AudioCtl`]
/// (super::datagram::HidOutput) routing/volume events. Active only when the host answers with
/// [`HOST_CAP_PAD_AUDIO`] AND the pad's arrival declared a renderer for the kind
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) — the capable-and-agreed
/// precedent, per pad; toward an older or incapable host nothing changes. `0x08` — `0x01` is [`CLIENT_CAP_CURSOR`],
/// `0x02` is [`CLIENT_CAP_PHASE_LOCK`], `0x04` is [`CLIENT_CAP_AUDIO_RED`].
pub const CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -154,6 +163,16 @@ pub const HOST_CAP_PEN: u8 = 0x10;
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
/// [`Welcome::host_caps`] bit: the host can capture pad audio — its virtual DualSense exposes
/// the pad's audio endpoints (voice-coil haptics + speaker), so a game's per-pad audio can be
/// captured and shipped on the [`PAD_AUDIO_MAGIC`](super::datagram::PAD_AUDIO_MAGIC) plane.
/// Set only when the client asked via [`CLIENT_CAP_PAD_AUDIO`]; when both bits agree, a
/// capable client marks its pads' render capabilities on their arrivals
/// ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`) and the host emits `0xD1`
/// toward exactly those pads. `0x40` — `0x20` is [`HOST_CAP_AUDIO_RED`], `0x10` is
/// [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`], `0x04` is [`HOST_CAP_TEXT_INPUT`],
/// `0x01`/`0x02` are gamepad-state / clipboard.
pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -337,6 +356,28 @@ mod tests {
);
}
#[test]
fn pad_audio_cap_bits_are_distinct() {
// The new pad-audio bits pack into the existing caps bytes without colliding with any
// taken bit (a collision would silently negotiate an unrelated feature).
assert_eq!(
CLIENT_CAP_PAD_AUDIO & (CLIENT_CAP_CURSOR | CLIENT_CAP_PHASE_LOCK),
0
);
assert_eq!(
HOST_CAP_PAD_AUDIO
& (HOST_CAP_GAMEPAD_STATE
| HOST_CAP_CLIPBOARD
| HOST_CAP_TEXT_INPUT
| HOST_CAP_CURSOR
| HOST_CAP_PEN),
0
);
// Single-bit values (a multi-bit cap would OR neighbours in).
assert_eq!(CLIENT_CAP_PAD_AUDIO.count_ones(), 1);
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
}
#[test]
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
+301 -6
View File
@@ -1,12 +1,15 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xD1):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing,
//! cursor state, pad audio.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client).
/// (0xCE, host→client), host timing = [`HOST_TIMING_MAGIC`] (0xCF, host→client), cursor state =
/// [`CURSOR_STATE_MAGIC`] (0xD0, host→client), pad audio = [`PAD_AUDIO_MAGIC`] (0xD1,
/// host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
@@ -401,11 +404,22 @@ impl RichInput {
}
}
/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger
/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many
/// into its report.
///
/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant
/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so
/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had
/// been bounded on both ends all along.
pub const TRIGGER_EFFECT_MAX: usize = 11;
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
const HIDOUT_HID_RAW: u8 = 0x05;
const HIDOUT_AUDIO_CTL: u8 = 0x06;
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
@@ -431,6 +445,14 @@ pub enum HidOutput {
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
///
/// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host
/// produces this variant and no client renders it; it codes/decodes and round-trips in tests
/// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a
/// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still
/// send it), it would only lose the decoder that keeps such a datagram from being mistaken
/// for something else. The producer is the Steam Controller coil path; the renderer is the
/// client-side coil write. Wire up either half and this becomes live with no format change.
TrackpadHaptic {
pad: u8,
side: u8,
@@ -446,6 +468,16 @@ pub enum HidOutput {
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
/// firmware watchdog — a lost datagram heals on the next refresh.
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
/// The audio-control region of a DS5 output report `0x02` a game wrote to the host's virtual
/// pad — the routing/volume side of pad audio (the audio SAMPLES ride the [`PAD_AUDIO_MAGIC`]
/// plane). `raw` is bytes 5..=10 of the report verbatim (headphone/speaker/mic volumes +
/// audio routing); `flags` condenses the report's audio valid-flags: bit0 = haptics-select
/// (`valid_flag0` bit1 — the title asked for audio haptics on the voice coils), bits1..4 =
/// `valid_flag0` bits 4..7 (the audio-valid flags gating `raw`). Wire form
/// `[0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes]`. Forwarded change-only (deduped by
/// value host-side, like `Led`/`Trigger`) — a merely-rumbling pad re-sends unchanged audio
/// state on every output report.
AudioCtl { pad: u16, flags: u8, raw: [u8; 6] },
}
impl HidOutput {
@@ -460,7 +492,7 @@ impl HidOutput {
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]);
}
HidOutput::TrackpadHaptic {
pad,
@@ -478,6 +510,12 @@ impl HidOutput {
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
}
HidOutput::AudioCtl { pad, flags, raw } => {
out.push(HIDOUT_AUDIO_CTL);
out.extend_from_slice(&pad.to_le_bytes());
out.push(*flags);
out.extend_from_slice(raw);
}
}
out
}
@@ -497,10 +535,17 @@ impl HidOutput {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
// `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it
// as an EMPTY effect was actively harmful — downstream an empty block is written as an
// all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A
// truncated datagram could therefore silently cancel the trigger a game was holding.
// A genuine "no effect" is a full-length zero block and still decodes fine.
HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
// Bounded like `HidRaw` below: at most the parameter block is kept from the
// (attacker-sized) tail.
effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
@@ -515,6 +560,22 @@ impl HidOutput {
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
}),
// B27: the pad is the only u16 index on this plane, and every consumer narrows it
// with `as u8` on the stated assumption that pads are 0..MAX_PADS. Nothing enforced
// that, so wire pad 256 silently ALIASED onto slot 0 — a malformed or hostile
// datagram steering a real controller's speaker volumes. Rejected here, at the one
// place the u16 exists, so the narrowings downstream are lossless by construction
// (the same fix R10 applied to the rumble plane).
HIDOUT_AUDIO_CTL
if b.len() >= 11
&& u16::from_le_bytes([b[2], b[3]]) < crate::input::MAX_PADS as u16 =>
{
Some(HidOutput::AudioCtl {
pad: u16::from_le_bytes([b[2], b[3]]),
flags: b[4],
raw: b[5..11].try_into().unwrap(),
})
}
_ => None,
}
}
@@ -773,6 +834,72 @@ pub fn decode_cursor_state_datagram(b: &[u8]) -> Option<CursorState> {
})
}
/// Pad-audio datagram tag, host → client: per-gamepad audio a game routed
/// to the host's virtual DualSense — voice-coil haptics and the built-in speaker — for the client
/// to render on the matching real controller. Next tag after [`CURSOR_STATE_MAGIC`]. The
/// per-pad AUDIO plane (Opus frames, the [`AUDIO_MAGIC`]/[`MIC_MAGIC`] shape plus pad + kind);
/// the routing/volume CONTROL side rides [`HidOutput::AudioCtl`]. Emitted only when the session
/// negotiated it ([`CLIENT_CAP_PAD_AUDIO`](super::caps::CLIENT_CAP_PAD_AUDIO) ∧
/// [`HOST_CAP_PAD_AUDIO`](super::caps::HOST_CAP_PAD_AUDIO)) and the pad's arrival declared a
/// renderer for the kind ([`crate::input::ARRIVAL_FLAG_PAD_AUDIO_HAPTICS`]/`_SPEAKER`).
/// Best-effort like every audio datagram: a lost frame is a concealed gap, never state.
pub const PAD_AUDIO_MAGIC: u8 = 0xD1;
/// [`PadAudioFrame::kind`]: the BACK channel pair — the DualSense voice-coil actuators (audio
/// haptics). 5 ms Opus frames, matching the [`AUDIO_MAGIC`] cadence: haptics are felt latency.
pub const PAD_AUDIO_KIND_HAPTICS: u8 = 0;
/// [`PadAudioFrame::kind`]: the FRONT channel pair — the controller's built-in speaker. 10 ms
/// Opus frames (speaker content tolerates the extra buffering for the better coding efficiency).
pub const PAD_AUDIO_KIND_SPEAKER: u8 = 1;
/// Wire length of a pad-audio datagram header: tag + pad + kind + u32 seq + u64 pts = 15 bytes.
const PAD_AUDIO_HEADER_LEN: usize = 1 + 1 + 1 + 4 + 8;
/// One decoded pad-audio frame (owned — the client's plane queue stores it). `seq`/`pts_ns` are
/// per-(pad, kind) counters from the host's capture clock, for gap concealment and lip-sync
/// against the main audio plane.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PadAudioFrame {
/// Gamepad index (the wire pad space, same as rumble/HID-output).
pub pad: u8,
/// [`PAD_AUDIO_KIND_HAPTICS`] or [`PAD_AUDIO_KIND_SPEAKER`].
pub kind: u8,
pub seq: u32,
pub pts_ns: u64,
/// The raw Opus payload — feed it to an Opus decoder as one frame. Empty = DTX silence.
pub opus: Vec<u8>,
}
/// Pad-audio datagram, host → client:
/// `[0xD1][u8 pad][u8 kind][u32 seq LE][u64 pts_ns LE][opus payload]` — the
/// [`encode_audio_datagram`]/[`encode_mic_datagram`] layout with a pad + kind prefix, one Opus
/// frame per datagram (5/10 ms — well under any MTU); QUIC already encrypts.
pub fn encode_pad_audio_datagram(pad: u8, kind: u8, seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(PAD_AUDIO_HEADER_LEN + opus.len());
b.push(PAD_AUDIO_MAGIC);
b.push(pad);
b.push(kind);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a pad-audio datagram → [`PadAudioFrame`]. `None` on bad tag/length (the fixed header
/// length bounds every read before it happens).
pub fn decode_pad_audio_datagram(buf: &[u8]) -> Option<PadAudioFrame> {
if buf.len() < PAD_AUDIO_HEADER_LEN || buf[0] != PAD_AUDIO_MAGIC {
return None;
}
Some(PadAudioFrame {
pad: buf[1],
kind: buf[2],
seq: u32::from_le_bytes(buf[3..7].try_into().unwrap()),
pts_ns: u64::from_le_bytes(buf[7..15].try_into().unwrap()),
opus: buf[15..].to_vec(),
})
}
#[cfg(test)]
mod tests {
use crate::quic::*;
@@ -981,6 +1108,82 @@ mod tests {
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
/// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side.
/// Pinned here because both halves matter: an over-long effect must be clamped on the way out
/// AND on the way in, and a body with no effect bytes must not decode at all.
#[test]
fn trigger_effect_is_clamped_on_both_encode_and_decode() {
// Encode clamps: a caller handing over an over-long block cannot put it on the wire.
let long = HidOutput::Trigger {
pad: 1,
which: 0,
effect: vec![0xAB; 200],
};
let d = long.encode();
assert_eq!(
d.len(),
4 + TRIGGER_EFFECT_MAX,
"magic + kind + pad + which + at most the parameter block"
);
// Decode clamps independently of encode — a hostile peer does not use our encoder.
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0];
hostile.extend_from_slice(&[0xCD; 500]);
match HidOutput::decode(&hostile) {
Some(HidOutput::Trigger { effect, .. }) => {
assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded");
}
other => panic!("expected a clamped Trigger, got {other:?}"),
}
// An exact-length effect survives untouched, and round-trips.
let ok = HidOutput::Trigger {
pad: 2,
which: 1,
effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0],
};
assert_eq!(HidOutput::decode(&ok.encode()), Some(ok));
}
/// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect:
/// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it
/// releases whatever effect the game was holding. A truncated datagram must not do that.
#[test]
fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() {
let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0];
assert_eq!(HidOutput::decode(&empty), None);
// One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes.
let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02];
assert_eq!(
HidOutput::decode(&one),
Some(HidOutput::Trigger {
pad: 0,
which: 0,
effect: vec![0x02]
})
);
}
/// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair
/// cannot drift apart again.
#[test]
fn hid_raw_stays_bounded_on_both_sides() {
let long = HidOutput::HidRaw {
pad: 0,
kind: HID_RAW_OUTPUT,
data: vec![0x11; 500],
};
assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX);
let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE];
hostile.extend_from_slice(&[0x22; 900]);
match HidOutput::decode(&hostile) {
Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX),
other => panic!("expected a clamped HidRaw, got {other:?}"),
}
}
#[test]
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
// v2 envelope round-trips seq + ttl.
@@ -1180,6 +1383,12 @@ mod tests {
f
},
},
// The DS5 audio-control region (haptics-select + speaker volume asserted).
HidOutput::AudioCtl {
pad: 1,
flags: 0b0_0101,
raw: [0x50, 0x60, 0x70, 0x05, 0x00, 0x00],
},
];
for ev in &cases {
let d = ev.encode();
@@ -1198,6 +1407,92 @@ mod tests {
)
.is_none());
}
#[test]
fn audio_ctl_wire_layout_and_truncation() {
// The exact 11-byte layout: [0xCD][0x06][u16 pad LE][u8 flags][6 raw bytes].
// The pad is deliberately a REPRESENTABLE one: this used to assert that 0x0201 (513)
// round-tripped, which pinned B27's aliasing in place as if it were the contract.
let a = HidOutput::AudioCtl {
pad: 0x000B,
flags: 0x17,
raw: [1, 2, 3, 4, 5, 6],
};
let d = a.encode();
assert_eq!(d, [0xCD, 0x06, 0x0B, 0x00, 0x17, 1, 2, 3, 4, 5, 6]);
assert_eq!(HidOutput::decode(&d), Some(a));
// Truncated buffers are rejected outright (fixed length — never a partial read).
for n in 2..d.len() {
assert_eq!(HidOutput::decode(&d[..n]), None);
}
}
#[test]
fn pad_audio_datagram_roundtrip_and_truncation() {
let opus = [0x5Au8; 61];
let d = encode_pad_audio_datagram(3, PAD_AUDIO_KIND_HAPTICS, 42, 9_999, &opus);
assert_eq!(d[0], PAD_AUDIO_MAGIC);
assert_eq!(d.len(), 15 + opus.len());
let f = decode_pad_audio_datagram(&d).unwrap();
assert_eq!((f.pad, f.kind, f.seq, f.pts_ns), (3, 0, 42, 9_999));
assert_eq!(f.opus, opus);
// Truncated headers are rejected outright (never partially read).
for n in 0..15 {
assert_eq!(decode_pad_audio_datagram(&d[..n]), None);
}
// Tag separation: a pad-audio datagram is not a session-audio/mic datagram and vice-versa.
assert!(decode_audio_datagram(&d).is_none());
assert!(decode_mic_datagram(&d).is_none());
assert!(decode_pad_audio_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
// Empty payload (DTX) is legal — header-only datagram.
let hdr = encode_pad_audio_datagram(0, PAD_AUDIO_KIND_SPEAKER, 0, 0, &[]);
assert_eq!(hdr.len(), 15);
assert!(decode_pad_audio_datagram(&hdr).unwrap().opus.is_empty());
}
/// B27: the pad is the only u16 index on the 0xCD plane and every consumer narrows it with
/// `as u8`. An out-of-range one used to alias onto a real slot instead of being refused —
/// wire pad 256 steering pad 0's speaker volumes.
#[test]
fn audio_ctl_rejects_a_pad_outside_the_index_space() {
let ok = HidOutput::AudioCtl {
pad: (crate::input::MAX_PADS - 1) as u16,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
};
assert_eq!(
HidOutput::decode(&ok.encode()),
Some(ok),
"the last valid pad must still decode"
);
// Anything at or above MAX_PADS is refused outright, not truncated.
for pad in [crate::input::MAX_PADS as u16, 256, u16::MAX] {
let d = HidOutput::AudioCtl {
pad,
flags: 0x12,
raw: [1, 2, 3, 4, 5, 6],
}
.encode();
assert_eq!(HidOutput::decode(&d), None, "pad {pad} must not decode");
}
// The specific alias the bug produced: 256 as u8 == 0.
let d = HidOutput::AudioCtl {
pad: 256,
flags: 0,
raw: [0; 6],
}
.encode();
assert!(
!matches!(
HidOutput::decode(&d),
Some(HidOutput::AudioCtl { pad: 0, .. })
),
"wire pad 256 must never surface as pad 0"
);
}
#[test]
fn cursor_state_roundtrip() {
for (flags, x, y) in [
+1 -1
View File
@@ -25,7 +25,7 @@
//! Split by concern (networking-audit deferred plan §3 — a pure move): `handshake` the
//! positional Hello/Welcome/Start codecs, `caps` the capability/codec-negotiation
//! vocabulary, `control` the typed control + clipboard messages, `pairing` the pairing
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xCF plane codecs,
//! message codecs with [`pake`] the SPAKE2 itself, `datagram` the 0xC90xD1 plane codecs,
//! `pen` the stylus batch (0xCC kind 0x05) + host stroke tracker,
//! [`io`] framed stream IO, `clock` skew estimation + mid-stream re-sync, [`endpoint`] the
//! quinn constructors, [`clipstream`] the per-transfer clipboard fetch streams. Every item
+11
View File
@@ -259,6 +259,17 @@ windows = { version = "0.62", features = [
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
# Pad-audio endpoint provisioning (audio/windows/pad_endpoint.rs): IMMDevice + IPropertyStore
# to stamp the DualSense identity onto the minted endpoints (PROPVARIANT lives in
# StructuredStorage and is gated on the Variant feature), DEVPKEY_Device_DriverInfPath to
# resolve the installed Steam Streaming Speakers INF, and raw Reg* calls behind the MMDevices
# ACL repair + the devnode's pad-index marker value.
"Win32_Media_Audio",
"Win32_UI_Shell_PropertiesSystem",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_Devices_Properties",
"Win32_System_Registry",
# SetUnhandledExceptionFilter + EXCEPTION_POINTERS — the last-resort native-crash logger
# (src/windows/crash.rs); Kernel gates the CONTEXT type EXCEPTION_POINTERS embeds.
"Win32_System_Diagnostics_Debug",
+6
View File
@@ -183,6 +183,12 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
#[cfg(target_os = "windows")]
#[path = "audio/windows/pad_endpoint.rs"]
pub(crate) mod pad_endpoint;
#[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap;
@@ -143,6 +143,17 @@ pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
}
/// Endpoint ids among `renders` that are the host's own pad-audio endpoints — the exclusion
/// data [`plan`] runs on. Detection lives in [`super::pad_endpoint`] (stamped PFDS container /
/// devnode marker, registry-only reads); this is just the per-pass collection.
fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
renders
.iter()
.filter(|(_, id)| super::pad_endpoint::is_pad_render_endpoint(id))
.map(|(_, id)| id.clone())
.collect()
}
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
@@ -159,6 +170,10 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
// The host's own pad-audio ("DualSense speaker") endpoints, by id — the pure plan filters
// them out of every role. Identity is platform data (stamped container / devnode marker),
// so it is collected HERE and passed in, like the candidate lists themselves.
let pad_ids = pad_render_ids(&renders);
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
@@ -179,6 +194,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
// cannot carry stereo cannot carry 5.1 either.
2,
&pad_ids,
);
let done = |wiring: Wiring| WiredPlan {
wiring,
@@ -245,7 +261,7 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
if let Some((mic_name, mic_id)) = &wiring.mic_render {
if default_render_id().as_deref() == Some(mic_id.as_str()) {
// Audible preference = the host_audio plan's loopback pick (real hardware first).
match plan(&renders, &captures, want.as_deref(), true).loopback_render {
match plan(&renders, &captures, want.as_deref(), true, &pad_ids).loopback_render {
Some((name, id)) => match set_default_endpoint(&id) {
Ok(()) => tracing::info!(mic = %mic_name, device = %name,
"default playback was the virtual-mic target — moved it so desktop \
@@ -302,8 +318,10 @@ fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The current default RENDER endpoint id, if any.
fn default_render_id() -> Option<String> {
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
/// default playback device).
pub(crate) fn default_render_id() -> Option<String> {
wasapi::DeviceEnumerator::new()
.ok()?
.get_default_device(&Direction::Render)
@@ -430,11 +448,13 @@ pub(crate) fn restore_default_playback() {
}
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), so it fails at random on ids that are perfectly valid.
pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
wasapi::DeviceEnumerator::new()
.map_err(|e| anyhow!("DeviceEnumerator: {e}"))?
.get_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e}", ep.0))
super::pad_endpoint::open_wasapi_device(&ep.1)
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
@@ -481,8 +501,9 @@ const _: () = {
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
/// fails.
fn set_default_endpoint(device_id: &str) -> Result<()> {
/// fails. pub(crate): the pad-endpoint default-device guard restores the operator's default
/// through the same machinery.
pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
File diff suppressed because it is too large Load Diff
@@ -511,7 +511,7 @@ fn capture_once(
if assert_plan {
if let Some(d) = seen_default.as_deref() {
if d != dev_id {
match judge_default(&en, wiring, d) {
match judge_default(wiring, d) {
DefaultKind::Capturable(name) => {
tracing::info!(default = %name, planned = %dev_name,
"could not park the default playback on the planned endpoint — \
@@ -639,7 +639,7 @@ fn capture_once(
);
return Ok(Next::Reopen(TargetMode::Follow));
}
match judge_default(&en, wiring, &nid) {
match judge_default(wiring, &nid) {
DefaultKind::Capturable(name) => {
audio_client.stop_stream().ok();
tracing::info!(device = %name,
@@ -726,8 +726,11 @@ enum DefaultKind {
Unknown,
}
fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = en.get_device(id) else {
/// Resolves through [`super::pad_endpoint::open_wasapi_device`], NOT the `wasapi` crate's
/// `DeviceEnumerator::get_device` — that one hands `GetDevice` a freed string (see the helper's
/// docs), and a spurious miss here silently downgrades a capturable default to `Unknown`.
fn judge_default(wiring: &wiring_plan::Wiring, id: &str) -> DefaultKind {
let Ok(dev) = super::pad_endpoint::open_wasapi_device(id) else {
return DefaultKind::Unknown;
};
let name = dev.get_friendlyname().unwrap_or_default();
@@ -736,7 +739,15 @@ fn judge_default(en: &DeviceEnumerator, wiring: &wiring_plan::Wiring, id: &str)
.mic_render
.as_ref()
.is_some_and(|(_, mic_id)| mic_id == id);
if is_mic || wiring_plan::excluded_from_loopback(&ln) {
// B10: a pad's audio endpoint is not ordinary hardware, and the name rules cannot see that —
// it is deliberately stamped with the controller's own name ("DualSense Wireless Controller")
// so games treat it as the pad's speaker, which means `excluded_from_loopback` passes it
// straight through as `Capturable`. The pure plan filtered these out, but the plan is not the
// only reader: this classifier drives the watchdog, Follow mode and the parked default, so a
// pad endpoint that happened to be the system default could be adopted as the desktop capture
// source — sending the whole desktop mix to a controller's voice coils. Identity, not name.
let is_pad = super::pad_endpoint::is_pad_render_endpoint(id);
if is_mic || is_pad || wiring_plan::excluded_from_loopback(&ln) {
DefaultKind::Dud(name)
} else {
DefaultKind::Capturable(name)
@@ -253,25 +253,16 @@ pub(crate) fn install_steam_audio_pair() -> bool {
mic || spk
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
/// Full path of a Steam Remote Play driver INF under Steam's per-arch driver directory
/// (`%CommonProgramFiles(x86)%\Steam\drivers\Windows10\{arch}\<inf_name>`), as a NUL-terminated
/// UTF-16 buffer. Shared by [`try_install_steam_audio`] and the pad-endpoint provisioning
/// ([`super::pad_endpoint`]), which feeds the same INF to `UpdateDriverForPlugAndPlayDevicesW`
/// when no installed Steam Streaming Speakers devnode exposes its `oemNN.inf`. `None` when the
/// environment expansion fails (existence is the caller's check).
pub(crate) fn steam_driver_inf_path(inf_name: &str) -> Option<Vec<u16>> {
use windows::core::PCWSTR;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
// Steam ships per-arch driver INFs under `Steam\drivers\Windows10\{arch}\`.
#[cfg(target_arch = "x86_64")]
let subdir = "x64";
@@ -290,8 +281,33 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
let n =
unsafe { ExpandEnvironmentStringsW(PCWSTR(template.as_ptr()), Some(path.as_mut_slice())) };
if n == 0 || n as usize > path.len() {
return None;
}
path.truncate(n as usize); // keeps the NUL
Some(path)
}
/// Install one Steam Streaming driver INF by filename via `DiInstallDriverW` (loaded from
/// `newdev.dll`, like Apollo, to avoid an extra windows-crate feature). See
/// [`install_steam_audio_pair`] for the contract; `inf_name` is a bare filename under Steam's
/// per-arch `drivers\Windows10\{arch}\` directory.
///
/// Safe: `inf_name` is a `&str` and every FFI argument is built locally from it, so there is no
/// precondition a caller could break — the `unsafe` is the `LoadLibraryExW`/`transmute`/call chain
/// inside, which is this function's own business.
fn try_install_steam_audio(inf_name: &str) -> bool {
use windows::core::{s, w, PCWSTR};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
if std::env::var_os("PUNKTFUNK_NO_MIC_INSTALL").is_some() {
return false;
}
let Some(path) = steam_driver_inf_path(inf_name) else {
return false;
};
// SAFETY: a static NUL-terminated literal, loaded from System32 only (the flag), so this cannot
// pick up a planted `newdev.dll` from the working directory. The handle is checked before use.
+120 -27
View File
@@ -186,6 +186,17 @@ fn virtualish(lname: &str) -> bool {
|| lname.contains("voicemeeter")
}
/// Is this render endpoint id one of the virtual pad's audio endpoints?
///
/// Pulled out of [`plan`] because the plan is NOT the only place that must not treat these as
/// ordinary hardware — see [`excluded_from_loopback`]'s callers. A pad endpoint is deliberately
/// stamped with the controller's own name ("DualSense Wireless Controller") so games read it as
/// the pad's speaker, which means no name-based rule can recognise one; the only reliable test is
/// identity against the ids the pad-endpoint provisioner created.
pub(crate) fn is_pad_render(id: &str, pad_renders: &[String]) -> bool {
pad_renders.iter().any(|p| p == id)
}
/// Compute the assignment. `mic_want` is the operator override (`PUNKTFUNK_MIC_DEVICE`,
/// lowercased): when set it beats the built-in candidate order for the mic target. `host_audio`
/// flips the loopback preference to real hardware (audio audible on the host too); the default
@@ -195,8 +206,17 @@ pub(crate) fn plan(
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
pad_renders: &[String],
) -> Wiring {
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
plan_with_formats(
renders,
captures,
mic_want,
host_audio,
&no_formats,
2,
pad_renders,
)
}
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
@@ -221,7 +241,20 @@ pub(crate) fn plan_with_formats(
host_audio: bool,
format_of: FormatProbe,
want_channels: u8,
pad_renders: &[String],
) -> Wiring {
// 0. Pad-audio endpoints are invisible to the plan: never the mic target (client voice
// would play out of a pad "speaker"), never a loopback source (a game's controller
// audio cues would stream as desktop audio), and — since this shadows `renders` for
// every tier below — never the flagged last resort either. Their names carry no virtual
// marker (they are stamped "DualSense Wireless Controller" on purpose, so games read
// them as the pad's speaker), so the name rules alone would take one for real hardware.
let renders: Vec<Endpoint> = renders
.iter()
.filter(|(_, id)| !is_pad_render(id, pad_renders))
.cloned()
.collect();
let renders = renders.as_slice();
let find_render = |needle: &str| {
renders
.iter()
@@ -422,7 +455,7 @@ mod tests {
ep("Microphone (Webcam)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -451,7 +484,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -471,7 +504,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let w = plan(&renders, &[], None, true);
let w = plan(&renders, &[], None, true, &[]);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Apple Audio Device)"
@@ -488,7 +521,7 @@ mod tests {
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
}
@@ -500,7 +533,7 @@ mod tests {
fn headless_cable_only_mic_wins() {
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.mic_render.is_some(), "mic must claim the only cable");
assert!(w.loopback_render.is_none(), "no echo-safe loopback exists");
}
@@ -518,7 +551,7 @@ mod tests {
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
@@ -546,7 +579,7 @@ mod tests {
ep("Speakers (Realtek HD Audio)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -560,7 +593,7 @@ mod tests {
fn steam_mic_only_no_echo() {
let renders = [ep("Speakers (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.mic_render.is_some());
assert!(w.loopback_render.is_none());
}
@@ -576,7 +609,7 @@ mod tests {
ep("Speakers (Steam Streaming Speakers)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
@@ -597,7 +630,7 @@ mod tests {
ep("Altavoces (Steam Streaming Microphone)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Altavoces (Steam Streaming Microphone)"
@@ -620,7 +653,7 @@ mod tests {
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Realtek HD Audio)",
@@ -642,7 +675,7 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
assert!(!w.loopback_last_resort, "host_audio={host_audio}");
assert!(w.loopback_unsatisfiable(), "host_audio={host_audio}");
@@ -691,7 +724,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("odyssey", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
@@ -721,7 +754,7 @@ mod tests {
("steam streaming microphone", fmt(48_000, 2)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -737,7 +770,7 @@ mod tests {
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Microphone)"
@@ -753,7 +786,7 @@ mod tests {
fn narrowing_is_reported_for_real_hardware_too() {
let renders = [ep("Headset (Hands-Free AG Audio)")];
let p = probe(vec![("headset", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
let w = plan_with_formats(&renders, &[], None, false, &p, 2, &[]);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Headset (Hands-Free AG Audio)"
@@ -773,8 +806,8 @@ mod tests {
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let a = plan(&renders, &captures, None, host_audio);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
let a = plan(&renders, &captures, None, host_audio, &[]);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2, &[]);
assert_eq!(a, b, "host_audio={host_audio}");
assert!(a.loopback_narrowing.is_none());
}
@@ -792,7 +825,7 @@ mod tests {
("steam streaming microphone", fmt(24_000, 1)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
let w = plan_with_formats(&renders, &[], None, true, &p, 2, &[]);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -820,7 +853,7 @@ mod tests {
ep("Voicemeeter Input (VB-Audio Voicemeeter VAIO)"),
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
let w = plan(&renders, &captures, Some("voicemeeter input"), false);
let w = plan(&renders, &captures, Some("voicemeeter input"), false, &[]);
assert_eq!(
w.mic_render.unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)"
@@ -836,7 +869,7 @@ mod tests {
#[test]
fn no_virtual_device() {
let renders = [ep("Speakers (Realtek HD Audio)")];
let w = plan(&renders, &[], None, false);
let w = plan(&renders, &[], None, false, &[]);
assert!(w.mic_render.is_none());
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
@@ -854,7 +887,7 @@ mod tests {
];
let captures = [ep("Voicemeeter Out B1 (VB-Audio Voicemeeter VAIO)")];
for host_audio in [false, true] {
let w = plan(&renders, &captures, None, host_audio);
let w = plan(&renders, &captures, None, host_audio, &[]);
assert_eq!(
w.mic_render.as_ref().unwrap().0,
"Voicemeeter Input (VB-Audio Voicemeeter VAIO)",
@@ -877,7 +910,7 @@ mod tests {
ep("Voicemeeter Aux Input (VB-Audio Voicemeeter AUX VAIO)"),
];
for host_audio in [false, true] {
let w = plan(&renders, &[], None, host_audio);
let w = plan(&renders, &[], None, host_audio, &[]);
assert!(w.mic_render.is_some(), "host_audio={host_audio}");
assert!(w.loopback_render.is_none(), "host_audio={host_audio}");
}
@@ -892,7 +925,7 @@ mod tests {
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Some Virtual Audio Device)"),
];
let w = plan(&renders, &[], None, false);
let w = plan(&renders, &[], None, false, &[]);
assert!(w.loopback_render.is_none());
}
@@ -918,7 +951,7 @@ mod tests {
// Field shape minus the Speakers (mic holds the Streaming Microphone, nothing else).
let renders = [ep("Altavoces (Steam Streaming Microphone)")];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("reserved for the virtual mic"), "{msg}");
@@ -929,10 +962,70 @@ mod tests {
// anyway), while the Steam pair is the remedy that adds a capturable sink.
let renders = [ep("CABLE Input (VB-Audio Virtual Cable)")];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
let w = plan(&renders, &captures, None, false);
let w = plan(&renders, &captures, None, false, &[]);
assert!(w.loopback_unsatisfiable());
let msg = describe_no_loopback(&renders, &w);
assert!(msg.contains("install Steam"), "{msg}");
assert!(!msg.contains("install VB-Audio Virtual Cable"), "{msg}");
}
/// A stamped pad endpoint is invisible to the plan. Its name carries NO virtual marker — on
/// purpose, games must read it as the pad's speaker — so the name rules alone would classify
/// it as real hardware and hand it the loopback; only the id exclusion prevents that.
/// Measured fact: the wiring plan on the target box already enumerated a stamped endpoint.
#[test]
fn pad_endpoints_invisible() {
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Realtek HD Audio)"),
];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &[], None, false, &pads);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
// Even an operator mic override matching the pad's name must not claim it; with the
// pad as the only render endpoint there is honestly no mic target and no loopback.
let w = plan(
&renders[..1],
&[],
Some("wireless controller"),
false,
&pads,
);
assert!(w.mic_render.is_none());
assert!(w.loopback_render.is_none());
}
/// The exclusion has to survive the LAST RESORT tier, which this merge introduced alongside
/// pad audio. `last_resort` matches on the Steam-Speakers name, but it reads the same
/// shadowed `renders`, so a pad can never be reached through it either — otherwise the whole
/// desktop mix would be routed into the controller's voice coils.
#[test]
fn a_pad_is_never_the_last_resort() {
// Only the pad and the Steam pair exist; the mic reserves the Streaming Microphone, so
// the plan falls all the way through to the last resort.
let renders = [
ep("DualSense Wireless Controller"),
ep("Speakers (Steam Streaming Microphone)"),
ep("Speakers (Steam Streaming Speakers)"),
];
let captures = [ep("Microphone (Steam Streaming Microphone)")];
let pads = [renders[0].1.clone()];
let w = plan(&renders, &captures, None, false, &pads);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Speakers)",
"the last resort must skip the pad"
);
assert!(w.loopback_last_resort);
// …and with the pad as the ONLY candidate left, the plan stays honestly unsatisfiable
// rather than falling back onto the coils.
let w = plan(&renders[..1], &captures, None, false, &pads);
assert!(
w.loopback_render.is_none(),
"a pad was taken as the last resort"
);
assert!(!w.loopback_last_resort);
assert!(w.loopback_unsatisfiable());
}
}
+115
View File
@@ -384,6 +384,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 2,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
@@ -430,6 +431,7 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
index: idx,
kind: 1,
capabilities: 0,
audio_caps: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
@@ -486,6 +488,119 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
#[cfg(target_os = "windows")]
pub fn pad_endpoint(args: &[String]) -> Result<()> {
use crate::audio::pad_endpoint as pe;
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--endpoint <id>` drives ANY render endpoint, not just a provisioned pad one. It is the
// discriminator between "this process cannot activate anything" and "our endpoint is broken":
// aim the same binary at a known-good endpoint and see whether it succeeds there.
let endpoint_override: Option<String> = args
.iter()
.skip_while(|a| *a != "--endpoint")
.nth(1)
.cloned();
match args.get(1).map(String::as_str) {
Some("ensure") => {
let p = pe::ensure(idx)?;
println!(
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
);
Ok(())
}
Some("remove") => match pe::find(idx)? {
Some(p) => {
pe::remove(&p);
println!(
"pad-endpoint remove: requested removal of {}",
p.device_instance
);
Ok(())
}
None => {
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
Ok(())
}
},
// `punktfunk-host pad-endpoint <n> tone [seconds] [hz]` — drive the endpoint directly so
// the whole pad-audio chain can be exercised without a game. Without this, every attempt
// costs a game launch and a failure does not say which link broke.
Some("tone") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let hz: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60.0);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => {
// `find` (a system lookup), NOT `endpoint_for` (the service's in-process
// cache): this runs as a separate CLI process and has no cache of its own.
let Some(ep) = pe::find(idx)? else {
println!(
"pad-endpoint tone: no pad-audio devnode for pad {idx} — run \
`ensure` first"
);
return Ok(());
};
if ep.endpoint_id.is_empty() {
println!("pad-endpoint tone: pad {idx} has no endpoint id yet");
return Ok(());
}
ep.endpoint_id
}
};
// `--pair front` drives the pad's SPEAKER instead of the voice coils — the only way to
// exercise the speaker kind without a game that renders one.
let pair = args
.iter()
.skip_while(|a| *a != "--pair")
.nth(1)
.map_or(pe::TonePair::Back, |s| pe::TonePair::parse(s));
println!(
"pad-endpoint tone: {hz} Hz into the {} of {endpoint_id} for {secs}s",
pair.label()
);
pe::render_test_tone(&endpoint_id, secs, hz, pair)?;
println!(
"pad-endpoint tone: done. A connected client with pad audio enabled should have \
buzzed; the host log shows whether the gate opened."
);
Ok(())
}
// `punktfunk-host pad-endpoint capture [seconds]` — the receiving half of `tone`. Run
// both at once to exercise render -> engine -> loopback -> pair routing with no game and
// no client attached.
Some("capture") => {
let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
let endpoint_id = match endpoint_override {
Some(id) => id,
None => match pe::find(idx)? {
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
_ => {
println!("pad-endpoint capture: pad {idx} has no endpoint — run `ensure`");
return Ok(());
}
},
};
println!("pad-endpoint capture: listening on {endpoint_id} for {secs}s");
pe::capture_probe(&endpoint_id, secs)
}
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
}
}
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
@@ -65,6 +65,8 @@ pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
index: *b.first()?,
kind: *b.get(1)?,
capabilities: le16(2)? as u16,
// GameStream's LI_CCAP vocabulary can't express pad audio — native-plane only.
audio_caps: 0,
}),
_ => None,
}
@@ -138,6 +140,7 @@ mod tests {
index,
kind,
capabilities,
..
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
else {
panic!("expected Arrival");
+4
View File
@@ -618,6 +618,10 @@ fn real_main() -> Result<()> {
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
#[cfg(target_os = "windows")]
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
// Windows: pad-audio endpoint provisioning (`ensure`/`status`) + the pnputil removal
// escape hatch (`remove`). `--index N` selects the pad slot (default 0).
#[cfg(target_os = "windows")]
Some("pad-endpoint") => devtest::pad_endpoint(&args),
// Capture→encode→file pipeline spike (dev tool).
Some("spike") => spike::run(parse_spike(&args[1..])?),
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
+20 -1
View File
@@ -62,6 +62,12 @@ use pairing::pair_ceremony;
mod audio;
use audio::audio_thread;
/// Per-pad DualSense audio (the 0xD1 plane): loopback capture of the pre-provisioned pad
/// endpoints → per-kind silence gate → stereo Opus → `PAD_AUDIO_MAGIC` datagrams. The input
/// thread spawns/reaps one streamer per arriving pad (`input`); the Welcome advertises the cap
/// via `pad_audio::host_cap` (`handshake`).
mod pad_audio;
/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a
/// channel of `ClientInput`. The `Pads` router + rumble live there too.
mod input;
@@ -345,6 +351,14 @@ pub(crate) async fn serve(
// binds its capture device) and self-heals when the backend dies (PipeWire restart, Windows
// endpoint churn).
let mic_service = crate::audio::MicPump::start();
// Windows, env-gated (PUNKTFUNK_PAD_AUDIO / _SLOTS): pre-provision the per-pad "DualSense
// speaker" render endpoints once per host lifetime — idempotent devnode + stamp work on a
// dedicated COM thread, results published for sessions to query by pad index
// (crate::audio::pad_endpoint::endpoint_for). If any stamp is stored-but-not-served, the
// worker performs ONE AudioEndpointBuilder+Audiosrv restart now, before any session exists.
// Failures log once and leave the feature off: pads still work, just without pad audio.
#[cfg(target_os = "windows")]
crate::audio::pad_endpoint::provision_at_startup();
// Host-lifetime worker that fires debounced TV-session restores (the managed gamescope path
// restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
@@ -1203,9 +1217,14 @@ async fn serve_session(
let input_handle = {
let conn = conn.clone();
let gamepad = welcome.gamepad;
// Pad audio (0xD1) negotiated: the Welcome advertised the cap (Windows + provisioned
// endpoints + the client asked — handshake reads `pad_audio::host_cap`). Read back off
// the Welcome rather than recomputed, so the input thread's spawns cannot disagree
// with what the client was told.
let pad_audio_on = welcome.host_caps & punktfunk_core::quic::HOST_CAP_PAD_AUDIO != 0;
std::thread::Builder::new()
.name("punktfunk1-input".into())
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad))
.spawn(move || input_thread(input_rx, conn, inj_tx, gamepad, pad_audio_on))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
@@ -640,6 +640,16 @@ pub(super) async fn negotiate(
punktfunk_core::quic::HOST_CAP_AUDIO_RED
} else {
0
}
// Per-pad DualSense audio (0xD1 + HidOutput::AudioCtl): granted only when the
// client asked AND this host can capture it — Windows with the feature enabled
// and at least one pad endpoint provisioned at startup. A capable client then
// marks its pads' renderers on their arrivals; the input thread streams toward
// exactly those pads (`super::pad_audio`).
| if super::pad_audio::host_cap(hello.client_caps) {
punktfunk_core::quic::HOST_CAP_PAD_AUDIO
} else {
0
},
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
+143 -4
View File
@@ -515,6 +515,100 @@ impl Pads {
}
}
/// Per-pad 0xD1 streamers (`super::pad_audio`), keyed by pad index like every per-pad table
/// here (bounded by [`MAX_WIRE_PADS`]; only slots 0..4 can ever have a provisioned endpoint —
/// `spawn` refuses the rest). Spawned when a negotiated session's DualSense-family arrival
/// declares renderer bits, reaped on remove / re-declare / session teardown.
struct PadAudioSlots {
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
restarts: [u8; MAX_WIRE_PADS],
}
/// R3: how many times one pad may change its declared audio kinds before the host stops
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
/// identical and take the no-op path above, so this is only reached by a client that keeps
/// changing its mind.
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
impl PadAudioSlots {
fn new() -> PadAudioSlots {
PadAudioSlots {
slots: std::array::from_fn(|_| None),
restarts: [0; MAX_WIRE_PADS],
}
}
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
}
if let Some((have, _)) = &self.slots[idx] {
if *have == kinds {
return; // identical re-arrival — keep the running streamer
}
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
// Nothing bounded it: a client alternating its declared kinds could make the host
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
// changed its mind this many times in one session is not doing anything legitimate.
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
tracing::warn!(
pad = idx,
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
ignoring; the streamer keeps its current kinds for this session"
);
return;
}
self.restarts[idx] += 1;
tracing::info!(
pad = idx,
restarts = self.restarts[idx],
"pad-audio kinds changed — restarting the streamer"
);
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
self.slots[idx] = Some((kinds, h));
}
}
/// Stop + reap one pad's streamer. The join rides a detached reaper thread: a quiet pad's
/// capturer can sit out its ~5 s recv timeout, and this thread must keep its ≤4 ms
/// feedback cadence (games block on GET_REPORT handshakes) — the reaper still joins, just
/// not here. A failed reaper spawn falls back to the handle's own drop (signal + join).
fn stop(&mut self, idx: usize) {
if let Some((_, h)) = self.slots.get_mut(idx).and_then(|s| s.take()) {
h.signal();
let _ = std::thread::Builder::new()
.name("punktfunk1-padreap".into())
.spawn(move || h.stop());
}
}
/// Session teardown: flag every streamer FIRST so they wind down concurrently, then join —
/// the worst case is ONE quiet-endpoint recv timeout (~5 s), well inside the session's
/// 10 s side-thread join grace, not one per pad.
fn stop_all(&mut self) {
for s in self.slots.iter().flatten() {
s.1.signal();
}
for s in &mut self.slots {
if let Some((_, h)) = s.take() {
h.stop();
}
}
}
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
/// every pure-gyro motion sample up to 4 ms of quantization).
@@ -683,8 +777,13 @@ pub(super) fn input_thread(
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
pad_audio_on: bool,
) {
let mut pads = Pads::new(gamepad);
// Per-pad 0xD1 audio streamers, live only when the Welcome granted the cap (`pad_audio_on`
// — read back off the negotiated host_caps). Spawned on DualSense-family arrivals that
// declare renderer bits, reaped on remove/teardown below.
let mut pad_streams = PadAudioSlots::new();
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
@@ -854,16 +953,53 @@ pub(super) fn input_thread(
&mut rumble_seen[idx],
&mut rumble_stop_burst[idx],
);
// The unplugged pad's 0xD1 streamer goes with it (seq-gated like the
// rest of this arm, so a reordered stale removal can't kill the
// stream of a re-plugged pad). A re-plug re-arrives and re-spawns.
pad_streams.stop(idx);
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
// `flags` = pad index. Applied before the pad's first frame (the client sends it
// on slot open), so the device is built as the right type from the start.
let idx = ev.flags as usize;
// frames to a backend of the declared kind. `code` = the GamepadPref wire
// byte, `flags` = pad index in the LOW BYTE — bits 8/9 carry the pad's
// audio-render caps (haptics/speaker) from a pad-audio-capable client, so
// the index MUST come from `decode_gamepad_arrival`, never the whole word.
// Applied before the pad's first frame (the client sends it on slot open),
// so the device is built as the right type from the start. The audio caps
// are surfaced here for the 0xD1 capture path (which emits pad audio only
// toward pads that declared a renderer).
let (pad, audio_caps) = punktfunk_core::input::decode_gamepad_arrival(ev.flags);
let idx = pad as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
if audio_caps != 0 {
tracing::debug!(
pad = idx,
haptics = audio_caps & 0x01 != 0,
speaker = audio_caps & 0x02 != 0,
"pad-audio render caps declared (arrival flags bits 8/9)"
);
}
pads.set_kind(idx, kind);
// Pad audio (0xD1): stream toward DualSense-family pads that declared a
// renderer, only on a session that negotiated the cap. Idempotent across
// the arrival re-sends (same kinds keeps the running streamer); a
// re-declare without bits — or as a kind with no pad audio — stops it.
if pad_audio_on {
let want = if matches!(
kind,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
) {
audio_caps
} else {
0
};
if want != 0 {
pad_streams.ensure(&conn, pad, want);
} else {
pad_streams.stop(idx);
}
}
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
@@ -1019,6 +1155,9 @@ pub(super) fn input_thread(
flags: 0,
});
}
// Reap the per-pad 0xD1 streamers with the session (after the instant release sends above
// — this can block on a quiet pad's capturer timeout, see PadAudioSlots::stop_all).
pad_streams.stop_all();
}
#[cfg(test)]
@@ -0,0 +1,662 @@
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
//! reopen-with-backoff on capture death, the same monotonic-seq-kept-across-reopens discipline,
//! the same power-of-two encode-warn throttle.
use super::*;
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
#[cfg(any(target_os = "windows", test))]
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
/// `kinds` bit for the speaker stream.
#[cfg(any(target_os = "windows", test))]
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
#[cfg(any(target_os = "windows", test))]
const HAPTICS_FRAME_MS: u32 = 5;
#[cfg(any(target_os = "windows", test))]
const SPEAKER_FRAME_MS: u32 = 10;
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
#[cfg(any(target_os = "windows", test))]
const HAPTICS_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
#[cfg(any(target_os = "windows", test))]
const SPEAKER_FRAME_SAMPLES: usize =
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
/// the pure splitter logic keeps its own copy).
#[cfg(any(target_os = "windows", test))]
const CAP_CHANNELS: usize = 4;
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
/// very frame (haptics are felt latency; the first active frame must ship). ≈ 60 dBFS.
#[cfg(any(target_os = "windows", test))]
const GATE_OPEN_PEAK: f32 = 1e-3;
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
/// clipped, short enough that an idle pad costs nothing in steady state.
#[cfg(any(target_os = "windows", test))]
const GATE_HANGOVER_MS: u32 = 250;
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
#[cfg(target_os = "windows")]
const PAD_AUDIO_BITRATE: i32 = 64_000;
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
/// unit-tested below.
#[cfg(any(target_os = "windows", test))]
struct SilenceGate {
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
hangover_frames: u32,
/// Consecutive sub-threshold frames seen so far while open.
quiet: u32,
/// Starts closed: a pad no game ever renders into never opens (and never sends).
open: bool,
}
#[cfg(any(target_os = "windows", test))]
impl SilenceGate {
fn new(frame_ms: u32) -> SilenceGate {
SilenceGate {
hangover_frames: (GATE_HANGOVER_MS / frame_ms).max(1),
quiet: 0,
open: false,
}
}
/// Feed one frame; `true` = encode + send it. Signal opens the gate on THIS frame; the
/// frame that completes the hangover closes it and is itself suppressed (the client
/// already has ~250 ms of ramped-out silence by then).
fn feed(&mut self, frame: &[f32]) -> bool {
if frame.iter().any(|s| s.abs() >= GATE_OPEN_PEAK) {
self.open = true;
self.quiet = 0;
} else if self.open {
self.quiet += 1;
if self.quiet >= self.hangover_frames {
self.open = false;
self.quiet = 0;
}
}
self.open
}
}
/// One kind's send-admission + seq bookkeeping (pure logic — the capture thread wraps it with
/// the encoder and the datagram send). `seq` is monotonic per (pad, kind) and NEVER advances
/// while the gate is closed: frozen-seq = deliberate silence — the client tells silence from
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
/// sees a gap, not a restart.
#[cfg(any(target_os = "windows", test))]
struct LaneCtl {
gate: SilenceGate,
seq: u32,
}
#[cfg(any(target_os = "windows", test))]
impl LaneCtl {
fn new(frame_ms: u32) -> LaneCtl {
LaneCtl {
gate: SilenceGate::new(frame_ms),
seq: 0,
}
}
/// Admit one frame: `Some(seq)` = encode + send it with this seq (advanced for the next);
/// `None` = gated — do not send, do not advance. An encode failure AFTER admission leaves a
/// one-frame seq gap, which the client conceals exactly like datagram loss.
fn admit(&mut self, frame: &[f32]) -> Option<u32> {
if !self.gate.feed(frame) {
return None;
}
let seq = self.seq;
self.seq = self.seq.wrapping_add(1);
Some(seq)
}
}
/// De-interleave one 4-ch block (FL FR BL BR) into its stereo pairs: `(front, back)` — front =
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
/// across channels.
#[cfg(any(target_os = "windows", test))]
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mut front = Vec::with_capacity(block.len() / 2);
let mut back = Vec::with_capacity(block.len() / 2);
for s in block.chunks_exact(CAP_CHANNELS) {
front.extend_from_slice(&s[..2]);
back.extend_from_slice(&s[2..4]);
}
(front, back)
}
/// Accumulates interleaved 4-ch capture and cuts it into the wire contract's per-kind stereo
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
#[cfg(any(target_os = "windows", test))]
struct PadFramer {
kinds: u8,
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
acc: Vec<f32>,
/// Front-pair stereo accumulation toward the next 10 ms speaker frame.
front: Vec<f32>,
}
#[cfg(any(target_os = "windows", test))]
impl PadFramer {
fn new(kinds: u8) -> PadFramer {
PadFramer {
kinds,
acc: Vec::with_capacity(HAPTICS_FRAME_SAMPLES * CAP_CHANNELS * 4),
front: Vec::new(),
}
}
/// Feed one capture chunk; `emit(kind, stereo_frame)` fires for each completed frame
/// (haptics first — it is the latency-critical pair).
fn feed(&mut self, chunk: &[f32], mut emit: impl FnMut(u8, &[f32])) {
self.acc.extend_from_slice(chunk);
let block_len = HAPTICS_FRAME_SAMPLES * CAP_CHANNELS;
while self.acc.len() >= block_len {
let block: Vec<f32> = self.acc.drain(..block_len).collect();
let (front, back) = split_quad(&block);
if self.kinds & KIND_BIT_HAPTICS != 0 {
emit(punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS, &back);
}
if self.kinds & KIND_BIT_SPEAKER != 0 {
self.front.extend_from_slice(&front);
let frame_len = SPEAKER_FRAME_SAMPLES * 2;
while self.front.len() >= frame_len {
let frame: Vec<f32> = self.front.drain(..frame_len).collect();
emit(punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER, &frame);
}
}
}
}
/// Drop the partial frames straddling a capture gap (reopen). The seq/gate state is NOT
/// here — [`LaneCtl`] deliberately survives reopens, so the client sees a gap, not a
/// restart.
fn clear(&mut self) {
self.acc.clear();
self.front.clear();
}
}
/// A running per-pad streamer. [`stop`](PadAudioHandle::stop) (or drop) flags the thread and
/// joins it; [`signal`](PadAudioHandle::signal) only flags — the input thread's teardown flags
/// every pad first so the joins overlap instead of serializing the capturer's worst-case ~5 s
/// quiet-endpoint recv timeout.
pub(super) struct PadAudioHandle {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
impl PadAudioHandle {
/// Flag the streamer to wind down without waiting for it.
pub(super) fn signal(&self) {
self.stop.store(true, Ordering::SeqCst);
}
/// Stop + reap. Bounded by the capturer's ~5 s quiet-endpoint recv timeout in the worst
/// case — the mid-session reap paths run this on a detached reaper thread for that reason
/// (`input.rs::PadAudioSlots::stop`); session teardown affords it inline (the 10 s
/// side-thread join grace covers it).
pub(super) fn stop(mut self) {
self.reap();
}
fn reap(&mut self) {
self.signal();
if let Some(join) = self.join.take() {
let _ = join.join();
}
}
}
/// A handle dropped without `stop()` (reaper-spawn failure) still winds its thread down.
impl Drop for PadAudioHandle {
fn drop(&mut self) {
self.reap();
}
}
/// Whether this session's Welcome should advertise
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
/// picks it up on its next connect.
pub(super) fn host_cap(client_caps: u8) -> bool {
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
#[cfg(target_os = "windows")]
{
// R5: a startup attempt that failed transiently leaves nothing latched, so retry here —
// this is the first moment in a session's life that anyone asks whether pad audio exists.
if asked {
crate::audio::pad_endpoint::ensure_provisioned();
}
asked
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
&& crate::audio::pad_endpoint::provisioned_endpoints()
.is_some_and(|eps| !eps.is_empty())
}
#[cfg(not(target_os = "windows"))]
{
// Only the Windows virtual DualSense exposes pad audio endpoints today.
let _ = asked;
false
}
}
/// Start the per-pad streamer toward `conn` for `pad`, streaming the kinds in `kinds` (bit 0 =
/// haptics, bit 1 = speaker — the arrival's audio-caps packing). `stop` is this handle's own
/// flag (fresh per spawn — pad streamers stop individually, not with the session). `None` when
/// the slot has no provisioned endpoint (provisioning failed or still running, or the slot is
/// past `PUNKTFUNK_PAD_AUDIO_SLOTS` — only 0..4 can ever have one) or the thread cannot spawn;
/// the pad itself keeps working either way, just without audio.
#[cfg(target_os = "windows")]
pub(super) fn spawn(
conn: quinn::Connection,
pad: u8,
kinds: u8,
stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
return None;
}
let Some(ep) = crate::audio::pad_endpoint::endpoint_for(pad) else {
tracing::debug!(
pad,
"pad-audio arrival for a slot without a provisioned endpoint — not streaming"
);
return None;
};
if ep.endpoint_id.is_empty() {
// The devnode-without-endpoint shape (`find`) — never in the provisioned set, but
// cheap to refuse rather than spin the open/backoff loop on an empty id.
return None;
}
if ep.needs_aeb_kick {
// R4: this flag was computed on every path and consulted nowhere past startup. It means
// the endpoint's stamps are STORED but not SERVED — the audio stack never picked up the
// DualSense identity — and startup's one restart did not fix it. Opening anyway is worse
// than refusing: `AUTOCONVERTPCM` makes a wrong-format endpoint initialize *successfully*,
// so the stream runs, the logs look healthy, and the haptics/speaker pair is mis-routed
// with nothing to point at. Decline, and say which reboot-shaped problem it is.
tracing::warn!(
pad,
endpoint = %ep.endpoint_id,
"pad endpoint stamps are stored but not served — the audio stack has not adopted the \
DualSense identity (a reboot, or a manual AudioEndpointBuilder+Audiosrv restart, \
clears it). Not streaming: the endpoint would open and mis-route."
);
return None;
}
let stop_t = stop.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
{
Ok(join) => Some(PadAudioHandle {
stop,
join: Some(join),
}),
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
None
}
}
}
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
/// without the audio side (and never advertise the cap, see [`host_cap`]).
#[cfg(not(target_os = "windows"))]
pub(super) fn spawn(
_conn: quinn::Connection,
_pad: u8,
_kinds: u8,
_stop: Arc<AtomicBool>,
) -> Option<PadAudioHandle> {
None
}
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
#[cfg(target_os = "windows")]
struct Lane {
kind: u8,
ctl: LaneCtl,
enc: opus::Encoder,
encode_errs: u64,
}
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
#[cfg(target_os = "windows")]
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
let mut lanes = Vec::new();
for (bit, kind, frame_ms) in [
(
KIND_BIT_HAPTICS,
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
HAPTICS_FRAME_MS,
),
(
KIND_BIT_SPEAKER,
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
SPEAKER_FRAME_MS,
),
] {
if kinds & bit == 0 {
continue;
}
let mut enc = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
enc.set_vbr(false).ok();
lanes.push(Lane {
kind,
ctl: LaneCtl::new(frame_ms),
enc,
encode_errs: 0,
});
}
Ok(lanes)
}
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
#[cfg(target_os = "windows")]
fn pad_audio_thread(
conn: quinn::Connection,
pad: u8,
kinds: u8,
endpoint_id: String,
stop: Arc<AtomicBool>,
) {
use crate::audio::AudioCapturer as _;
let mut lanes = match build_lanes(kinds) {
Ok(l) => l,
Err(e) => {
tracing::warn!(pad, error = %e, "pad-audio opus encoder init failed — pad continues without audio");
return;
}
};
if lanes.is_empty() {
return; // spawn() refuses kinds == 0 — belt and braces
}
let mut framer = PadFramer::new(kinds);
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
// plane's slack.
let mut opus_buf = vec![0u8; 1500];
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
let mut last_failed: Option<std::time::Instant> = None;
tracing::info!(
pad,
haptics = kinds & KIND_BIT_HAPTICS != 0,
speaker = kinds & KIND_BIT_SPEAKER != 0,
"pad audio streaming (0xD1, Opus 48 kHz, silence-gated)"
);
'session: while !stop.load(Ordering::SeqCst) {
if capturer.is_none() {
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
Ok(c) => {
if last_failed.take().is_some() {
tracing::info!(pad, "pad-audio capture reopened");
}
capturer = Some(c);
framer.clear(); // drop the partial frames straddling the gap
}
Err(e) => {
tracing::debug!(pad, error = %format!("{e:#}"), "pad-audio open failed — will retry");
last_failed = Some(std::time::Instant::now());
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
}
}
// An empty chunk is a QUIET endpoint (the capturer's idle timeout), not a death — keep
// it; only a genuine Err (capture thread ended) drops the capturer for reopen.
let chunk = match capturer.as_mut().unwrap().next_chunk() {
Ok(c) => c,
Err(e) => {
tracing::warn!(pad, error = %format!("{e:#}"), "pad-audio capture lost — reopening");
capturer = None;
last_failed = Some(std::time::Instant::now());
continue;
}
};
let mut session_gone = false;
framer.feed(&chunk, |kind, frame| {
if session_gone {
return;
}
let Some(lane) = lanes.iter_mut().find(|l| l.kind == kind) else {
return; // framer emits only enabled kinds — unreachable, but never panic here
};
// Gated = deliberate silence: no datagram AND a frozen seq (the client tells
// silence from loss by seq continuity).
let Some(seq) = lane.ctl.admit(frame) else {
return;
};
let pts_ns = now_ns();
match lane.enc.encode_float(frame, &mut opus_buf) {
Ok(n) => {
let d = punktfunk_core::quic::encode_pad_audio_datagram(
pad,
kind,
seq,
pts_ns,
&opus_buf[..n],
);
if conn.send_datagram(d.into()).is_err() {
session_gone = true; // connection gone — the session is over
}
}
Err(e) => {
lane.encode_errs += 1;
if lane.encode_errs.is_power_of_two() {
tracing::warn!(
pad,
kind,
error = %e,
count = lane.encode_errs,
"pad-audio opus encode failed — dropping frame"
);
}
}
}
});
if session_gone {
break 'session;
}
}
// Dropping the capturer stops its WASAPI thread. Nothing to park: pad capture is per-pad,
// per-session by design (unlike the session audio slot there is no cross-session reuse).
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
/// A stereo frame of `n` samples at a constant level.
fn frame(level: f32, n: usize) -> Vec<f32> {
vec![level; n * 2]
}
#[test]
fn gate_opens_immediately_and_closes_after_hangover() {
let mut g = SilenceGate::new(HAPTICS_FRAME_MS);
// 250 ms of 5 ms frames.
assert_eq!(g.hangover_frames, 50);
// Closed from birth: an idle pad never sends.
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// A peak at exactly the threshold opens on THIS frame (haptics are felt latency).
assert!(g.feed(&frame(GATE_OPEN_PEAK, HAPTICS_FRAME_SAMPLES)));
// 49 quiet frames ride the hangover; the 50th completes 250 ms and is suppressed.
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// ... and stays closed.
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
// Sub-threshold wiggle does not reopen; real signal does (negative peaks count).
assert!(!g.feed(&frame(9e-4, HAPTICS_FRAME_SAMPLES)));
assert!(g.feed(&frame(-0.5, HAPTICS_FRAME_SAMPLES)));
// A loud frame mid-hangover rearms the full 250 ms.
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(g.feed(&frame(0.02, HAPTICS_FRAME_SAMPLES)));
for _ in 0..49 {
assert!(g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, HAPTICS_FRAME_SAMPLES)));
}
#[test]
fn gate_hangover_scales_with_frame_ms() {
let mut g = SilenceGate::new(SPEAKER_FRAME_MS);
assert_eq!(g.hangover_frames, 25); // 250 ms of 10 ms frames
assert!(g.feed(&frame(0.1, SPEAKER_FRAME_SAMPLES)));
for _ in 0..24 {
assert!(g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
}
assert!(!g.feed(&frame(0.0, SPEAKER_FRAME_SAMPLES)));
}
#[test]
fn seq_freezes_while_gated_and_survives_reopen() {
let mut lane = LaneCtl::new(HAPTICS_FRAME_MS);
// Two audible frames: seq 0, 1.
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(0));
assert_eq!(lane.admit(&frame(0.5, HAPTICS_FRAME_SAMPLES)), Some(1));
// The hangover is still sent (seq advances), then the gate closes and seq FREEZES —
// deliberate silence the client tells from loss by continuity.
for i in 0..49u32 {
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), Some(2 + i));
}
for _ in 0..500 {
assert_eq!(lane.admit(&frame(0.0, HAPTICS_FRAME_SAMPLES)), None);
}
// A capture reopen resets ONLY the framer (PadFramer::clear) — LaneCtl is deliberately
// untouched, so the next audible frame CONTINUES the sequence (gap, not restart).
assert_eq!(lane.admit(&frame(0.9, HAPTICS_FRAME_SAMPLES)), Some(51));
}
#[test]
fn splitter_exact_pairs() {
// Interleave [FL FR BL BR] × 2 frames with distinct values everywhere.
let quad = [0.0, 1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0];
let (front, back) = split_quad(&quad);
assert_eq!(front, [0.0, 1.0, 10.0, 11.0]);
assert_eq!(back, [2.0, 3.0, 12.0, 13.0]);
// A ragged tail (never produced by the capturer) is dropped, not smeared.
let (front, back) = split_quad(&quad[..7]);
assert_eq!((front.len(), back.len()), (2, 2));
}
#[test]
fn framer_cuts_the_wire_cadence() {
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
let mut got: Vec<(u8, usize, f32)> = Vec::new();
// 10 ms of capture (480 samples), fed in ragged chunks: exactly two 5 ms haptics
// frames from the back pair, then one 10 ms speaker frame from the front pair.
let mut quad = Vec::new();
for _ in 0..2 * HAPTICS_FRAME_SAMPLES {
quad.extend_from_slice(&[0.25, 0.25, -0.5, -0.5]);
}
for chunk in quad.chunks(101) {
f.feed(chunk, |kind, frame| got.push((kind, frame.len(), frame[0])));
}
assert_eq!(
got,
vec![
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES, -0.5),
(PAD_AUDIO_KIND_SPEAKER, 2 * SPEAKER_FRAME_SAMPLES, 0.25),
]
);
}
#[test]
fn framer_masks_disabled_kinds() {
// 20 ms of all-ones capture: 4 potential haptics frames, 2 potential speaker frames.
let quad = vec![1.0f32; 4 * HAPTICS_FRAME_SAMPLES * CAP_CHANNELS];
let mut kinds_seen = Vec::new();
// Haptics-only: the front pair is never split out, let alone encoded.
let mut f = PadFramer::new(KIND_BIT_HAPTICS);
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_HAPTICS; 4]);
// Speaker-only: no haptics frames.
let mut f = PadFramer::new(KIND_BIT_SPEAKER);
kinds_seen.clear();
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert_eq!(kinds_seen, vec![PAD_AUDIO_KIND_SPEAKER; 2]);
// kinds = 0 is never spawned, but the framer must still be total: nothing comes out.
let mut f = PadFramer::new(0);
kinds_seen.clear();
f.feed(&quad, |kind, _| kinds_seen.push(kind));
assert!(kinds_seen.is_empty());
}
#[test]
fn framer_clear_drops_partials_only() {
let mut f = PadFramer::new(KIND_BIT_HAPTICS | KIND_BIT_SPEAKER);
let mut emitted = 0;
// 100 samples: no frame boundary reached yet.
f.feed(&vec![0.1; 100 * CAP_CHANNELS], |_, _| emitted += 1);
assert_eq!(emitted, 0);
f.clear();
// After the gap: exactly one haptics frame from 240 fresh samples — the 100 stale
// samples are gone (they would skew every later frame boundary).
f.feed(
&vec![0.2; HAPTICS_FRAME_SAMPLES * CAP_CHANNELS],
|kind, frame| {
emitted += 1;
assert_eq!(
(kind, frame.len()),
(PAD_AUDIO_KIND_HAPTICS, 2 * HAPTICS_FRAME_SAMPLES)
);
},
);
assert_eq!(emitted, 1);
}
#[test]
fn host_cap_requires_the_client_bit() {
// Without CLIENT_CAP_PAD_AUDIO the answer is no on EVERY platform (on Windows the
// env + provisioning legs are environment-dependent — not unit-tested here).
assert!(!host_cap(0));
assert!(!host_cap(punktfunk_core::quic::CLIENT_CAP_CURSOR));
}
}
+329 -145
View File
File diff suppressed because it is too large Load Diff