Compare commits

..
Author SHA1 Message Date
enricobuehler 0890cf3244 docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits
ci / docs-site (pull_request) Successful in 1m47s
ci / rust (pull_request) Successful in 7m49s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m16s
ci / rust-arm64 (pull_request) Successful in 2m13s
German-first Promotional Text, Descriptions, Keywords and App Review notes,
plus the app-specific privacy text the existing website policy is missing.

Every character-limited field is checked by check-limits.py, which also catches
headings whose stated count has drifted from the real length. Three things the
brief assumed turned out not to hold, and the copy says so rather than shipping
the claim: a Mac cannot act as a host, the published privacy policy covers only
the website, and the App Review notes field caps at 4000 characters.
2026-08-04 21:25:55 +02:00
enricobuehler 454fa2e0cb Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42
2026-08-04 18:12:40 +00:00
enricobuehler 9f1f23eb40 Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
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
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
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-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
Reviewed-on: #41
2026-08-04 18:06:41 +00:00
enricobuehler d1c4cb18dd test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 2m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m51s
ci / rust (pull_request) Successful in 8m33s
android / android (pull_request) Successful in 5m21s
PyroWave sessions are gated out of mid-session renegotiation, so a
constrained path serves them through the leg-1 SESSION-START clamp. This
pins the consistency that guarantee rests on: everything chunk-aligned
derives from the one Welcome::shard_payload number — the host
packetizes at it, the client's C-ABI parse window reads it back, and
partial delivery zero-fills exact windows of it — verified at the two
clamp shapes a constrained path actually produces (1216, the
WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire
with real loss.
2026-08-04 20:00:09 +02:00
enricobuehler 91aa684f0d Merge pull request 'docs: Android is on Google Play production, not a closed test track' (#40) from worktree-docs-play-production into main
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 37s
ci / web (push) Failing after 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
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 8s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 10m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
ci / rust-arm64 (push) Successful in 3m10s
docker / builders-arm64cross (push) Successful in 16s
Reviewed-on: #40
2026-08-04 17:53:23 +00:00
enricobuehler e629606e39 docs: Android is on Google Play production, not a closed test track
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 6m48s
Play production access landed 2026-08-01 and the listing is live, but the
docs still told Android users to beg for a tester invite on Discord and
warned that the Play link "only resolves once your account is on the
tester list". Both are now wrong, and the install page is the first thing
a new Android user reads.

Stable is a public Play listing. Canary is unchanged — it still goes to
the invite-only Internal testing track — so each page now draws that line
explicitly instead of describing both as test tracks.

Also corrects the release process: channels.md said CI "never
auto-publishes to the public stores" and that someone promotes alpha ->
production by hand. Since 43e3c7b6 a vX.Y.Z tag publishes to production
at 100% with no further click (android.yml resolves TRACK=production on
refs/tags/v*). Apple is still manual, so that half stands.

Touches install-client.md, clients.md, channels.md, support-matrix.md and
uninstall.md — the last one told people to ask on Discord to be removed
from a tester list that no longer gates the app.
2026-08-04 19:48:59 +02:00
enricobuehler ff5602361f fix(android/gamepad): TV wording points at the Controller-optimized UI toggle
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 1m37s
ci / rust (pull_request) Successful in 8m35s
android / android (pull_request) Successful in 5m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
'Created and edited in the touch interface' is dead advice on a TV box — no
touch to reach it with. Unlike tvOS the editor DOES exist on-device (same
APK), behind this screen's own Controller-optimized UI toggle, so on TV the
Profiles strings now name that route instead.
2026-08-04 19:47:20 +02:00
enricobuehler 5e319f3b77 Merge pull request 'fix(client/abr): the decode-cap latch fires on the knee's real presentations' (#36) from worktree-abr-decode-cap-latch into main
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 13m16s
apple / screenshots (push) Successful in 5m51s
flatpak / build-publish (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13m6s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m53s
deb / build-publish (push) Successful in 6m13s
arch / build-publish (push) Successful in 8m42s
ci / web (push) Successful in 1m3s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m9s
apple / swift (push) Successful in 1m24s
deb / build-publish-host (push) Successful in 7m20s
ci / docs-site (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m32s
release / apple (push) Successful in 8m58s
android / android (push) Successful in 5m50s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / rust (push) Canceled after 6m55s
windows-host / package (push) Successful in 12m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 27s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Canceled after 0s
Reviewed-on: #36
2026-08-04 17:46:27 +00:00
enricobuehler 34ad3cc611 feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict
Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0
per-frame geometry. The leg-1 watcher stops merely diagnosing the
constrained path and heals the CURRENT session; the same machinery,
inverted, takes a proven jumbo LAN up to ~8.9 KB shards.

- Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload
  u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by
  design: a shrink re-keys the packetizer at the next AU immediately
  after sending (per-frame pinning makes ordering irrelevant; the ack is
  telemetry), a grow emits nothing above the old size until the ack —
  the ack is the gate even though client buffers are statically sized.
- Client: one dispatch arm in the shared pump control task (all client
  families) — validate against the advertised receive bounds, ack;
  out-of-bounds requests get SILENCE, not an ack, so a buggy host can
  never read a granted grow out of garbage.
- Host driver: the wire_mtu watcher grows a ShardReneg arm — on a
  below-ceiling verdict it still records the learned budget (session 2
  starts right) and now also shrinks session 1 at the ~3-10 s verdict
  mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU
  > 1500 — one knob, derived) it sends the ack-gated grow after a
  settled-at-sealed-jumbo proof and then stays alive as the revert
  guard: quinn's blackhole detection lowering current_mtu shrinks the
  wire back through the same path. The QUIC MTUD probe ceiling rises
  from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a
  few extra failed probes toward non-jumbo peers, zero cost otherwise).
- Apply point: Session::set_shard_payload drained in the send loop next
  to the adaptive-FEC target, gated on no open streamed AU (a streamed
  frame's shard-aligned tiling derives from the size it began with).
- Renegotiation is gated OFF for PyroWave sessions: their clients parse
  chunk-aligned AUs in windows of the Welcome value pinned at session
  start (read once over the C ABI), so a mid-stream re-key would corrupt
  the parse — those sessions keep the leg-1 next-session clamp. This
  also settles the plan's open question on the two wire_chunk consumers:
  both are PyroWave-only, so the gate covers them entirely.
- Legacy peers are inert both ways: no Hello advertisement → the host
  never constructs the driver; an old host never sends the message.

core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the
regenerated header carries the new message ids (drift gate).
2026-08-04 19:42:36 +02:00
enricobuehler 857d7d7b6b feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings
GamepadSettingsScreen gains the trailing Profiles section (per-profile rows
with live pin counts, touch-interface explainer) and a console-styled
GamepadPinHostsDialog — controller- and TV-remote-navigable pin management
writing KnownHost.pinnedProfileIds through the existing store path. Pin-add
was previously touch-only; pinned-card rendering and unpin stay as they
were.
2026-08-04 19:41:04 +02:00
enricobuehler f3c0ee47d7 Merge pull request 'fix(client/windows): "Open log folder" stops opening Documents' (#31) from worktree-client-logs-folder-msix into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 28s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 1m21s
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
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
ci / web (push) Successful in 1m3s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / docs-site (push) Successful in 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
ci / rust-arm64 (push) Successful in 1m55s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Canceled after 5m21s
Reviewed-on: #31
2026-08-04 17:41:03 +00:00
enricobuehler 80b4eccff9 feat(apple/gamepad): Profiles section + pin picker in gamepad settings
GamepadSettingsView gains a trailing Profiles section (one row per catalog
profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker
driving HostStore.setPinned — the first pin management reachable from the
controller-first UI, and on tvOS the only possible one. tvOS wording drops
the 'create them in the standard interface' promise (no profile editor
exists there); other platforms keep it. Pinned-card rendering and the
connect path were already in from WP5 and stay untouched.
2026-08-04 19:41:03 +02:00
enricobuehler 63a4f583b9 feat(console): profiles reach the gamepad UI — pinned cards, pin management, settings section
The Skia console now renders a pinned profile card after its host's primary
tile (KnownHost::pinned_profiles resolved by the service thread), connects
with that profile as a one-off via the existing effective_settings resolver,
and shows the bound default profile on the primary tile. The settings screen
gains a trailing Profiles section — one row per catalog profile with a live
pin count — whose activation opens a pin-to-hosts screen; toggles ride the
new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the
same field the CLI resolves for Decky's host list). Profiles themselves stay
desktop-authored (design client-settings-profiles.md §5.2a, §5.4).
2026-08-04 19:40:53 +02:00
enricobuehler 290d760ea4 feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement
Phase 0 of mid-session shard-payload renegotiation (planning
design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All
three legs are client-side and forward-compatible: deployed clients that
carry them accept a mid-session shard change the moment a future host
sends one, and nothing changes on the wire until then.

- W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame
  pinning: a frame's first-arriving packet pins that frame's shard size
  (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later
  packets must match the pin, and the per-frame block ceiling derives
  from the pinned size (a session-level cap would reject legitimate
  post-shrink frames). The reorder race between an ordered control
  message and unordered video dies structurally: old-geometry frames in
  flight complete under their own pin while new frames arrive under the
  new one, and no cross-geometry splice can land in one buffer. The
  in-flight budget stays byte-based and exact.
- W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport
  RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams
  (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack:
  the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily
  allocated, hosts unaffected. Grep verdict: no embedder uses the
  constant directly, so no C ABI bump — the regenerated header rides
  along (drift gate).
- W0.3 — trailing Hello field max_shard_payload: u16 (0/absent =
  legacy), the append-with-placeholder discipline of video_caps/
  client_caps. One field is both the renegotiation capability flag and
  the jumbo ceiling; core's pump advertises it for all client families,
  the probe too.
- Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload
  (re-derives the block ceilings; construction delegates to it) +
  Session::set_shard_payload (host-only, Config::validate parity).

Verification (the 0.23.0 lesson — geometry changes breed sizing bugs):
the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple
sweep, lossy + reversed roundtrips, sentinel path, in-flight budget);
mid-stream shrink→grow→revert delivery; the old-geometry reorder race;
cross-geometry splice rejection; firewall bounds non-vacuous both ways;
a 48-case mixed-geometry reorder-torture proptest asserting per-frame
byte-identical DELIVERY and an exactly-zero final budget; and a sealed
loopback session test (continuous crypto/replay) delivering frames
across live re-keys — every test asserts delivered frames, never the
absence of errors.

core: 294/294 --features quic + clippy -D warnings (macOS), fmt.
2026-08-04 19:27:09 +02:00
enricobuehler 69f1db5ea9 Merge pull request 'feat(host/wire): MTU resilience for the video data plane' (#37) from worktree-wire-mtu-resilience into main
apple / swift (push) Successful in 1m30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m37s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m50s
windows-host / package (push) Successful in 16m8s
android / android (push) Successful in 5m39s
windows-host / winget-source (push) Skipped
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows-host / canary-manifest (push) Successful in 30s
deb / build-publish (push) Successful in 21m51s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
deb / build-publish-client-arm64 (push) Successful in 9m9s
ci / rust (push) Canceled after 27m31s
flatpak / build-publish (push) Successful in 8m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
release / apple (push) Successful in 9m14s
arch / build-publish (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 6m19s
apple / screenshots (push) Successful in 6m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
ci / web (push) Successful in 1m3s
deb / build-publish-host (push) Failing after 30s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / rust-arm64 (push) Successful in 2m41s
Reviewed-on: #37
2026-08-04 17:02:01 +00:00
enricobuehler 7331be0a40 Merge pull request 'fix(audio): the quality root cause, the latency ratchet, and making the plane observable' (#33) from worktree-audio-quality-latency into main
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m28s
ci / rust-arm64 (push) Successful in 2m31s
ci / docs-site (push) Successful in 1m19s
deb / build-publish-client-arm64 (push) Successful in 3m11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 6m0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
android / android (push) Successful in 7m13s
arch / build-publish (push) Successful in 8m1s
ci / rust (push) Successful in 10m0s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Canceled after 8m16s
docker / builders-arm64cross (push) Canceled after 9s
docker / deploy-docs (push) Canceled after 42s
release / apple (push) Successful in 9m17s
flatpak / build-publish (push) Canceled after 4m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m48s
windows-host / package (push) Canceled after 5m27s
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 / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #33
2026-08-04 16:51:34 +00:00
enricobuehler 4bc7eecf05 feat(host/wire): MTU resilience for the video data plane
android / android (pull_request) Successful in 3m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 22m23s
ci / docs-site (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m46s
Video datagrams are sealed at a shard payload sized for a clean 1500-byte
MTU (1472-byte UDP payloads). A host whose route to the client crosses a
smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered
NIC MTU) delivers every small flow — QUIC control, hole punch, input,
audio — while 100% of video datagrams die: the client sits on a black
screen reporting zero loss and the host streams into the void with every
gauge green. Field-reported as 'connects fine, black screen forever'.

Three legs, none of which changes a session on a healthy path:

- PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a
  given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is
  already negotiated per session (the v4/v6 split ships two values
  today) and every client follows the negotiated value.
- Detection: the QUIC MTU-discovery probe ceiling moves from quinn's
  stock 1452 to exactly the sealed video-datagram size (1472), so a
  control connection's settled MTU becomes a verdict on the path:
  settled at the ceiling proves it carries video, settled below proves
  it cannot. A per-session watcher samples after the search has settled
  (live-connection guard against mid-search false learns) and logs an
  actionable WARN naming the failure shape and the diagnosis commands.
- Healing: the measured budget is recorded per peer IP; the next
  handshake clamps shard_payload to fit, so a reconnect self-heals. A
  later session that reaches the ceiling erases the record.

Verified: core 286/286 --features quic + clippy -D warnings (macOS);
host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container).
The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant.
2026-08-04 18:30:33 +02:00
enricobuehler dbc12dedcc Merge pull request 'fix(client/ios): a click wins the pointer back after Escape drops it' (#34) from worktree-ipad-click-relock into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
ci / web (push) Successful in 58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 1m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
ci / docs-site (push) Successful in 1m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 1m41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 25s
docker / deploy-docs (push) Successful in 40s
apple / screenshots (push) Successful in 6m7s
release / apple (push) Successful in 11m50s
ci / rust (push) Successful in 26m18s
Reviewed-on: #34
2026-08-04 16:20:47 +00:00
enricobuehlerandClaude Opus 5 2dfb7791a2 fix(apple): the drift test tripped Swift's static exclusivity check
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m44s
ci / rust-arm64 (pull_request) Successful in 2m15s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m3s
android / android (pull_request) Successful in 4m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m17s
ci / rust (pull_request) Successful in 10m50s
CI caught what my local harness could not: reading `huge.count` inside the closure that already
holds `huge` exclusively is an exclusivity violation, so PunktfunkKitTests failed to compile.

The blind spot is worth recording. I verified `AudioRing` by compiling it against a standalone
harness whose bodies were TOP-LEVEL code, where Swift applies DYNAMIC exclusivity — the same
statement in a function body gets the static check and is a hard error. A harness that does not
share the shape of the thing it stands in for can be green for a reason the real build does not
have. The harness now puts every body in a method and compiles with
`-enforce-exclusivity=checked`.

Length now comes off the buffer pointer (`$0.count`), which is what the closure already owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:14:24 +02:00
enricobuehler 5e19a4611f fix(client/abr): the decode-cap latch fires on the knee's real presentations
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Successful in 10m22s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m10s
The client-decoder knee latch (decode_cap_kbps) was unreachable in
production — zero "decode cap learned" lines across every field log, while
its own doc named the exact sawtooth it exists to end (the 2026-08-03
1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs,
no latch):

- The ordinary two-bad-window backoff — the knee's most common
  presentation, a standing 15–45 ms decode rise below the severe tier —
  carried no decode evidence at decision time, because evidence was judged
  from the deciding window alone. Worse, the backoff the decode signal
  itself caused then RESET the knee streak. Now the streak carries its own
  attribution (streak_decode_windows): a backoff whose bad windows were
  all decode-flagged is decode evidence.

- A cascade's second backoff can never agree with the first: a live host
  acks the ×0.7 request in ~100 ms, so the second sample always sits at
  the reduced rate — outside the ±1/8 similarity band by construction
  (0.7 < 7/8). The canonical test never acked between its backoffs, which
  is how the premise survived. Now a backoff only samples a rate the
  controller climbed back to (climb_since_backoff, armed by any ack that
  raises the rate); a drain-time backoff neither latches nor erases the
  reference the real knee set.

- A keyframe-ask storm on a clean link (the Steam Deck presentation: the
  overdriven decoder wedges and begs instead of queueing — 14–19 asks at
  ~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too;
  with real loss present the asks stay network-attributed.

The reworked tests model the ack round-trip (choke → ack → re-climb →
choke), including a regression test replaying the field trace's rates and
decode figures, which must latch at its second knee encounter.
2026-08-04 18:08:49 +02:00
enricobuehler 6f54fcdd2d fix(client/ios): a click wins the pointer back after Escape drops it
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / rust (pull_request) Successful in 13m38s
Pressing Escape mid-stream on an iPad leaves the capture in a state it
could never leave: iPadOS releases the pointer lock by itself, a bare
Escape deliberately never clears `captured` (it is a game key), and the
re-lock burst added with the Escape-drop fix is the only thing that ever
asks for the lock back. That burst fires in the 0.6 s immediately after
the platform's own "let me out" gesture — precisely when it is least
likely to be granted — and once its budget is spent nothing re-asks:
`setCaptured` is the only other requester, and `captured` never went
false. The capture then spends the rest of its life on the absolute
pointer path, which is why the field report reads the way it does —
clicks still land exactly where you aim, because absolute positions keep
forwarding, but the game receives no relative deltas and camera look is
dead for the rest of the session.

Make the click the second stage of the recovery. A click into the video
while captured-but-unlocked now re-anchors the lock chain and re-asks,
which is the request the platform actually wants: a genuine user
gesture rather than an app grabbing the pointer straight back.

Asked on the button UP, so the click has fully forwarded on one
transport first — asking on the DOWN can flip `gcMouseForwarding`
mid-click and strand the release on the GCMouse path. Gated on
`pointerLockWasEngaged`, exactly as the drop path is, so a scene that
never qualifies (Stage Manager, Split View) is never bursted at, and on
no burst already being in flight, since a pending burst mutes absolute
motion and re-arming one per click would freeze the cursor between
clicks of a menu the user is still aiming around.

Worst case is now today's behaviour rather than a permanent one: a
refused burst settles, and the next click tries again.

Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet
verified on glass — the premise that a click-driven re-request is
honoured is exactly what the previous fix got wrong.
2026-08-04 18:07:07 +02:00
enricobuehlerandClaude Opus 5 c6597cbeb5 docs(troubleshooting): the audio quality knobs are a request, not a guarantee
android / android (pull_request) Failing after 2s
apple / swift (pull_request) Failing after 1m13s
ci / docs-site (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m20s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m59s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m26s
ci / rust (pull_request) Canceled after 10m32s
The page claimed "audio is a fraction of a percent of a stream's bandwidth, so high costs
nothing worth counting". At 256 kbps plus redundancy that is 512 kbps — true of a 20 Mbps
session, wrong by an order of magnitude on a 5 Mbps one, which is why the budget now exists.
Says what actually happens on a narrow link, and points at the log line that reports the
settled tier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:39 +02:00
enricobuehlerandClaude Opus 5 2cfc82e96c fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md.

**The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were
added separately, each costed as "~1 % of the video budget", and nobody added them together:
256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio
rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a
constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed
the rest.

`plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved
video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio,
since the field report was about quality and redundancy only pays under loss, so `High` alone
outranks `Standard`+redundancy even though they cost the same. It can lower what the operator
asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is
worse than one spending a few percent more.

**The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` —
silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a
click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had
not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus
per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as
the Windows one was on 2026-08-03.

**Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread
now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the
numbers in a line describe the same instant.

Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that
changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have
been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be
reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid
it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does
not do; recorded as an open gap rather than implied to be covered.

Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still
described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:`
parameter, dead since the depth moved into the ring, is gone.

Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced
clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on
Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple
ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails
under qemu — the recorded environmental flake, unrelated to audio, green on the earlier
less-loaded run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:17 +02:00
enricobuehlerandClaude Opus 5 e9a209ef61 docs(troubleshooting): why streamed audio can sound worse than the host, and the knobs
WP0.4. The 2026-08-03 reporter had no way to know their desktop mix was being routed through
Steam's voice-carrier endpoint, and no documented way to change it — `PUNKTFUNK_HOST_AUDIO`
existed only in a module doc comment.

Two new sections: what the host actually captures (a render endpoint, not "the sound card"),
what the new `engine_hz/engine_ch/engine_bits` log line tells you, and the
`PUNKTFUNK_AUDIO_OUTPUT_MODE` / `_QUALITY` / `_REDUNDANCY` knobs — with host_and_client called
out as the quickest A/B for the endpoint question; and why audio that lags the picture should
now correct itself, plus what to check when it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:42:06 +02:00
enricobuehlerandClaude Opus 5 a12f1f092c feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery
Phase 4 + WP3.2 of design/audio-quality-and-latency.md.

**The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none
walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain
host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until
an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard
cap and stayed there for the rest of the session; that is the "audio latency is too high"
report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip".

All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than
device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms
one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed
audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still
carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted
crackle, where one transient drain manufactured a whole target's worth of silence.

Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually
underrun, instead of every device pre-paying for the worst one. The Windows ring moves from
raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all.

Apple is the one client where the policy is hand-written in a second language, so it gets
its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift`
standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero
silent callbacks, where the old ring would have ridden its 80 ms high-water mark.

**WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into
the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete
stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops
seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason
core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to.

Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core,
pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests);
punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a
planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all
are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on
macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:28:01 +02:00
enricobuehlerandClaude Opus 5 3055e29ebb feat(host/audio): make audio observable, fix the endpoint choice, raise the encode quality
Phases 0-3 of design/audio-quality-and-latency.md, host side.

**WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's
Streaming *Microphone* render endpoint over real hardware unconditionally, because it is
silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked
whether it could carry music: on the reporter's box it won all 31 loopback opens across 25
sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went
through it before reaching Opus. A silent sink now has to EARN its preference — if its mix
format narrows the mix it drops below real hardware. It is still taken when nothing better
exists (narrow audio beats no audio), but flagged so the capture side says why.

`plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests
still run on every platform. An unknown format counts as fine, which is asserted:
`unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the
plan worse than it was before formats existed.

**WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz
f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever
the endpoint really runs. That is why a 3,600-line log filed over an audio-quality
complaint contained nothing that could diagnose it.

**WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`:
a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing
recorded it — a click plus a permanent shift of everything after. Now counted and warned,
alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we
are damaging ourselves stop looking identical.

**WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the
planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old
full reopen tore the capture down for nothing. The field log shows the cost: something
re-set the default every ~4 s and each round was a teardown, a wiring pass with
IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a
2 s error backoff. Now: put the default back, keep the stream, and after four rounds in
twenty seconds concede for a minute and say so once.

**WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with
GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default:
stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately
untouched — its FEC really does need fixed-size packets.

**WP3.1 — redundant `0xD2` plane**, sent when the client asked for it.

**WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client`
/ `follow_default`), superseding the two undocumented env vars, which stay honoured. The
enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays
in core where the codec knowledge is.

`capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field
behaviour, so their tests must run on Linux CI, not only on a Windows box. That split
immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being
divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by
sqrt(channels) and made a sine report an RMS equal to its own peak.

WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared
mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a
no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked
endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM
on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS
line above.

Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio
test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26
wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and
audio_control.rs are NOT compile-verified anywhere yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:02:12 +02:00
enricobuehlerandClaude Opus 5 7077b0a0df feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane
Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md).
All three pieces are pure and unit-tested here so the four client rings and the Windows
host glue that follow stay thin.

**Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard`
value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is
provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are
much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what
~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a
host-side encoder knob — libopus reads the bitrate out of the packet, so no client
change and no negotiation.

**`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share.
Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane
15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and
clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency
permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA
that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade.
Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe
in a realtime callback) and deterministic under test.

`every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than
decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets,
so the ring was trimmed back before the average could ever reach the shed point: drift
correction was dead code and the ratchet test passed for the wrong reason (the hard cap
did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived
from `headroom_ms` so it cannot invert again.

**`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous
one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC
cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only
(RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency —
the copy rides the successor, which arrives inside de-jitter slack that already exists.
Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session
keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program.

cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C
macros are the same hazard the BTN_* renames already document — a clashing #define takes
the last definition silently rather than failing to compile.

Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a
pristine tree — this Mac has no system libopus for the C harness link.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:11:35 +02:00
enricobuehler e5453aebb7 fix(client/windows): "Open log folder" stops opening Documents
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m55s
ci / rust (pull_request) Successful in 7m46s
ci / web (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 1m36s
The button shipped in d839f4c2 opens the user's Documents folder instead of the log
directory on every packaged install. Nothing is wrong with the button — the path is.

The client ships as a full-trust MSIX package, and Windows redirects a packaged app's
%LOCALAPPDATA% writes into its private ...\Packages\<family>\LocalCache\Local\. The log
module creates and appends through that redirection without ever seeing it, so the
literal %LOCALAPPDATA%\punktfunk\logs it hands out is right to WRITE to and names a
directory that never exists on disk. Explorer runs outside the container: it resolves the
literal path, finds nothing, and — instead of failing — silently falls back to Documents.
An unpackaged dev run creates that directory for real, which is why this only ever showed
up in the field.

Two more places handed the same phantom path straight to the user, both added by the same
commit and both wrong in the same way: the "client log file" startup line, and the
failed-spawn banner's "Check <path>" — the one people are told to follow after a session
dies. Anyone who did landed in an empty or absent directory.

So the fix is one resolver, not three call-site patches. `real_dir` canonicalizes the
directory it just created, which resolves through the redirection on a packaged run and
changes nothing on an unpackaged one — no package identity to detect, no LocalCache path
to hand-assemble. `log_dir` stays as the write path and goes private so a future caller
can't reach for the wrong one; `path` now resolves too, which fixes both messages.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:39:00 +02:00
enricobuehler 1feeff3ca6 Merge pull request 'fix(abr): eleven defects from a sweep of the Automatic-bitrate path' (#28) from fix/abr-sweep into main
apple / swift (push) Successful in 1m19s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 2m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
android / android (push) Successful in 5m58s
deb / build-publish (push) Successful in 5m52s
docker / builders-arm64cross (push) Successful in 13s
deb / build-publish-host (push) Successful in 5m5s
docker / deploy-docs (push) Successful in 36s
apple / screenshots (push) Successful in 6m14s
arch / build-publish (push) Successful in 8m31s
ci / rust (push) Successful in 8m36s
flatpak / build-publish (push) Successful in 7m27s
windows-host / package (push) Successful in 13m53s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m31s
windows-host / canary-manifest (push) Successful in 35s
release / apple (push) Successful in 11m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
Reviewed-on: #28
2026-08-03 17:33:28 +00:00
enricobuehler 1ae8b4d4ca fix(client/abr): let the ceiling follow a host-initiated re-target
apple / swift (pull_request) Successful in 1m15s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m8s
ci / web (pull_request) Successful in 2m1s
ci / rust-arm64 (pull_request) Successful in 2m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m14s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m44s
ci / rust (pull_request) Successful in 9m9s
Interaction between two fixes in this series. The host now tells the client when
a rebuild re-resolves an Automatic rate, and that rate can legitimately sit ABOVE
the client's climb ceiling — the ceiling is the negotiated start rate until the
capacity probe raises it, while the host's re-resolve answers "what do these
pixels actually need" (a 1080p session mirroring a 4K panel resolves ~3× higher).

Left alone, the client would learn the new rate, notice it was above a stale
ceiling, and step the host straight back down off the rate it had just chosen
for itself. So an ack raises the ceiling to meet it. `set_ceiling` only ever
raises and still clamps to PUNKTFUNK_ABR_MAX_MBPS, which is the one limit that
should bind here. No effect on ordinary acks: a climb is never requested above
the effective ceiling to begin with.
2026-08-03 18:53:02 +02:00
enricobuehler 33ecd8e1a5 fix(client/abr): a granted climb disproves the learned cap
Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the
client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so
even a host that had fully recovered still granted the session its real ceiling
one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps
link. The crawl was never the point; re-learning was.

A request granted IN FULL at or above the cap is the host's own word that the
limit is gone. Drop the cap outright at that point instead of nudging it. A
standing limit is unaffected — it answers the same re-probe with another short
ack, which re-latches it and doubles its clock, exactly as before.

Adds the end-to-end regression the sweep was really about: a session pinned at
20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling,
now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes.
2026-08-03 18:53:02 +02:00
enricobuehler 48565c4e9e fix(host/abr): stop pinning Automatic sessions, and tell the client when the rate moves
Two host-side halves of the same sweep.

**The cadence latch.** `cadence_degraded` — which makes the control task refuse
bitrate CLIMBS — was latched true for as long as the session was escalated
(adaptive capture depth or pipelined retrieve), independently of whether encode
was still missing deadlines. The client cannot tell that refusal apart from an
encoder's real ceiling: both arrive as a short `BitrateChanged`, and two
identical ones latch a cap. Escalation needs ~20 net behind-frames, which a
startup hitch supplies while the ABR is still in slow start at the 20 Mbps
default — so one transient pinned the whole session there, long after the
escalation had bought back the headroom it was for, and escaping cost +12.5 %
per 60 s. An escalated session is still judged strictly (ANY net behind-frame
keeps it flagged, where an unescalated one gets the full bucket), but being
escalated no longer flags it by itself: escalating exists so cadence CAN be
held, and once it is, refusing climbs refuses the thing that worked. The rule
moves into `encode_behind_cadence` so it is stateable and testable.

**The silent re-target.** `adopt_built_bitrate` publishes the rate a rebuilt
pipeline actually opened at — `build_pipeline` re-resolves an Automatic rate
whenever the source delivers a size the session did not negotiate, the
mirrored-panel case — and the encoder's own clamp can land below what the
control task already acked. Neither reached the client, whose controller keeps
its own copy of that number as its climb base. A 1080p client mirroring a 4K
panel therefore believed 20 Mbps while the host encoded 60, and its first climb
computed from the stale base asked for 40: a re-target DOWNWARD, paying an
encoder rebuild to get there. Both paths now push the applied rate to the
control task, which sends `BitrateChanged` — the existing 9-byte message, which
already means precisely this and which clients already handle arriving
unprompted. No wire-format change, no capability negotiation, old clients
unaffected.

2 host tests added.
2026-08-03 18:53:02 +02:00
enricobuehler e9a7373c76 fix(client/abr): measure delivered throughput in media bytes, not wire bytes
The controller's two throughput-driven gates both compare "what the pipeline
carried" against the ENCODER's target: the utilization gate asks whether a clean
window actually tested that target (a calm menu proves nothing), and the
never-decaying proven mark bounds how far every later climb may step.

Both were fed `bytes_received`, which counts every accepted datagram — headers,
FEC parity, probe filler, audio. So the figure rose with the redundancy the host
adds in ANSWER to loss: at 25 % FEC the gate passed with the encoder emitting
~55 % of target, and the proven mark inherited the same inflation permanently.
The signal was weakest exactly on the lossy links it exists for.

Count data-shard payload separately at the reassembler's routing decision — the
same place, and for the same reason, the probe counters are already stamped —
and feed the ABR that. First time both gates are dimensionally honest: a media
rate compared against a media target.
2026-08-03 18:53:02 +02:00
enricobuehler f7a8c2013d fix(client/abr): the controller stops learning the wrong lessons from one window
Six defects found by a sweep of the Automatic-bitrate path, all of them the same
shape: a single window, or a single refusal, taught the controller something it
then treated as permanent.

- Rolling baselines (OWD, client decode, host encode) armed off ONE sample. The
  baseline is a rolling minimum, so one window IS the floor — and `on_ack`
  deliberately clears the encode baseline after every decrease we ourselves
  asked for, re-opening that hole each time. A calm re-seed window followed by
  ordinary motion read as 4 ms of "congestion", backed off, cleared again, and
  ratcheted toward the floor on a link that was never the problem. All three now
  need BASELINE_MIN_WINDOWS of evidence before they may fire, via one shared
  `score_baseline` (the three copies had already drifted apart).

- A mode switch rebased only the encode baseline. Decode and OWD are just as
  mode-scoped: 4K120 decodes slower and puts bigger frames on the wire than
  1080p60, so the old floor was one the new mode cleared on its first window —
  ~30 s of every window scoring bad, i.e. a backoff every other window. A switch
  UP in mode cratered the rate instead of raising it. `proven_kbps` goes with
  them; throughput the old mode's decoder digested is not evidence about this one.

- `proven_kbps` — never decayed, and permanent authority over how far every
  later climb may step — was raised by any window without a decode rise,
  including ones scored SEVERE. The windows that overstate delivered throughput
  are exactly the damaged ones: a stall's backlog draining at once, a flush's
  queue, the FEC surge answering a loss burst. Now only clean windows raise it.

- A learned cap escaped at +12.5 % per ~60 s. The host cannot distinguish a
  durable encoder ceiling from a climb refused while it is transiently behind
  cadence, and the latter routinely latches during slow start at the 20 Mbps
  default — from which crossing the gap to a probe-measured ceiling took upwards
  of twenty minutes. Re-probe after 12 s instead, doubling the interval each time
  the lift is immediately re-learned: a transient is out in one interval, a real
  ceiling settles into a slow poll.

- The decode cap latched AT the rate that choked, authorizing a climb straight
  back into the failure, and a bare jump-to-live flush could teach a "decoder
  knee" from what was a network event. It now latches just under the choke rate
  (inside the ±1/8 band the evidence already required) and only credits a flush
  where the decode signal is absent and cannot speak for itself.

- PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, not the negotiated
  start rate — so the one knob an Automatic session gives the operator did
  nothing when the session already started above it. It now binds at
  construction, and a session sitting above its ceiling steps down to it (no
  congestion signal will ever find that: the link is fine, the cap is policy).

Also: a SetBitrate dropped by a full control queue counted toward MAX_UNACKED,
so three of them retired the controller for the session while logging that an
"older host" was at fault. The pump now tells the controller what happened.

Wire format and ABI untouched. 34 abr tests green (3 new).
2026-08-03 18:53:02 +02:00
enricobuehler 926e2ccbdd Merge pull request 'feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar' (#24) from worktree-decky-stats-overlay-toggle into main
ci / web (push) Successful in 1m7s
ci / rust-arm64 (push) Successful in 2m12s
decky / build-publish (push) Successful in 30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / docs-site (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m26s
docker / deploy-docs (push) Failing after 6m7s
ci / rust (push) Successful in 8m27s
Reviewed-on: #24
2026-08-03 16:44:55 +00:00
enricobuehler b8b38d082e Merge pull request 'fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner' (#27) from worktree-plugin-logs-and-vh-fixes into main
ci / rust (push) Canceled after 23s
ci / rust-arm64 (push) Canceled after 24s
ci / web (push) Canceled after 24s
ci / docs-site (push) Canceled after 24s
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
apple / swift (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m12s
deb / build-publish (push) Successful in 3m52s
deb / build-publish-host (push) Successful in 4m45s
android / android (push) Successful in 5m24s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m4s
windows-host / package (push) Successful in 17m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m42s
Reviewed-on: #27
2026-08-03 16:44:31 +00:00
enricobuehler 9979489b56 fix(host/pads): an unplugged controller actually disappears
ci / web (pull_request) Successful in 1m11s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m24s
ci / rust-arm64 (pull_request) Successful in 1m35s
android / android (pull_request) Successful in 3m11s
ci / rust (pull_request) Successful in 6m3s
Unplug a controller mid-session and the virtual pad it was driving outlives
it: the game keeps seeing a connected, permanently idle device for the rest of
the session. The single-controller session — the common case — hits this every
time.

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

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

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

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

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

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

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

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

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

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

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00
enricobuehlerandClaude Opus 5 db1faef9fb docs(plugins): don't name a release that doesn't exist yet
ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m45s
ci / rust-arm64 (pull_request) Successful in 2m39s
android / android (pull_request) Successful in 3m16s
ci / rust (pull_request) Successful in 7m58s
The /tmp troubleshooting note said PrivateTmp=yes shipped "until 0.23.1".
0.23.0 is the latest tag and the next number isn't decided, so that could be
wrong on arrival. "In earlier releases" is true whichever number it gets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:00:48 +02:00
enricobuehlerandClaude Opus 5 442ea12b96 test(mgmt): pin the two things that would silently break plugin logging
The runner holds the PLUGIN token and nothing else — on Windows its LocalService
principal cannot read the admin one at all. `plugin_may_access` is an exclusion
list, so `/plugins/logs` is reachable today only because it happens not to match
`/ui-credential`. If that ever changed, plugin logs would go quiet in the console
with no other symptom and no failing test. Now asserted on that lane directly.

The second test covers ingest end to end through `GET /logs`: the `plugin:` target
prefix the console's Host/Plugins filter keys on, the level coercion (an unranked
level would sort as 0 and hide under every filter setting), a sourceless line
being attributed to the runner rather than to nothing, the caller's timestamp
surviving the trip, and an oversized batch being refused whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:56:25 +02:00
enricobuehlerandClaude Opus 5 1e56705b86 fix(sdk/log-ship): a busy plugin's lines survive a POST, and the shutdown tail is actually sent
Two bugs in the log shipper, both found by re-reading it rather than by a
failing test, and both of the kind where the symptom is a missing log line —
which is the one failure a logging path must not have.

The recursion guard was held across the whole `await fetch`, and `enqueue`
checked it. So every line logged while a POST was open was dropped, silently.
That window is milliseconds when the host is healthy and much longer when it is
not, and the lines lost are whatever a busy plugin happened to be saying — so
the shipper was least reliable exactly when it was most needed. The flag now
guards flush re-entry only (the interval can fire while a slow POST is still
open, and two concurrent flushes would splice disjoint batches out of one queue
and deliver them out of order). Nothing on the shipping path logs, so the
recursion it was guarding cannot form; that is now a stated rule at the top of
the file rather than a flag that costs real lines.

An explicit `flush()` hit that same re-entry guard and returned having sent
nothing. That is the shutdown path: the runner flushes once more after its
units' finalizers have run, and those last lines are the ones that say whether
the shutdown was clean. It now waits for an in-flight flush before starting its
own.

Both are covered by tests that fail against the previous code. The first needed
a server that signals when it has the request — logging merely "after calling
flush()" passes against the bug, because flush yields at its own awaits long
before the fetch starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:53:29 +02:00
enricobuehlerandClaude Opus 5 365caa23be fix(plugins): plugin output reaches the console's log page, and /tmp is no longer hidden from the runner
A user could not get the VirtualHere plugin to use their VirtualHere client
and asked, reasonably, where the logs were. There was no good answer, and the
reason they were stuck turned out to be ours.

**The runner could not see /tmp.** `punktfunk-scripting.service` set
PrivateTmp=yes, which hands the unit a private tmpfs. But integrating with
things already running on the box is the entire job of a plugin, and on Linux
those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
/tmp/vhclient_response, X11 is /tmp/.X11-unix. So the plugin launched the vendor
binary happily and could then never reach the daemon behind it — while the same
command worked perfectly in the operator's own shell, because that shell has the
real /tmp. No config change could fix it, which is exactly the loop the report
described. PrivateTmp is now off, with /tmp added to ReadWritePaths (which
ProtectSystem=strict would otherwise make read-only).

**Plugin logs now land in the console.** Plugins are not host child processes —
the runner is a separate bun process that import()s each plugin in-process — so
nothing they print passed through the host's tracing, and the console's Logs
page could not show a single plugin line. The fallback was journalctl on Linux;
on Windows the runner's scheduled task writes no log file at all, so a failing
plugin was diagnosable only by stopping the task and re-running the runner by
hand. Both mean shell access on the host box, which is what the console exists
to avoid — and it left the one question a stuck user asks with no answer.

So the runner now tees its output to POST /api/v1/plugins/logs, and those lines
join the host's own ring under one cursor, targeted plugin:<name>. The console
grows a Host/Plugins switch beside the level filter; an empty Plugins view says
the thing that is actually usually wrong (the runner isn't running) rather than
"adjust the filter".

The shipper keeps stdout authoritative — journald and foreground output are
unchanged whatever the host is doing — and is built so that logging can never
hurt the thing being logged: it never throws into a caller, holds a bounded
queue that drops oldest and then says how many, backs off when the host is away
(a restart is normal), and re-sends a batch the host failed to take. Lines
logged while a POST is in flight are kept, which cost one round to get right:
the first version held its recursion guard across the await and silently dropped
exactly the lines a busy plugin produces.

Runner lines that report a failure (a refused unit file, a crashed plugin, a
give-up) now go out at warn/error instead of all arriving as INFO, so the
console's level filter means something for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:47:51 +02:00
enricobuehlerandClaude Opus 5 f71bee917b Merge origin/main; the presenter rebuild's four settings join the sidebar
ci / web (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / docs-site (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m1s
`#20` landed while this branch was open and added four settings the
console screen groups under a new "Presentation" header: Prioritize,
Smoothness buffer, V-Sync and Follow variable refresh. A branch whose
whole claim is "everything the store holds is reachable" cannot merge
past those, so they get a Presentation page of their own, in the console
screen's position (after Video, before Audio) and with its wording.
Smoothness buffer is indented under Prioritize and disabled until the
intent is Smoothness — the same relationship the console's `enabled`
gate draws.

The docs conflict resolves to main's side plus this branch's correction:
the 4:4:4 advertisement claim main rewrote is the current one and stays,
while "Android, Decky and the console home don't offer it" was wrong
about two of the three before this branch and about all three after it.
The four new settings' paragraphs pick up the console home and Decky the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:40:05 +02:00
enricobuehlerandClaude Opus 5 6de78213ee feat(decky): the settings tab covers the whole store, as a SteamOS-style sidebar
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m31s
ci / rust (pull_request) Canceled after 3m0s
The stats overlay was the visible half of a general problem: nine of the
client's settings had a row here and twenty didn't, so a Deck that never
sees a desktop could not reach its own decoder, chroma, HDR, audio
layout, echo cancellation, touch or mouse model, scroll direction,
auto-wake, or either audio endpoint. Everything the store holds is here
now — except the two things a plugin backend genuinely cannot answer,
named in `backend.ts` so the next reader doesn't go looking: which
physical pad is player 1 (SDL's live device list lives in the client
process, and no CLI enumerates it) and the session's remembered window
size, which is not a preference.

Thirty rows is too many to scroll past on a thumbstick, so they are
split across a `SidebarNavigation` — the left-rail-of-categories layout
SteamOS's own Settings uses, and the one Deck users already know. Every
page fits on screen without scrolling, which is the point: the rail is
the index, so nothing is more than one hop away. The categories, their
order and the wording of the rows are the console settings screen's — it
is the other settings editor reachable without leaving Gaming Mode, and
two different orders for one store is how people stop trusting either.
It shows them as one steppable list because it has no pointer and no
room for a rail; here they become the rail's pages. The six pages take
one shared settings object rather than each holding state, so a change
on one is visible on the others the moment you switch.

Three more rules:

- A dependent setting is INDENTED under what it depends on and DISABLED,
  never hidden: mic device and echo cancellation under the microphone,
  controller type under forwarding. The console dims those rows for the
  same reason, and a row that vanishes as you toggle the one above it is
  a moving target for a thumbstick. The device row at the foot of Audio
  is rendered even while it reads, for that reason.
- A picker with nothing to pick doesn't appear: the GPU row shows up
  only where the enumeration found more than one adapter, so it is
  absent on a Deck and present on a Bazzite desktop with a dGPU.
- A setting that behaves differently HERE says so in its own
  description rather than being dropped. Capture system shortcuts holds
  nothing back under gamescope; fullscreen-on-stream can't lose to a
  launch that always passes `--fullscreen`; the client's library toggle
  isn't this plugin's browser. Each says which.

The device pickers are real, not stubs: `list_devices` reads
`--list-adapters` and `--list-audio` off the SESSION binary, the same
two enumerations the GTK shell shells out for because it links no Vulkan
itself. It is cached for the life of the backend (that call inits Vulkan
and PipeWire) with an explicit Refresh for the headset you just plugged
in, and a failure — a client too old to ship the session binary — leaves
the pickers on Automatic and says so instead of claiming you have no
devices. `_parse_audio_endpoints` is split out and unit-tested with the
malformed lines that must never reach a picker.

Two smaller honesty fixes fall out of building it. A Dropdown can only
display a value that is one of its options, and this store has four
other writers — so a stored value the table doesn't list is carried as
its own entry rather than rendering blank or, worse, showing a different
value than the stream will use. And a stored audio endpoint that isn't
currently connected keeps a "(not connected)" entry, the way the Linux
picker keeps "(not detected)", instead of silently re-pointing the next
stream at the default.

The Settings tab's wrapper deliberately stops being a scroll area: a
SidebarNavigation given an indefinite height to fill collapses its rail,
so the pane hands it the full height and keeps its hands off the
overflow, and the footer inset moves inside the pages.

The docs claimed nine things about this plugin that are no longer true,
and three about the console home that stopped being true when its own
row set grew on 2026-07-31 (4:4:4, echo cancellation, auto-wake and the
library toggle are all there in `screens/settings.rs`). Both corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:21:15 +02:00
enricobuehler aa3bcfd0d0 Merge pull request 'feat(client/present): desktop presenter rebuild — intent engine, V-Sync/VRR, display-stat split, settings UI' (#20) from worktree-desktop-presenter into main
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Failing after 1m17s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m30s
deb / build-publish-host (push) Failing after 11s
ci / rust-arm64 (push) Successful in 3m51s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
apple / screenshots (push) Successful in 5m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 1m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
android / android (push) Successful in 6m54s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish (push) Failing after 4m50s
ci / rust (push) Failing after 7m29s
docker / deploy-docs (push) Successful in 36s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
flatpak / build-publish (push) Successful in 6m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m21s
Reviewed-on: #20
2026-08-02 22:06:37 +00:00
enricobuehlerandClaude Opus 5 6b3c582eb1 feat(client/present): use the driver's queue-free vblank mode where it exists
apple / swift (pull_request) Successful in 1m14s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m30s
ci / rust-arm64 (pull_request) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m3s
android / android (pull_request) Successful in 5m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 7m13s
`VK_PRESENT_MODE_FIFO_LATEST_READY_EXT` is FIFO's tear-free vblank pacing that
presents the LATEST READY image at each refresh and retires the older ones,
instead of draining a queue. That is precisely what the software glass gate
emulates — so where the driver offers it, the driver does the job, and it does
it exactly where the gate matters most: a surface with no MAILBOX gets
newest-wins behaviour back without the app holding frames.

Found by asking the surface what it actually offers rather than trusting a
comment: the previous commit's `surface present modes` line read back
`[MAILBOX, 1000361000, FIFO]` on NVIDIA/Wayland, and 1000361000 is this mode.

The extension postdates the Vulkan headers ash 0.38 is generated from (1.3.281),
so there is no binding — hence the bare number in the log. It is hand-declared
here: mode value, extension name, and
`VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` spliced into the device
pNext chain. One trap worth naming: the SURFACE advertises the mode even with
the extension disabled, and using it on that basis is undefined — so the ladder
only offers it when the device feature actually came back true and we enabled it.

The gate/probe predicate had to split in two, and the distinction is the point:

* `needs_glass_gate()` — FIFO and FIFO_RELAXED only. NOT this mode: gating on
  top of a driver that already retires stale images would hold frames back to
  emulate something the presentation engine is doing, paying the serialisation
  twice, which is the ~27 ms the last commit measured.
* `vblank_locked()` — the whole FIFO family INCLUDING this mode, because it
  still presents on the refresh boundary, so the VRR cadence probe's premise
  ("with VRR off, a present waits for vblank") still holds.

Ranking: MAILBOX first (measured good at 1.4 ms), then LATEST_READY, then plain
FIFO — so a MAILBOX-less surface reaches newest-wins in the driver rather than
in our gate.

MEASURED ON GLASS (.21, NVIDIA 610.43.03, GNOME/Wayland): the extension probe,
feature enable and swapchain creation all succeed with a mode ash has no binding
for. Default ladder selects MAILBOX with `fifo_latest_ready=true`; the VRR ladder
selects `present_mode=1000361000` and measures `display 2.6 ms (pace 0.6 + latch
2.0)` — against 13-28 ms for plain FIFO + gate on the same box. The vblank-locked
path is now MAILBOX-class.

That changes the previous commit's reversal. The VRR ladder was reverted to
opt-in because it led with plain FIFO and cost ~27 ms; led with LATEST_READY it
costs 0.6 ms over MAILBOX. So `allow_vrr` is automatic again WHERE THE DEVICE
OFFERS THE MODE, and stays behind `PUNKTFUNK_VRR_FIFO=1` where it does not — on
those drivers the ladder would fall back to plain FIFO and the regression
returns. Both branches are pinned by tests. This also retires a dead switch: the
"Follow variable refresh rate" row did nothing at all after the reversal, and now
does something real on any driver with the extension.

⚠ Still unverified off this box: whether Windows and Intel drivers expose the
mode at all. Nothing measured here carries over — Windows Vulkan WSI goes through
DXGI, so exposing the enum and mapping it usefully onto flip-model semantics are
separate questions, and Intel is a different vendor stack again. Both facts are
logged unconditionally now (`surface present modes` + `fifo_latest_ready=`), so
one run on any box settles it. The code is safe either way: the mode is only
requested where the device feature enabled, and `allow_vrr` only goes automatic
there — everywhere else the shipped MAILBOX-first behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e08474d96d fix(client/present): log the surface's actual present modes, and document the VRR opt-in
"AMD's Windows driver offers no MAILBOX" is the premise the FIFO glass gate is
built on, and it has been carried in a code comment rather than measured. Present
modes are a property of the (surface, device) pair — they vary by platform
surface, driver version and fullscreen state — so the only way to settle it is to
read them back from real machines. One unconditional log line makes every field
log answer the question.

First reading, .21 (NVIDIA 610.43.03, GNOME/Wayland):
  surface present modes available=[MAILBOX, 1000361000, FIFO]

Two things fall out. No IMMEDIATE and no FIFO_RELAXED on this surface, which is
why a PUNKTFUNK_PRESENT_MODE=immediate run reported mode=fifo — the pin was not
offered and the ladder fell through; previously that looked like a puzzling
result and is now evidence. And 1000361000 is
VK_PRESENT_MODE_FIFO_LATEST_READY_EXT: FIFO's tear-free vblank pacing that
presents the LATEST READY image instead of draining a queue — the driver-native
version of what the glass gate emulates in software, and a candidate to replace
it wherever the driver exposes it (needs VK_EXT_present_mode_fifo_latest_ready
enabled at device creation, so a work package rather than a tweak).

Also documents PUNKTFUNK_VRR_FIFO, which the previous commit introduced without
a docs entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 f422ae3e38 fix(client/present): what the first on-glass session found, including a reversed default
WP6 ran against .21 (CachyOS, RTX 5070 Ti, NVIDIA 610.43.03, GNOME/Wayland,
1080p60 HDMI, VRR provably disabled — `org.gnome.mutter experimental-features`
is empty), host and client on the same box, `VK_KHR_present_wait` available.

Five defects that unit tests and both CI gates had passed over:

1. The latch learner and the VRR probe observed NOTHING. Both derived spacings
   with `windows(2)` inside a single batch, but the run loop drains present-wait
   samples every pass, so a batch is normally ONE stamp. `period_us` read back
   exactly the mode fallback — correct by luck on a 60 Hz panel, wrong the moment
   a mode lies, which is the entire reason PanelGrid exists. The tests fed
   40-stamp batches, a shape the live loop never produces. Spacings are now
   measured against the previous stamp across calls.

2. The VRR reference was circular. It compared spacings against the LEARNED
   period, but the grid cannot be learned from our own presents when the stream
   runs below panel rate — we only ever observe multiples ≥ our frame interval,
   so the learner adopts our own cadence and every delta is on-grid by
   construction. It learned 18-22 ms from a 40-50 fps stream and reported VRR on
   a display with VRR off. The reference is now the DISPLAY MODE's period, which
   is the vblank grid presents actually quantize to.

3. The probe is meaningless outside FIFO. MAILBOX deliberately decouples presents
   from scanout, so its stamps are never grid-quantized: same panel, same minute,
   FIFO read `no` (correct, period 16.4 ms) and MAILBOX read `yes` (wrong).
   Outside a FIFO-family mode the honest answer is Unknown, and that is now what
   it reports.

4. Round evaluation was per-CALL rather than per-sample, so the verdict depended
   on how the caller batched its stamps. Closed inside the sample loop now, with
   a test pinning bulk-vs-one-at-a-time equivalence — the same invariant (1)
   violated, in a second place.

5. `force_latency` was dead code without the `pyrowave` feature: a warning in the
   `--no-default-features` build CI actually ships (the Windows ARM64 leg). The
   gate only ever tested default features; it now tests both.

DESIGN REVERSAL — the VRR FIFO-first ladder is opt-in (`PUNKTFUNK_VRR_FIFO=1`),
no longer default. It shipped default-on for `allow_vrr` + fullscreen, which is
the default configuration. Measured A/B, same box, back to back, reproduced
across three runs: FIFO+engine `display 28.4 ms (pace 11.8 + latch 16.6)` versus
MAILBOX `1.4 ms (0.2 + 1.2)`. Under a compositor the FIFO present's on-glass
confirmation arrives a whole refresh later and the presenter serialises behind
it. The VRR upside is real in principle but UNMEASURED — no VRR panel was
available — and a default that is measurably ~27 ms worse on the hardware we
could test, bought against an unproven win on hardware we could not, is the
wrong way round. A test pins the default to MAILBOX; flip it back when a VRR
panel confirms the win.

NOT measured, and not claimed: the FIFO glass gate's own headline. The standing
queue only forms when the stream rate approaches the panel rate, and an idle
GNOME desktop is damage-driven at 40-50 fps on a 60 Hz panel, so `gated`/`forced`
read 0 in every mode and the mechanism never engaged. The 11-13 ms figure is
still the code's inherited documentation, not a fresh measurement. It needs its
actual target: AMD-on-Windows (no MAILBOX, direct scanout) under load.

Rig caveats recorded rather than smoothed over: host and client shared one GPU,
so absolute latencies are contended and run-to-run variance was large, and it
could not be visually confirmed what the physical screen showed. Mode selection,
the fallback ladder, the VRR verdict and the counter plumbing are robust to
that; absolute numbers are not.

Gates: fmt, clippy -D warnings over the five client crates AND the
`--no-default-features` build (added because defect 5 hid there), 160 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 e38e3c44c9 feat(client/present): V-Sync and VRR become real settings, and VRR is measured
WP3 of design/desktop-presentation-rebuild.md. The `vsync` and `allow_vrr`
settings have existed since WP1 but nothing consumed them — the swapchain picked
MAILBOX-or-FIFO once, from an env var, and froze. This makes them mean
something, which is also what unblocks their settings rows (deliberately
withheld from WP5 rather than shipped as dead switches).

Present-mode selection is now a preference ladder, not a constant:

* V-Sync off — IMMEDIATE, then FIFO_RELAXED, then the tear-free modes. Asking
  to tear and silently getting vsync is a lie, so the mode that actually took is
  named in the stats line and a refused preference is logged requested-vs-active.
* V-Sync on + VRR allowed + fullscreen — FIFO first. On a variable-refresh panel
  with direct scanout the FIFO present IS the flip, so the panel follows the
  stream's cadence instead of a fixed grid; MAILBOX would decouple presents from
  scanout and re-quantize to the compositor's clock. This is only safe because
  WP2's glass gate bounds the standing queue that historically made FIFO costly.
* Otherwise — MAILBOX then FIFO, the shipped default, unchanged.

`PUNKTFUNK_PRESENT_MODE` still pins a mode outright and now falls back to the
settings (rather than to mailbox) when the name is unknown.

VRR detection is MEASURED, never queried. No portable query exists — SDL exposes
none, Wayland does not report adaptive-sync state, Windows surfaces nothing
through Vulkan — and the platforms that do answer have been caught lying (see
the Android per-uid refresh-rate finding). The discriminator is quantization: on
a fixed-refresh panel every on-glass instant lands on the vblank grid, so the
spacing between presents is ~k×period for whole k even when the stream runs
slower than the panel (it just picks a larger k); under real VRR the panel
refreshes when we present, so the spacing follows our own cadence and sits off
the grid. `CadenceProbe` folds each delta to its distance from the nearest
multiple of the learned period and takes the median. Tri-state: it stays Unknown
below 24 deltas and after a display change, so `vrr` is reported only when it
has been measured — never inferred from what the display claims.

Also fixes the read-once refresh rate: `native.refresh_hz` was sampled at
startup and never revisited, so dragging the window to another monitor left a
60 Hz-seeded clock pacing a 144 Hz panel. `WindowEvent::DisplayChanged` now
relearns the latch grid, resets the cadence verdict, and clears the served-slot
latch.

Settings rows for both, on all three surfaces (GTK, WinUI, console). The
console's V-Sync row is reachable in Gaming Mode, which is the only editor a
Deck user has.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 160 tests (the two new ones cover every ladder and both cadence
regimes, including the case that matters most: a stream slower than a FIXED
panel must still read as fixed). WinUI leg on the Windows runner .133:
clippy=0 tests=0, against a tree proven by content to contain the edit.

⚠ On-glass validation is still owed and is NOT claimed here: every box with a
real display was powered off when this landed, so the VRR ladder and the
detector have been exercised only against synthetic stamps in unit tests.

Rebase follow-up: `20de58a7` landed the same "panel grid can be wrong in both
directions" defect fix on Android and extracted the corrected learner into
`punktfunk_core::phase::PanelGrid` for the iOS and desktop presenters to share.
This clock had the identical bug — it capped the learned period at the display
mode's refresh, and the mode is only a CLAIM, so a display really running slower
than it advertises pinned a grid whose instants never arrive, for the session,
with no way back. Adopted the shared learner rather than carrying a second,
buggier copy; still fed the window's MIN spacing, which preserves the k×period
resistance the cap was actually aimed at while the streak requirement lets a
genuinely slower panel be discovered. New test: seed 120 Hz, real panel 60 Hz,
the clock must climb back out.

Took the same commit's third lesson too: the adaptive margin widened on a
latch over 1.5×period (a number picked here), and now widens on the latch
exceeding one period plus the lead already applied — the slot actually aimed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:56 +02:00
enricobuehlerandClaude Opus 5 b1ac4d02de feat(client/present): the display stat splits, and the intent reaches the settings UI
WP4 + WP5 of design/desktop-presentation-rebuild.md, on top of the WP1/WP2
engine. The engine shipped with no way to choose it and no way to see what it
cost; this closes both.

WP4 — the display stage splits into `pace` (decoded → present-submit, our own
pipeline) + `latch` (submit → on-glass, the presentation queue and the vblank
wait), off the `submitted_ns` stamp WP2 already carried. That split is what
makes a high `display` self-diagnosing: latch dominating is the vsync floor or
a standing queue, pace dominating is us. A `present:` line joins the Detailed
tier naming the live swapchain mode — the answer to most "why is my latch a
whole refresh" questions, since a MAILBOX request silently lands on FIFO
wherever the driver has no mailbox — plus the engine's counters, rendered only
when they are non-zero so a healthy latency session shows just the mode.

Deviation from the plan: the planned `display_adj` twin is NOT here. It was
specified as `display − latch_p50` for parity with the Apple HUD's shaved
figure, but with a real per-sample `pace` percentile that twin is the same
quantity derived worse (subtracting percentiles). `pace` IS the
Apple-comparable number — Apple subtracts its OS present floor, the latch is
ours — and the user docs now say exactly that.

WP5 — Prioritize + Smoothness buffer on all three surfaces: the GTK dialog (a
new Presentation group on the Display page), the WinUI settings page, and the
console settings screen, which is the ONLY editor reachable in Gaming Mode and
so the one that decides whether Deck users can reach this at all. The buffer
control follows the intent the way echo cancellation follows the mic: hidden on
the desktop shells, dimmed and inert on the console, where a row that vanished
mid-list would shift everything under the cursor.

The V-Sync and VRR rows are deliberately NOT here. Their settings exist and are
profile-routed, but the swapchain does not honour them until WP3, and a toggle
that does nothing is exactly how "Full chroma (4:4:4)" shipped inert on desktop
for three releases after being announced.

Buffer labels carry no millisecond hints (Apple/Android derive them from the
session refresh): under a Native mode the shells do not know the refresh at
settings time, so the captions state the cost as one refresh per frame rather
than a confident wrong number.

Docs: the stats page documents the split and the `present:` line, and stops
claiming Linux/Windows measure to the present instant (untrue since
present_wait); client-settings documents both new rows and drops the stale
claim that the desktop 4:4:4 toggle has no effect (it was wired to
VIDEO_CAP_444); configuration documents PUNKTFUNK_PRESENTER and
PUNKTFUNK_PRESENT_DEBUG.

Gates: punktfunk-rust-ci linux/amd64 — fmt, clippy -D warnings over
pf-client-core, pf-presenter, pf-console-ui, the session binary and the GTK
client, 158 tests. The WinUI leg cannot be reached by any Linux or macOS check,
so it was compiled on the Windows runner .133: clippy -D warnings and tests
both exit 0, against a tree proven by content to contain the edit. ⚠ The first
run there reported a false pass — the script printed its done-marker while the
log carried a test failure (a STATUS_DLL_NOT_FOUND launch failure, ffmpeg's
DLLs missing from PATH); the harness now echoes each phase's exit code so the
verdict is a fact in the log rather than an inference from a marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:01:38 +02:00
enricobuehlerandClaude Opus 5 5f55fa874a feat(client/present): the desktop presenter gains the Apple/Android intent model
WP1+WP2 of design/desktop-presentation-rebuild.md. The shared Linux/Windows
session client presented arrival-paced with no pacing layer at all: two depth-2
newest-wins hops into a drain-to-newest and an immediate present. That IS the
lowest-latency intent, but it was unnamed, unselectable, and had no alternative
— and on a surface without MAILBOX (AMD's Windows driver offers none, and any
compositor holding images does the same) the swapchain's own FIFO becomes a
standing queue worth a measured 11-13 ms at 60 Hz.

WP1 — the settings cluster, under the keys the Apple client already writes into
the shared profile catalog (present_priority / smooth_buffer / vsync /
allow_vrr): mismatched names would ride SettingsOverlay::extra, carried but
never applied. PresentPriority::resolve mirrors the Android reference exactly
(anything but an explicit "smooth" is latency; a buffer outside 1..=3 becomes
2), so a profile authored on any client means the same thing on all of them.
Only the first two are consumed here; vsync/allow_vrr land in WP3.

WP2 — the engine (present_pace.rs, pure state + arithmetic, 6 tests):
- FrameStore: newest-wins slot, or the smoothing FIFO with preroll-to-capacity,
  drop-oldest overflow, and an underflow that re-arms the preroll (repeat by
  omission) — the Apple/Android semantics, with qDrop/qDry counters.
- LatchClock: the panel grid learned from VK_KHR_present_wait glass stamps,
  min positive spacing capped by the mode refresh (measured, never queried —
  VRR and Android's per-uid refresh lie both punish trusting a reported rate).
  It now also publishes the host-facing LatchGrid, so the phase-lock report and
  the local scheduler cannot disagree about the grid.
- PresentGate: one undisplayed present in flight on FIFO surfaces, with the
  100 ms stale force-open. This is the standing-queue killer, and it is inert
  on MAILBOX/IMMEDIATE and without present timing — where behaviour stays
  byte-for-byte the shipped arrival pacing.

Wiring: glass samples drain every pass (a 1 Hz batch would starve clock and
gate) and the waiter pushes an SDL wake, so a gate reopen never waits out the
event timeout; smoothness serves one frame per latch slot and tightens the
loop's wait to that deadline; the adaptive slot margin starts at 0 and widens
+500 us per missed window toward 2.5 ms (a fixed lead was measured to be pure
display tax). PUNKTFUNK_PRESENTER=arrival disables the whole engine for field
A/B without a rebuild.

PyroWave collapses smoothness to latency for the stream: its plane-ring
retirement accounting assumes the depth-2 newest-wins hand-off, and all-intra
frames make buffering moot anyway.

Gates (punktfunk-rust-ci, linux/amd64, sources touched first so a warm target
cannot print a vacuous Finished): clippy -D warnings across pf-client-core,
pf-presenter and punktfunk-client-session; 80 + 32 tests pass; rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:00:50 +02:00
enricobuehlerandClaude Opus 5 8af6e2dd02 feat(decky): the stats overlay gets an off switch in Gaming Mode
Field report: "as of version 0.23 of this plugin, there is no setting to
toggle off the stat overlay." Correct, and it never had one — no commit in
`clients/decky` has ever touched a stats key. Every other client does:
the GTK dialog, the Windows page, the Apple app, and the console's own
settings screen all carry the four-tier picker.

The tier defaults to on. `Settings::default` is `show_stats: true` and
`stats_verbosity: None`, which `Settings::stats_verbosity` resolves to
Normal — so a Deck that has only ever been configured through this panel
streams with the overlay up and no way here to put it down. What escapes
exist are not discoverable: Ctrl+Alt+Shift+S wants a keyboard, and the
three-finger touchscreen tap is documented in `docs/stats`, not on the
glass. The console's picker is reachable (X on console home), but that is
a different shortcut than the one-tap stream this panel launches, and a
user editing stream settings here has no reason to look there.

So the row lands here, last in the section, matching the console's
wording. It writes `stats_verbosity` AND the legacy `show_stats` in the
same pairing `Settings::set_stats_verbosity` keeps, so a client too old
for the tiers still honours an Off chosen here; it reads them back the
way `Settings::stats_verbosity` does, so a pre-tier file — including
every file this plugin wrote before today — shows the Normal the stream
actually runs at.

`set_settings` stops replacing the file and merges onto it instead. This
JSON is shared with the desktop client and the console, and holds many
more keys than this panel models (decoder, GPU, profiles, touch/mouse
model). The panel reads it once when it mounts, so a wholesale write
posts a snapshot that predates anything another editor stored while it
sat open — silently reverting it. That was invisible until 0.23.0:
`9c5af8d7` fixed the GTK shell handing the session a spec built from
`Settings::default()`, and only since then does this file reach a stream
at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:53:57 +02:00
enricobuehlerandClaude Fable 5 d839f4c2b6 fix(client/windows): settings stop going stale behind your back, and the log has a door
ci / web (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m35s
ci / docs-site (push) Successful in 2m35s
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 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
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 4s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m28s
ci / rust (push) Successful in 7m1s
android / android (push) Successful in 7m5s
arch / build-publish (push) Successful in 8m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 8s
apple / screenshots (push) Successful in 5m42s
docker / deploy-docs (push) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
flatpak / build-publish (push) Canceled after 9m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
A field reporter's codec setting "changed by itself" between sessions. Nothing writes
the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is
loaded ONCE at process start and the page renders from it, but this process is not the
file's only writer (the spawned session persists its match-window size, the console UI
and Decky save too), so the page showed values another process had already replaced —
until a row was touched and `commit`'s rebase pulled the file in, at which point the
value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and
missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm
cloned the snapshot without reloading, so overlay absorption diffed against stale
globals. Both now re-base on the file.

Two more ways a setting could vanish or cost time:

* An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no
  unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives
  profiles exactly that contract. Extended to the globals: additive, empty on every
  existing store, and an empty map serializes to nothing so no file churns. (`save()`
  was already temp+rename, so the torn-file → silent-Default reset was closed.)
* "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder
  row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation
  is in reach), and the failed-spawn banner now names the path.

The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends
people hunting: the host gate is PyroWave or an NVENC backend. It says so now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:49:45 +02:00
enricobuehler 0de161e29b Merge pull request 'feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way' (#22) from worktree-gamepad-passthrough-toggle into main
ci / web (push) Successful in 1m10s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 2m0s
ci / rust-arm64 (push) Successful in 2m37s
decky / build-publish (push) Successful in 48s
deb / build-publish-client-arm64 (push) Successful in 1m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish (push) Successful in 4m0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m3s
deb / build-publish-host (push) Successful in 3m51s
docker / builders-arm64cross (push) Successful in 14s
arch / build-publish (push) Failing after 5m22s
docker / deploy-docs (push) Successful in 33s
android / android (push) Successful in 7m22s
ci / rust (push) Failing after 9m12s
flatpak / build-publish (push) Failing after 5m29s
release / apple (push) Successful in 9m14s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 8m12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m34s
apple / screenshots (push) Canceled after 4m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m8s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 1m31s
Reviewed-on: #22
2026-08-02 21:38:01 +00:00
enricobuehlerandClaude Opus 5 b297542c4d feat(clients/input): controllers can stop being forwarded, for couches that hand the pad over another way
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m50s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 3m27s
ci / rust (pull_request) Successful in 7m59s
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.

New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.

On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.

The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.

Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.

Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:21:26 +02:00
enricobuehlerandClaude Fable 5 98e040fd01 fix(host/stream): the wire holds the session rate when the display outruns it
apple / swift (push) Successful in 1m20s
ci / rust (push) Successful in 5m0s
android / android (push) Successful in 6m30s
ci / rust-arm64 (push) Successful in 1m50s
apple / screenshots (push) Successful in 5m37s
arch / build-publish (push) Successful in 8m0s
ci / web (push) Successful in 1m28s
ci / docs-site (push) Successful in 1m36s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 18s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 5m33s
deb / build-publish (push) Successful in 5m39s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 24s
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m18s
PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one
extra frame on the wire, but the frame-driven trigger enforced its pace only
as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source
that always has a frame pending — the overdriven display under uncapped
content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That
is the field report's 132 fps on a 120 fps session: ten percent more
bitrate, encode and decode for frames a 120 Hz panel can only drop.

A credit bucket (PaceBudget) now pins the long-run average at the pacing
rate: credit accrues at one frame per interval of real elapsed time, capped
at 1.25 frames of post-stall burst, and every submitted frame spends one. A
grab may run early only against banked credit, so the 0.9 floor keeps its
per-gap jitter headroom while the average cannot exceed the rate — and a
source at or below it banks faster than it spends and is never delayed.
Anchoring to real elapsed time also keeps the synchronous-encode overlap the
arrival-anchored floor bought (the owed fraction absorbs a constant encode
tail instead of stacking on top of it), and it cannot fight the phase lock's
submit grid: both agree the period is the interval.

The charge lives under the same guard as the gate — the legacy fixed tick
paces by its own grid, and charging it without ever accruing would bank
unbounded debt that stalls the loop if a rebuild later flips the capturer to
arrival-wait.

Verified on .25: native::stream tests 15/15 (three new PaceBudget tests),
punktfunk-host 369/369, clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Fable 5 5174a59832 fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata
composite), a KWin capture-model stream always has a cursor — and it never
went away again: not in game, not in Big Picture, not with a controller in
hand (field report, 2026-08-01). The host blended the arrow forever because
pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible`
latched true nothing on Linux ever cleared it.

Two producer contracts meet on id 0, and one flag now carries which one a
stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and
writes id 0 whenever Cursor::isOnOutput says the pointer is not in this
stream — which covers both a globally hidden cursor and a client null-cursor
surface (empty geometry intersects nothing). There id 0 IS the hide, and
honoring it is what lets a game hide the pointer mid-stream. Mutter only
rewrites a buffer's meta when the cursor changed, so recycled buffers carry
stale id-0 regions between damage frames — honoring those flickered the
cursor off between hovers (on-glass round 5), and that path keeps its
last-known-state behavior.

The flag rides from the backend that created the output (correct for
registry-pooled reuse too — a kept display only ever matches its own
backend) through capture_virtual_output into the parser's CursorState. The
portal-monitor path stays on the stale-meta contract: the only thing routed
through it today is Mutter's HDR mirror.

Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy
-D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean.
On-glass KDE validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00
enricobuehlerandClaude Opus 5 c2a6d30d7b fix(android/decode): a codec input slot the feeder can't fill goes back, and so does the AU
deb / build-publish (push) Failing after 2s
deb / build-publish-host (push) Failing after 2s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m19s
ci / rust (push) Successful in 5m52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 9m12s
release / apple (push) Successful in 9m14s
ci / rust-arm64 (push) Successful in 1m57s
android / android (push) Successful in 11m23s
windows-host / package (push) Successful in 11m26s
windows-host / winget-source (push) Skipped
ci / docs-site (push) Failing after 11m59s
ci / web (push) Failing after 12m2s
flatpak / build-publish (push) Successful in 6m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 28s
apple / screenshots (push) Successful in 5m54s
windows-host / canary-manifest (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m26s
`AMediaCodec_getInputBuffer` returning null for an index the input-available
callback had just handed us dropped both the slot and the access unit on the
floor. Every sibling path in this loop recycles the slot — the orphan-part
discard and the oversize drop both say so in as many words — because nothing was
written and nothing was queued, so it is still ours. Forgetting it leaks one of
the codec's input buffers per occurrence: we never use it again and the codec
never frees what it never received, so the pipeline runs out of input slots,
`pending_aus` overflows into its drop-oldest arm, and the resulting keyframe storm
reads as a decode fault rather than a bookkeeping one.

The AU went with it, silently — no keyframe request, no freeze gate, unlike every
other loss path here — leaving a hole in the reference chain whose concealment
was free to reach the screen.

Both go back now. `break` rather than `continue`, because a codec that cannot
hand out an input buffer it has just advertised is in no state to be fed the rest
of the parked queue on this pass, and retrying the same index against every
parked AU would burn the whole backlog for nothing; the loop comes round again on
the housekeeping wake within 5 ms if it was transient.

Gates: cargo ndk check green on arm64 and armv7, fmt clean, Android clippy at the
same 4 pre-existing warnings as the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 20de58a78a fix(android/present): the panel grid can be wrong in both directions, and the margin listens to the latch
Three defects in the 0.23.0 timeline presenter, all found while root-causing the
field report that turned out to be the slice wire. None of them is that bug; all
three are real, and the first is the one that would still bite once it is fixed.

The panel-period learner could only ever narrow. It is seeded from the display
mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may
refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for
120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a
16.67 ms display with no way back, for the rest of the session: it then aims at
instants that never arrive and releases faster than the panel scans. The learner
moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is
host-testable and where the iOS and desktop presenters can share it. The
asymmetry is kept and made explicit — narrowing is immediate (a finer real grid
is always safe to subdivide onto, and it is the per-uid down-rate case the seed
most often gets wrong), widening needs eight consecutive agreeing observations
and then takes the narrowest of them, because one wide sample is a missed
callback and eight in a row is a display that really did slow down.

The glass budget was a prediction with nothing underneath it. `OnFrameRendered`
already reports what actually reached glass, but the budget never consulted it,
so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills,
MediaCodec runs out of output buffers, the decoder stalls, and the no-output
backstop starts begging for keyframes. Releases are now counted against their
confirms and the presenter holds back past six outstanding — loose on purpose,
since the callbacks are allowed to arrive batched and a held frame in the
newest-wins slot is a dropped one. It self-clears when the confirms catch up, and
writes the ledger off after the same 100 ms the stale reopen uses, so a platform
that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join
the 1 Hz pf.present line, which is what would have made this visible from a log.

The adaptive latch margin widened on `paced_drops` — the newest-wins store's own
policy evictions, which happen whenever the stream out-runs the panel and say
nothing about SurfaceFlinger's latch lead. On a healthy device that walked the
margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had
just measured away. It now widens on the measured latch exceeding one panel
period plus the live margin, which is what a missed vsync actually looks like.

Also corrects two doc comments that named `display.refreshRate` as the panel_hz
source; it has been the mode table since the A024 down-rate fix.

Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode
regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and
armv7. Android clippy reports the same 4 warnings as the base commit and no new
ones. NOT yet confirmed on glass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehlerandClaude Opus 5 97b2c01ac1 fix(core/packet): a slice-streamed frame costs its own size, not the whole frame ceiling
The 0.23.0 slice wire flushes a block every MIN_STREAM_BLOCK_SHARDS, so every
ordinary access unit is now opened by a SENTINEL — a header with no totals. The
reassembler sized those frames at `max_frame_bytes`, which the QUIC handshake
clamps to 8-64 MiB. That was survivable while sentinels were rare (the streamed
path emitted one only for an AU exceeding a whole FEC block, ~281 KB); it is not
survivable now that every frame is one.

Two consequences, both measured: each access unit allocated and ZEROED a
multi-megabyte buffer, and the in-flight budget (IN_FLIGHT_BUF_FACTOR x
max_frame_bytes) was spent after ~3 concurrent frames — with production geometry,
12 ordinary AUs in flight lost 9 of them outright, every packet dropped before it
could be placed. On a link with normal reorder that is a permanent loss storm:
frames never complete, the re-anchor gate freezes the picture, and the client begs
for keyframes. Only clients advertising VIDEO_CAP_MULTI_SLICE reach this path —
Android and the Linux/Windows session client; Apple and the Windows in-process
client never did, which is why it read as a platform-specific "video pipeline"
fault in the field.

A sentinel carries no total but does pin its own block's extent: a slice sentinel
by its wire base, a legacy one by its full-K position. Size the buffer to that and
grow as later blocks (or the final block's totals) reveal more. The budget is
re-checked on growth for the same reason it is checked at open.

The same flush also drained `pending` to empty whenever the AU's length was an
exact multiple of the shard payload, leaving `finish_streamed` to seal a final
block of one zero-padded FILLER shard. Its derived base overlapped the block
flushed a moment earlier, retro-validation correctly read that as a lying header,
and the whole AU died — one frame in every 1408 on a 1500-MTU link, ~12 s apart at
120 fps, each costing a freeze and a recovery keyframe. A flush now keeps one
whole shard back, restoring the invariant `StreamedAu::pending` already documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 20:15:45 +02:00
enricobuehler 29473d6280 Merge pull request 'fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS' (#19) from worktree-ipad-esc-pointer-relock into main
ci / rust-arm64 (push) Failing after 2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 34s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m21s
docker / builders-arm64cross (push) Successful in 45s
ci / rust (push) Successful in 4m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 5m6s
docker / deploy-docs (push) Failing after 9s
ci / web (push) Successful in 6m50s
release / apple (push) Successful in 9m10s
apple / screenshots (push) Successful in 5m53s
Reviewed-on: #19
2026-08-02 17:27:54 +00:00
enricobuehlerandClaude Opus 5 d63e913f52 fix(client/ios): Escape keeps the pointer captured instead of handing it back to iPadOS
ci / web (pull_request) Successful in 1m2s
apple / swift (pull_request) Successful in 1m18s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m26s
ci / docs-site (pull_request) Successful in 3m46s
ci / rust (pull_request) Successful in 4m8s
iPadOS releases the scene's pointer lock by itself when Escape is pressed — the platform's
built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing in
our code does it: a bare Esc never touches `captured`, and it keeps forwarding to the host as
the game key it is. But the lock going away flips the mouse onto the absolute UIKit path and
un-hides the iPadOS cursor, so pressing Esc for an in-game menu silently cost the capture until
the user clicked into the video to win it back.

Esc is a GAME key in a stream, not a request to hand the pointer back to iPadOS, so an unwanted
drop is now re-requested. `syncPointerLock` arms a short, bounded burst (3 attempts over ~0.6 s,
no restart inside 2 s) whenever the lock is wanted, was previously HELD, and is now gone; the
first attempt re-asserts `prefersPointerLocked`, later ones present a real false→true transition
and re-anchor the PointerLockChain. Every deliberate release (⌘⎋, ⌃⌥⇧Q, the Stream menu,
resigning active) clears `captured` first, so `wantsPointerLock` is already false when their drop
is observed and none of them are fought.

The "previously held" half of the condition keeps a scene that never qualifies (Stage Manager,
Split View) from paying for a lock that isn't coming — there, a first grant is still driven by
the chain engage in setCaptured/viewDidAppear exactly as before.

While a re-lock is in flight the local cursor stays hidden and absolute pointer MOTION stays
muted, so the couple of frames it takes read as "Esc did nothing to my mouse" rather than a
cursor that blinks in and out and a host cursor that teleports to the pointer's absolute
position. Buttons still forward (they carry no position), so a click mid-relock isn't swallowed.
The burst clears itself on give-up, so the cursor can never stay hidden on a lock the system
won't grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:08:31 +02:00
155 changed files with 14504 additions and 1148 deletions
Generated
+32 -32
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2897,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -2915,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2936,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"ash",
"bindgen",
@@ -2969,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"libc",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2995,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.23.0"
version = "0.24.0"
[[package]]
name = "pf-inject"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3028,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3050,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -3058,7 +3058,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"base64",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3115,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ash",
@@ -3323,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3334,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"android_logger",
"jni",
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3382,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3402,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3434,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3519,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"anyhow",
"ksni",
@@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -53,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.23.0"
version = "0.24.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+90 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.22.3"
"version": "0.23.0"
},
"paths": {
"/api/v1/clients": {
@@ -2170,6 +2170,51 @@
}
}
},
"/api/v1/plugins/logs": {
"post": {
"tags": [
"plugins"
],
"summary": "Ingest runner log lines",
"description": "The plugin/script runner ships its output here so the console's **Logs** page can show it.\n\nPlugins are not host child processes — the runner is a separate `bun` process that `import()`s\neach plugin in-process — so nothing a plugin logs passes through the host's own `tracing`, and\nbefore this endpoint the console's log page could not show a single plugin line. On Linux the\nfallback was `journalctl --user -u punktfunk-scripting`; on Windows the runner task writes no\nlog file at all, so a failing plugin was diagnosable only by stopping the scheduled task and\nre-running the runner by hand. Both are shell access on the host box, which is exactly what the\nconsole exists to avoid.\n\nLines land in the same ring as the host's own, sharing one `seq` cursor, targeted\n`plugin:<source>` — so `GET /logs` needs no second cursor and the console needs no second poll.",
"operationId": "ingestPluginLogs",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginLogBatch"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Lines ingested"
},
"400": {
"description": "Batch too large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/plugins/{id}": {
"put": {
"tags": [
@@ -6238,6 +6283,50 @@
"gamestream"
]
},
"PluginLogBatch": {
"type": "object",
"description": "A batch of runner log lines.",
"required": [
"entries"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PluginLogLine"
}
}
}
},
"PluginLogLine": {
"type": "object",
"description": "One log line produced by the runner or a plugin inside it (`POST /plugins/logs`).",
"required": [
"ts_ms",
"level",
"source",
"msg"
],
"properties": {
"level": {
"type": "string",
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`. Anything else is coerced to `INFO`."
},
"msg": {
"type": "string"
},
"source": {
"type": "string",
"description": "Which unit emitted it — a plugin's `definePlugin` name, a package name, or `runner`.\nSurfaced in the console's target column as `plugin:<source>`."
},
"ts_ms": {
"type": "integer",
"format": "int64",
"description": "When the line was produced, unix milliseconds. Kept verbatim — see\n[`crate::log_capture::LogRing::push_remote`].",
"minimum": 0
}
}
},
"PluginRegistration": {
"type": "object",
"description": "Register/renew body for `PUT /plugins/{id}`.",
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds — the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
.clip(shape)
.background(visuals.background)
.border(1.dp, visuals.border, shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
@@ -72,6 +74,8 @@ private class GpRow(
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
)
@Composable
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
// The catalog is read once per screen entry: this screen can't create or edit profiles
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
// on each toggle, keeping the "Pinned to N hosts" counts honest.
val knownHostStore = remember { KnownHostStore(context) }
val profileStore = remember { ProfileStore(context) }
val profiles = remember { profileStore.all() }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
// only: pin appends at the end (card order), unpin removes, and the host's default binding
// (profileId) is never touched.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
val pins = if (profile.id in kh.pinnedProfileIds) {
kh.pinnedProfileIds - profile.id
} else {
kh.pinnedProfileIds + profile.id
}
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
savedHosts = knownHostStore.all()
}
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
BackHandler(onBack = onBack)
GamepadNavEffect2D(
active = navActive,
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
// drops its probes — the pattern ConnectScreen's dialogs use.
active = navActive && pinProfile == null,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
// The legend follows the focused row (the desktop console's hints() does the same):
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
val focused = rows.getOrNull(focus)
GamepadHintBar(
listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
),
when {
focused != null && !focused.enabled -> listOf(
PadGlyph.hint('B', "Done", onClick = onBack),
)
focused != null && !focused.adjustable -> listOf(
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
else -> listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
// touch UI's per-profile pin toggles in the host edit sheet.
pinProfile?.let { p ->
GamepadPinHostsDialog(
profileName = p.name,
hosts = savedHosts,
pinned = { kh -> p.id in kh.pinnedProfileIds },
onToggle = { kh -> togglePin(kh, p) },
onDismiss = { pinProfile = null },
)
}
}
}
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
val visuals = animateConsoleFocus(active = focused)
val shape = RoundedCornerShape(14.dp)
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
// focus arrives; the value colour cross-fades with them.
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
val chevronAlpha by animateFloatAsState(
if (focused && row.adjustable) 0.6f else 0f,
tween(160),
label = "chevrons",
)
val valueColor by animateColorAsState(
Color.White.copy(alpha = if (focused) 1f else 0.6f),
tween(160),
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
// so its detail line can still explain what would go here.
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
maxLines = 1,
)
Spacer(Modifier.weight(1f))
@@ -401,8 +471,15 @@ private fun buildSettingsRows(
s.echoCancel,
) { update(s.copy(echoCancel = it)) },
toggle(
"padForward", "Controllers", "Forward controllers",
"Send this device's controllers to the host. Turn it off when your controller " +
"already reaches the host another way — USB passthrough such as VirtualHere — " +
"so games don't see two of them.",
s.gamepadForwarding,
) { update(s.copy(gamepadForwarding = it)) },
choice(
"padType", "Controllers", "Controller type",
"padType", null, "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS, s.gamepad,
) { update(s.copy(gamepad = it)) },
@@ -428,3 +505,62 @@ private fun buildSettingsRows(
) { update(s.copy(sc2Capture = it)) },
)
}
/**
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
* nowhere useful on a touchless device, so the strings name the actual route — the
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
*/
private fun buildProfileRows(
profiles: List<StreamProfile>,
savedHosts: List<KnownHost>,
tv: Boolean,
openPinPicker: (StreamProfile) -> Unit,
): List<GpRow> {
val createHint = if (tv) {
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
"and use the standard interface."
} else {
"Profiles are created and edited in the touch interface."
}
if (profiles.isEmpty()) {
return listOf(
GpRow(
id = "noProfiles",
header = "Profiles",
label = "No profiles yet",
value = "",
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
"one-press connect cards here. " + createHint,
adjust = { false },
activate = {},
adjustable = false,
enabled = false,
),
)
}
return profiles.mapIndexed { i, p ->
// Counted straight off the host records, so it agrees with what the carousel renders.
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
GpRow(
id = "profile:${p.id}",
header = if (i == 0) "Profiles" else null,
label = p.name,
value = when (pins) {
0 -> "Not pinned"
1 -> "Pinned to 1 host"
else -> "Pinned to $pins hosts"
},
detail = "Pin this profile to a host and it appears as its own card — one press " +
"connects with it. " + createHint,
adjust = { false },
activate = { openPinPicker(p) },
adjustable = false,
)
}
}
@@ -43,6 +43,7 @@ data class SettingsOverlay(
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
val gamepad: Int? = null,
val gamepadForwarding: Boolean? = null,
val statsVerbosity: StatsVerbosity? = null,
/**
* Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere
@@ -76,6 +77,7 @@ data class SettingsOverlay(
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
gamepad = gamepad ?: base.gamepad,
gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
@@ -110,6 +112,9 @@ data class SettingsOverlay(
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
gamepadForwarding =
if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding
else gamepadForwarding,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
@@ -136,6 +141,7 @@ data class SettingsOverlay(
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
"gamepad" -> copy(gamepad = null)
"gamepad_forwarding" -> copy(gamepadForwarding = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
@@ -159,6 +165,7 @@ data class SettingsOverlay(
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
if (gamepad != null) add("gamepad")
if (gamepadForwarding != null) add("gamepad_forwarding")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
@@ -190,6 +197,7 @@ data class SettingsOverlay(
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
gamepad?.let { j.put("gamepad", it) }
gamepadForwarding?.let { j.put("gamepad_forwarding", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
@@ -205,7 +213,8 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "stats_verbosity",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"stats_verbosity",
"low_latency_mode", "present_priority", "smooth_buffer",
)
@@ -227,6 +236,7 @@ data class SettingsOverlay(
?.let { n -> MouseMode.entries.firstOrNull { it.storedName == n } },
invertScroll = j.optBooleanOrNull("invert_scroll"),
gamepad = j.optIntOrNull("gamepad"),
gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"),
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
@@ -34,6 +34,17 @@ data class Settings(
val hdrEnabled: Boolean = true,
val compositor: Int = 0,
val gamepad: Int = 0,
/**
* Forward this device's controllers to the host at all. Default on — that was the
* unconditional behaviour before this became a setting.
*
* Off is for a couch whose controller reaches the host another way: a USB passthrough tool
* (VirtualHere and friends), or a pad simply plugged into the host itself. Leaving it on
* there gives the host two controllers for one pair of hands, and games read both. It also
* stops this device CLAIMING the pad — a device held open is one a passthrough tool can't
* bind — which is why it gates the USB capture paths, not just the wire sends.
*/
val gamepadForwarding: Boolean = true,
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
@@ -216,6 +227,7 @@ class SettingsStore(context: Context) {
hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true),
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
@@ -262,6 +274,7 @@ class SettingsStore(context: Context) {
.putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
@@ -291,6 +304,7 @@ class SettingsStore(context: Context) {
const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
const val K_GAMEPAD_FORWARDING = "gamepad_forwarding"
const val K_AUDIO_CH = "audio_channels"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
@@ -818,11 +818,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
@Composable
private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsGroup(footer = "Applies from the next session.") {
// The master switch, above everything it governs. Profileable, so it shows in both
// scopes: a "Work" profile can decline to forward what "Game" forwards.
ToggleRow(
title = "Forward controllers",
subtitle = "Send this device's controllers to the host. Turn it off when your " +
"controller already reaches the host another way — USB passthrough such as " +
"VirtualHere, or a pad plugged into the host — so games don't see two of them",
checked = s.gamepadForwarding,
field = "gamepad_forwarding",
onCheckedChange = { on -> update(s.copy(gamepadForwarding = on)) },
)
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS,
selected = s.gamepad,
field = "gamepad",
enabled = s.gamepadForwarding,
caption = "The virtual pad the host creates. Automatic matches your controller; " +
"every connected one is forwarded as its own player.",
) { g -> update(s.copy(gamepad = g)) }
@@ -852,6 +864,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Stream a Steam Controller 2 as-is — Steam on the host drives its " +
"trackpads, gyro and haptics directly",
checked = s.sc2Capture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
@@ -861,6 +874,7 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
enabled = s.gamepadForwarding,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
@@ -1013,6 +1027,7 @@ private fun <T> SettingDropdown(
selected: T,
field: String? = null,
caption: String? = null,
enabled: Boolean = true,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -1020,18 +1035,25 @@ private fun <T> SettingDropdown(
?: options.firstOrNull()?.second.orEmpty()
Column {
OverrideBadge(field)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) expanded = it },
) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false },
) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
@@ -321,7 +321,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
val router = GamepadRouter(
context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding,
)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it
@@ -442,7 +444,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// The menu-time capture (UI navigation) must let go before the stream-mode capture can
// claim the interfaces; it resumes in onDispose once the stream releases them.
activity?.stopSc2MenuNav()
val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null
val sc2 = if (initialSettings.sc2Capture && initialSettings.gamepadForwarding) {
Sc2Capture(context, router)
} else {
null
}
var sc2UsbReceiver: BroadcastReceiver? = null
if (sc2 != null) {
feedback.onHidRaw = sc2::onHidRaw
@@ -492,7 +498,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
val ds = if (initialSettings.dsCapture && initialSettings.gamepadForwarding) {
DsCapture(context, router)
} else {
null
}
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
@@ -33,7 +33,24 @@ import java.util.concurrent.ConcurrentHashMap
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
class GamepadRouter(
context: Context,
private val handle: Long,
private val setting: Int,
/**
* Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
* default true). Off is for a couch whose controller reaches the host another way USB
* passthrough such as VirtualHere, or a pad plugged into the host itself where forwarding
* as well would give the host two pads for one pair of hands.
*
* Off still opens slots and tracks held state; it only stops the wire sends. That is
* deliberate: the exit and mic chords are read off the same slots, and a couch that lost its
* quit shortcut because a forwarding preference was off would be the worse bug. Nothing is
* claimed by keeping a slot the Android input stack shares controllers unlike the USB
* capture links, which `StreamScreen` does not start at all while this is off.
*/
private val forwarding: Boolean = true,
) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
@@ -123,7 +140,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
if (down) {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
slot.held = slot.held or bit
// Full chord now held on this pad → start the hold countdown (idempotent while held).
@@ -136,7 +155,9 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
onMicChord?.invoke()
}
} else {
if (send) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (send && forwarding) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
// A chord button lifted before the hold elapsed → cancel, unless another pad still
// holds the full chord.
@@ -186,7 +207,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
if (forwarding) slot.mapper.onMotion(event)
return true
}
@@ -221,24 +242,26 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** One axis update ([Gamepad].AXIS_*: stick i16 +y=up / trigger 0..255). On-change only. */
fun axis(id: Int, value: Int) {
if (slot != null) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
if (slot != null && forwarding) NativeBridge.nativeSendGamepadAxis(handle, id, value, index)
}
/** One raw HID report, forwarded verbatim for the host's as-is virtual pad. */
fun hidReport(buf: java.nio.ByteBuffer, len: Int) {
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
if (slot != null && forwarding) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
if (slot != null && forwarding) {
NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
if (slot != null && forwarding) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
@@ -260,7 +283,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Synthetic ids live below any real InputDevice id (those are positive), so they can't
// collide and InputDevice.getDevice(id) resolves them to null for the feedback path.
val syntheticId = EXTERNAL_ID_BASE - index
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
return ExternalPad(syntheticId, index)
}
@@ -317,7 +340,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
@@ -330,7 +353,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadRemove(handle, slot.index)
// If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer.
if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit()
// Release this controller's feedback bindings (close its lights session / cancel rumble).
@@ -342,11 +365,11 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
if (forwarding) slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
+54 -56
View File
@@ -12,10 +12,14 @@
//! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the
//! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to
//! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free`
//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and
//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain
//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and
//! grown on XRuns (Google's anti-glitch technique).
//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled
//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't
//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on
//! XRuns (Google's anti-glitch technique).
//!
//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also
//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down,
//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling.
use ndk::audio::{
AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
@@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000;
/// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE).
const RING_CHUNKS: usize = 64;
// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). --------
// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied
// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`.
// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking
// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and
// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately
// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent.
/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to
/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever.
const PRIME_FLOOR_MS: usize = 40;
/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high).
const PRIME_CEIL_MS: usize = 80;
/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst
/// without overflowing.
const JITTER_HEADROOM_MS: usize = 80;
/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency).
const HARD_CAP_MS: usize = 150;
/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient
/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly).
const DEPRIME_AFTER_CALLBACKS: u32 = 5;
// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). --
// They used to be four Android-only constants here. The rationale for Android being DEEPER than the
// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which
// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a
// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as
// underruns/overflows = crackle.
//
// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS
// the target on the devices that actually underrun instead of every device pre-paying for the worst
// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so
// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and
// stayed there — the "audio latency is too high" report.
/// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum).
const XRUN_CHECK_EVERY: u32 = 128;
@@ -104,6 +100,7 @@ struct Counters {
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
ring_depth: AtomicU64, // ring sample count at the last callback
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
}
/// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread.
@@ -126,10 +123,9 @@ impl AudioPlayback {
// Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms-
// denominated jitter-ring depths scale by it.
let ms = (SAMPLE_RATE as usize / 1000) * channels;
let prime_floor = PRIME_FLOOR_MS * ms;
let prime_ceil = PRIME_CEIL_MS * ms;
let jitter_headroom = JITTER_HEADROOM_MS * ms;
let hard_cap_max = HARD_CAP_MS * ms;
let tuning = punktfunk_core::audio::JitterTuning::AAUDIO;
// Worst transient the ring can hold before the policy trims it.
let hard_cap_max = tuning.hard_cap_ms as usize * ms;
let counters = Arc::new(Counters::default());
// One open attempt at a given sharing mode. Everything the realtime callback captures
@@ -157,8 +153,10 @@ impl AudioPlayback {
// `decode_loop`.
let mut ring: VecDeque<f32> =
VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms);
let mut primed = false;
let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis)
// Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The
// hysteresis this replaces was Android-only; Linux and Windows carried the instant
// `if ring.is_empty()` re-prime until now.
let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8);
let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check)
let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for
let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| {
@@ -173,21 +171,25 @@ impl AudioPlayback {
ring.extend(chunk.drain(..));
let _ = free_tx.try_send(chunk);
}
// Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained
// drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst
// `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single
// quantum.
let target = (3 * want).clamp(prime_floor, prime_ceil);
let hard_cap = (target + jitter_headroom).min(hard_cap_max);
while ring.len() > hard_cap {
ring.pop_front();
// Jitter buffer: the shared policy decides prime/silence, trims a burst, and —
// new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above
// target long enough to be drift rather than jitter. Without that shed this ring
// had no way back down: it clamped at 120 ms and stayed pinned there.
let step = policy.step(ring.len(), want);
if step.drop_front > 0 {
punktfunk_core::audio::crossfade_drop(
&mut ring,
step.drop_front,
step.crossfade,
);
}
if !primed && ring.len() >= target {
primed = true;
}
if primed {
let mut ran_short = false;
if !step.silence {
for slot in out.iter_mut() {
*slot = ring.pop_front().unwrap_or(0.0);
*slot = ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
});
}
cb_counters
.pcm_written
@@ -196,20 +198,15 @@ impl AudioPlayback {
out.fill(0.0);
cb_counters.underruns.fetch_add(1, Ordering::Relaxed);
}
// Re-prime only after a RUN of empty callbacks, not a single transient one —
// otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour,
// self-inflicted crackle on any jitter spike).
if ring.is_empty() {
empties += 1;
if empties >= DEPRIME_AFTER_CALLBACKS {
primed = false;
}
} else {
empties = 0;
}
// No-op while un-primed, so a deliberate priming silence is never counted as an
// underrun (which would otherwise drive the adaptive floor up for no reason).
policy.note_read(ran_short);
cb_counters
.ring_depth
.store(ring.len() as u64, Ordering::Relaxed);
cb_counters
.target_ms
.store(policy.target_ms() as u64, Ordering::Relaxed);
// Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the
// HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are
// both callback-safe / non-blocking, and set clamps to capacity so it self-limits.
@@ -408,10 +405,11 @@ fn decode_loop(
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
"audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64,
counters.target_ms.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
@@ -392,7 +392,7 @@ pub(super) fn run_async(
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
if p.pump(&codec, clock, &tracker, &meter, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
@@ -822,8 +822,21 @@ fn feed_ready(
}
}
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
// Nothing was written and nothing was queued, so BOTH stay ours. Dropping the slot
// here leaked one of the codec's input buffers per occurrence — we forget it and the
// codec never frees what it never received, so the pipeline quietly runs out of input
// slots, `pending_aus` overflows, and the resulting drop storm reads as a decode
// fault. Dropping the AU on top of that punched a hole in the reference chain with no
// keyframe request behind it, unlike every sibling path here.
//
// `break`, not `continue`: a codec that cannot hand out an input buffer it just
// advertised is in no state to be fed the rest of the parked queue this pass, and
// retrying the same index against every parked AU would burn the whole backlog. The
// loop re-runs within the housekeeping wake (≤ 5 ms) if it was transient.
log::warn!("decode: input_buffer({idx}) returned None — retrying next pass");
free_inputs.push_front(idx);
pending_aus.push_front(frame);
break;
};
let au = &frame.data;
if au.len() > dst.len() {
+8 -3
View File
@@ -115,9 +115,14 @@ pub(crate) struct DecodeOptions {
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
/// stream is down-rated below the panel (see `vsync.rs`).
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
///
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
}
+156 -20
View File
@@ -4,10 +4,12 @@
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
//! * a **glass budget of one**: at most one undisplayed release in flight to SurfaceFlinger,
//! reopened on the clock-predicted latch (with a 100 ms stale force-open as the liveness
//! backstop, mirroring Apple's `PresentGate.staleAfter`), and bounded underneath by what
//! `OnFrameRendered` actually confirmed reached glass ([`UNDISPLAYED_CAP`]) — because the
//! prediction is only as good as the panel grid behind it, and 0.23.0 shipped a grid that
//! could be wrong in one direction forever;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
@@ -20,6 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -36,9 +39,9 @@ use super::vsync::VsyncShared;
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
/// signal to widen, not stutter.
/// here is a ms off every frame's display stage. A device that misses at the live margin shows it
/// as a measured latch beyond one panel period (see the adaptation in
/// [`Presenter::flush_log`]) — that, not a drop counter, is the signal to widen.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
@@ -71,6 +74,26 @@ fn latch_margin_ns() -> Option<i64> {
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Releases still unconfirmed by `OnFrameRendered` at which the presenter stops handing
/// SurfaceFlinger more work.
///
/// The reopen above is a PREDICTION off the learned panel grid. A grid finer than the panel
/// (0.23.0 could pin one permanently — see [`punktfunk_core::phase::PanelGrid`]) reopens the
/// budget before the display has consumed anything, and the presenter then releases faster than
/// the panel scans: the BufferQueue fills, MediaCodec runs out of output buffers, the decoder
/// stalls, and the no-output backstop starts begging for keyframes. The render callback is the
/// ground truth about what actually reached glass, so it bounds the prediction.
///
/// Six, not one: the platform is explicitly allowed to deliver these callbacks BATCHED, and this
/// module's own `RENDERED_CAP` note records them trailing a release by a vsync or two — so a
/// healthy device sits at 1-3 outstanding and a tight cap would throttle it for nothing (a held
/// frame in the newest-wins slot is a DROPPED frame the moment a fresher one decodes). This is
/// not a pacing knob; it is the "something is structurally wrong" rail, and a presenter genuinely
/// out-running its display climbs past any fixed cap within a second. If a device's BufferQueue
/// is shallower than this the rail simply never engages and the no-output backstop handles it,
/// exactly as before — best-effort, never worse than not having it.
const UNDISPLAYED_CAP: i32 = 6;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
@@ -121,6 +144,14 @@ struct InFlight {
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
/// Frames released to SurfaceFlinger that `OnFrameRendered` has not yet confirmed reached
/// glass. The presenter's structural rail (see [`UNDISPLAYED_CAP`]) and the pf-present line's
/// queue-depth readout. Lock-free because the release side runs on the decode loop and the
/// confirm side on the codec's callback thread, once per frame each.
undisplayed: AtomicI32,
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
}
struct PresentMeterInner {
@@ -147,11 +178,23 @@ impl PresentMeter {
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
self.confirms.store(true, Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some((v - 1).max(0))
});
let mut g = self
.inner
.lock()
@@ -164,6 +207,26 @@ impl PresentMeter {
}
}
/// One frame handed to SurfaceFlinger, awaiting its confirm. Decode thread.
fn note_released(&self) {
self.undisplayed.fetch_add(1, Ordering::Relaxed);
}
/// Releases still unconfirmed, and whether confirms happen on this device at all.
fn outstanding(&self) -> (i32, bool) {
(
self.undisplayed.load(Ordering::Relaxed),
self.confirms.load(Ordering::Relaxed),
)
}
/// Write off the outstanding releases: the platform stopped confirming (it is allowed to
/// drop callbacks under load) or SurfaceFlinger discarded the buffers without presenting
/// them. Never stall the stream on a ledger we cannot audit.
fn forgive_outstanding(&self) {
self.undisplayed.store(0, Ordering::Relaxed);
}
/// One decoded frame's always-on measurements: the `decode`-stage split (feed =
/// received→queued when a receipt stamp matched; codec = queued→decoded when the queued
/// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof.
@@ -239,6 +302,13 @@ pub(super) struct Presenter {
no_budget: u64,
forced: u64,
dry: u64,
/// Pump passes that held a frame back because too many earlier releases were still
/// unconfirmed ([`UNDISPLAYED_CAP`]) — reads 0 on a healthy device, and a climbing value is
/// the signature of a presenter out-running its display.
queue_waits: u64,
/// When the unconfirmed-release rail first engaged, so it can be forgiven if the confirms
/// simply stopped coming. `None` while the rail is down.
backed_up_since: Option<i64>,
pace_us: Vec<u64>,
last_flush: Instant,
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
@@ -280,6 +350,8 @@ impl Presenter {
no_budget: 0,
forced: 0,
dry: 0,
queue_waits: 0,
backed_up_since: None,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
margin_ns,
@@ -334,6 +406,7 @@ impl Presenter {
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
meter: &PresentMeter,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
@@ -346,6 +419,10 @@ impl Presenter {
self.inflight = None;
}
}
// The measured rail beneath that prediction (see `UNDISPLAYED_CAP`). Evaluated on every
// pass — frame waiting or not — so its forgiveness timer measures real elapsed time
// rather than how often a frame happened to be ready.
let backlogged = self.unconfirmed_backlog(meter, now_mono_ns);
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
@@ -373,9 +450,12 @@ impl Presenter {
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() {
if self.inflight.is_some() || backlogged {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
if backlogged {
self.queue_waits += 1;
}
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
@@ -412,6 +492,7 @@ impl Presenter {
released_at_ns: now_mono_ns,
});
self.released += 1;
meter.note_released();
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
@@ -422,6 +503,33 @@ impl Presenter {
true
}
/// Whether SurfaceFlinger is sitting on too many unconfirmed releases to be handed another.
///
/// The predicted reopen is only as good as the panel grid behind it; this is the measured
/// rail underneath it (see [`UNDISPLAYED_CAP`]). It self-clears two ways — the confirms catch
/// up, or [`STALE_REOPEN_NS`] passes with the backlog stuck, which means the ledger itself is
/// unreliable (callbacks dropped under load, or SF discarded the buffers) and is written off
/// rather than allowed to wedge the stream.
fn unconfirmed_backlog(&mut self, meter: &PresentMeter, now_ns: i64) -> bool {
let (outstanding, confirms_live) = meter.outstanding();
if !confirms_live || outstanding < UNDISPLAYED_CAP {
self.backed_up_since = None;
return false;
}
match self.backed_up_since {
Some(t) if now_ns - t > STALE_REOPEN_NS => {
meter.forgive_outstanding();
self.backed_up_since = None;
self.forced += 1;
false
}
_ => {
self.backed_up_since.get_or_insert(now_ns);
true
}
}
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
@@ -434,7 +542,9 @@ impl Presenter {
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `pace` (decodedrelease) / `latch` (release→displayed) /
/// `qDry` (FIFO underflows) / `qWait` (pumps held back by unconfirmed releases — 0 when
/// healthy) / `unconfirmed` (releases OnFrameRendered hasn't settled) /
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
@@ -462,14 +572,15 @@ impl Presenter {
let circ = clock.and_then(|c| {
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
});
let latch_samples = latch.len();
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ms = clock
.map(|c| c.panel_period_ns() as f64 / 1e6)
.unwrap_or(0.0);
let panel_ns = clock.map(|c| c.panel_period_ns()).unwrap_or(0);
let (outstanding, _) = meter.outstanding();
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
qWait={} unconfirmed={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
@@ -480,6 +591,8 @@ impl Presenter {
self.no_budget,
self.forced,
self.dry,
self.queue_waits,
outstanding,
pace_p50,
pace_max,
latch_p50,
@@ -493,25 +606,48 @@ impl Presenter {
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ms,
panel_ns as f64 / 1e6,
);
self.released = 0;
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
// late and coalesces the next frame into `paced`) mean this device's SF does need
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
// `now + margin`, so a frame that makes its vsync is on glass within one panel period of
// that margin; beyond it, SurfaceFlinger wanted more lead and the frame waited out an
// extra refresh. Widen toward the pre-sweep ceiling. One-way by design: a margin that
// once proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
//
// ⚠ NOT `paced_drops`, which 0.23.0 used: those are the newest-wins store's own policy
// evictions — a second frame decoding while one is held — which happen whenever the
// stream out-runs the panel and say nothing at all about SF's latch lead. Driving the
// margin from them widened it to the ceiling on healthy devices, re-imposing the 2.5 ms
// of pure display latency the P2e sweep had just measured away.
let latch_p50_ns = (latch_p50 * 1e6) as i64;
if !self.margin_pinned
&& self.margin_ns < LATCH_MARGIN_NS
&& panel_ns > 0
&& latch_samples >= 8
&& latch_p50_ns > panel_ns + self.margin_ns
{
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
log::warn!(
"presenter: {} latch misses in 1s — margin widened to {}us",
self.paced_drops,
"presenter: latch p50 {:.2}ms over the {:.2}ms panel period — margin widened to {}us",
latch_p50,
panel_ns as f64 / 1e6,
self.margin_ns / 1_000
);
}
if self.queue_waits > 0 {
log::warn!(
"presenter: {} pump(s) held back — {} release(s) still unconfirmed by \
OnFrameRendered (the display is not keeping up with the release rate)",
self.queue_waits,
outstanding
);
}
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
self.queue_waits = 0;
circ
}
}
+32 -22
View File
@@ -58,8 +58,10 @@ pub(super) struct VsyncShared {
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
/// The panel's own refresh period — the grid SurfaceFlinger actually latches on (0 = unknown).
/// Seeded from the display mode Kotlin resolved at stream start and then corrected by
/// measurement; the learner itself is [`punktfunk_core::phase::PanelGrid`], owned by the
/// choreographer thread (see [`CallbackCtx::panel`]) and published here for the decode loop.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
@@ -231,6 +233,11 @@ struct CallbackCtx {
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
/// The panel-period learner. `Cell` rather than an atomic because it is touched from exactly
/// one thread — callbacks only ever fire inside this thread's looper poll (see the struct
/// doc) — and its streak state is nobody else's business; only the settled period is
/// published, to `shared.panel_period_ns`.
panel: std::cell::Cell<punktfunk_core::phase::PanelGrid>,
}
impl CallbackCtx {
@@ -240,22 +247,25 @@ impl CallbackCtx {
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
// spacing ever observed is the panel's true period — trustworthy where the configured
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
// valid, widening on a later down-rated window never is.
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and therefore the
// only honest witness to what the panel is doing — the configured mode is not (under a
// per-uid frame-rate override `Display.getRefreshRate` REPORTS THE OVERRIDE, observed
// on-glass: a 120 Hz panel read back as 60 while its timelines ran at 8.28 ms), and
// neither is the mode Kotlin *requested* (`preferredDisplayModeId` is a hint the system
// may refuse). Both directions matter and the asymmetry lives in `PanelGrid`.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
if (2_000_000..=42_000_000).contains(&spacing) {
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
if cur == 0 || spacing < cur - 200_000 {
self.shared
.panel_period_ns
.store(spacing, Ordering::Relaxed);
}
let mut grid = self.panel.get();
if grid.observe(spacing) {
self.shared
.panel_period_ns
.store(grid.period_ns(), Ordering::Relaxed);
log::info!(
"vsync: panel grid now {:.2}ms",
grid.period_ns() as f64 / 1e6
);
}
self.panel.set(grid);
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
@@ -372,8 +382,9 @@ pub(super) struct VsyncClock {
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
/// `panel_hz` SEEDS the panel-grid learner (0 = unknown) the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. A seed, not a fact: it names the display
/// mode Kotlin *requested*, and the observed timeline spacing is what settles it. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
@@ -383,11 +394,9 @@ impl VsyncClock {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
0
}),
panel_period_ns: AtomicI64::new(
punktfunk_core::phase::PanelGrid::seeded(panel_hz).period_ns(),
),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
@@ -408,6 +417,7 @@ impl VsyncClock {
choreographer,
shared: thread_shared,
on_tick,
panel: std::cell::Cell::new(punktfunk_core::phase::PanelGrid::seeded(panel_hz)),
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView()
GamepadSettingsView(store: store)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
}
.frame(minWidth: 640, minHeight: 420)
#else
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
@@ -146,7 +146,9 @@ private struct ShotGamepadHome: View {
}
private struct ShotGamepadSettings: View {
var body: some View { GamepadSettingsView() }
@StateObject private var store = ShotMock.hostStore()
var body: some View { GamepadSettingsView(store: store) }
}
private struct ShotGamepadAddHost: View {
@@ -672,7 +672,11 @@ final class SessionModel: ObservableObject {
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared)
// `gamepadForwarding` off means the host gets this device's pads from somewhere else
// (USB passthrough, or a pad plugged into the host) capture still runs, and still
// watches for the escape chord, but puts nothing on the wire.
let capture = GamepadCapture(
connection: conn, manager: .shared, forwarding: settings.gamepadForwarding)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
@@ -10,6 +10,14 @@
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
// with one button. Toggles read left = off, right = on refusing a no-op with the same thud.
//
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker an
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
// Pins are presentation only: never the host's default binding, never the profile itself
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
// per-device catalog the detail strings are honest about).
import PunktfunkKit
import SwiftUI
@@ -21,11 +29,16 @@ import CoreHaptics
struct GamepadSettingsView: View {
@Environment(\.dismiss) private var dismiss
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@ObservedObject var store: HostStore
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@@ -51,6 +64,10 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
#endif
@ObservedObject private var gamepads = GamepadManager.shared
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) the
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
/// interface shows up live.
@ObservedObject private var profiles = ProfileStore.shared
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so more rows fit.
@@ -61,6 +78,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously
#endif
@State private var focusID: String?
/// The pin-to-hosts picker's profile non-nil swaps the row list for one toggle row per
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
@State private var pinTarget: StreamProfile?
/// The direction of the last value step (+1 right/forward, -1 left) picks which edge the
/// changed value slides in from, so the animation follows the user's motion.
@State private var lastAdjustDelta = 1
@@ -71,7 +91,7 @@ struct GamepadSettingsView: View {
focusID: $focusID,
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { dismiss() }
onBack: { back() }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -79,7 +99,7 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
Text("Settings")
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -95,11 +115,7 @@ struct GamepadSettingsView: View {
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
])
GamepadHintBar(hints: hints)
}
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
@@ -137,6 +153,43 @@ struct GamepadSettingsView: View {
.accessibilityLabel("Close settings")
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
pinTarget.map { "Pin “\($0.name)" } ?? "Settings"
}
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
/// picker where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
guard pinTarget != nil else {
return [
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
]
}
guard !store.hosts.isEmpty else {
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
}
return [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
]
}
/// B peels one layer: the pin picker back to the settings rows focus returning to the
/// profile row it came from then the screen itself.
private func back() {
if let profile = pinTarget {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else {
dismiss()
}
}
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
@@ -163,7 +216,7 @@ struct GamepadSettingsView: View {
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 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.
@@ -184,7 +237,7 @@ 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 ? 0.6 : 0))
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
}
}
.padding(.horizontal, m.rowHPad)
@@ -218,6 +271,9 @@ struct GamepadSettingsView: View {
let value: String
/// One-line explanation shown near the hint bar while this row is focused.
let detail: String
/// 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
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
@@ -237,6 +293,9 @@ struct GamepadSettingsView: View {
}
private var rows: [Row] {
// The pin picker replaces the whole list while it's up same screen, one layer deeper,
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
if let profile = pinTarget { return pinRows(for: profile) }
let resolution = resolutionOptions
let refresh = SettingsOptions.refreshRates(including: hz)
.map { (label: "\($0) Hz", tag: $0) }
@@ -323,8 +382,15 @@ struct GamepadSettingsView: View {
+ "speaker setups feeding the game back to the host.",
value: $echoCancel),
toggleRow(
id: "padForward", header: "Controller", icon: "gamecontroller",
label: "Forward controllers",
detail: "Send this device's controllers to the host. Turn it off when your "
+ "controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere — so games don't see two of them.",
value: $gamepadForwarding),
choiceRow(
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
id: "pad", icon: "gamecontroller", label: "Use controller",
detail: "Which pad is forwarded to the host, as player 1.",
options: controllers, current: gamepads.preferredID
) { gamepads.preferredID = $0 },
@@ -386,7 +452,98 @@ struct GamepadSettingsView: View {
at: at + 1)
}
#endif
return list
return list + profileRows
}
// MARK: - Profiles (§5.2a)
/// The trailing Profiles section: one row per catalog profile, its value how many saved
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that this surface
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
/// left/right is a boundary thud, not an editor.
private var profileRows: [Row] {
guard !profiles.profiles.isEmpty else {
return [Row(
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
label: "No profiles yet", value: "",
detail: emptyCatalogDetail,
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return profiles.profiles.enumerated().map { i, profile in
let pins = store.hosts
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
return Row(
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
icon: "slider.horizontal.3", label: profile.name,
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
detail: profileDetail,
adjustable: false,
adjust: { _ in false },
activate: {
// Focus lands on the picker's first row the focus list's reconcile
// follows this id when the row set swaps underneath it.
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
pinTarget = profile
})
}
}
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
/// boundary thud). Writes ride `HostStore.setPinned` pin appends, unpin removes and
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
private func pinRows(for profile: StreamProfile) -> [Row] {
guard !store.hosts.isEmpty else {
return [Row(
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
value: "",
detail: "Pair with a host first, then pin this profile to it.",
adjustable: false,
adjust: { _ in false }, activate: {})]
}
return store.hosts.map { host in
let hostID = host.id
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
return Row(
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
label: host.displayName,
value: pinned ? "Pinned" : "Off",
detail: "A pinned profile appears as its own card on the host — one press "
+ "connects with it.",
adjust: { delta in
let target = delta > 0
guard pinned != target else { return false }
store.setPinned(hostID, profileID: profile.id, pinned: target)
return true
},
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
}
}
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
/// suite nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
/// user at a "standard interface" would promise profiles that can never arrive there.
private var profileDetail: String {
#if os(tvOS)
return "Pin this profile to a host and it appears as its own card on the home screen — "
+ "one press connects with it."
#else
return "Pin this profile to a host and it appears as its own card — one press connects "
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
#endif
}
/// What the empty catalog's placeholder explains again honest on tvOS, where profiles
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
private var emptyCatalogDetail: String {
#if os(tvOS)
return "Profiles bundle stream settings for different uses. Creating them isn't "
+ "available on Apple TV yet."
#else
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
+ "standard interface, then pin them here as one-press connect cards."
#endif
}
/// Resolution choices as "WxH" tags the current size is inserted when it's a custom mode
@@ -122,6 +122,10 @@ enum SettingsFields {
.init(name: "gamepad", key: DefaultsKey.gamepadType,
overlay: \.gamepadType, effective: \.gamepadType)
}
static var gamepadForwarding: SettingsField<Bool> {
.init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding,
overlay: \.gamepadForwarding, effective: \.gamepadForwarding)
}
static var statsVerbosity: SettingsField<String> {
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
overlay: \.statsVerbosity, effective: \.statsVerbosity)
@@ -181,6 +185,7 @@ extension SettingsView {
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
base.fullscreenWhileStreaming = fullscreenWhileStreaming
base.presentPriority = presentPriority
@@ -641,6 +641,15 @@ extension SettingsView {
@ViewBuilder var controllersSection: some View {
Section {
// The master switch, above everything it governs. Profileable, so it renders in
// both scopes: a "Work" profile can decline to forward what "Game" forwards.
described("Sends controllers connected to this device to the host. Turn it off when "
+ "your controller already reaches the host another way — USB passthrough such "
+ "as VirtualHere, or a pad plugged into the host itself — so games don't see "
+ "two of them.",
field: "gamepad_forwarding") {
Toggle("Forward controllers", isOn: scoped(SettingsFields.gamepadForwarding))
}
// Which physical pad this device forwards, and what its own haptics do, are facts
// about THIS device (tier G) only the virtual pad the host creates is profileable.
if !inProfileScope {
@@ -659,6 +668,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
}
described("The virtual pad created on the host. Automatic matches your controller "
@@ -669,6 +679,7 @@ extension SettingsView {
Text(option.label).tag(option.tag)
}
}
.disabled(!effective.gamepadForwarding)
}
#if os(iOS)
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
@@ -49,6 +49,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.gamepadForwarding) var gamepadForwarding = true
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
SettingsOptions.presentPriorityDefault
@@ -3,28 +3,66 @@ import os
/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread render
/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming:
/// reads return silence until enough is buffered (at least `prefill`, and at least one
/// reads return silence until enough is buffered (at least the target, and at least one
/// packet more than the device's render quantum large-buffer devices would otherwise
/// chronically out-demand the prefill and oscillate prime dropout re-prime), and an
/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle.
/// chronically out-demand the prefill and oscillate prime dropout re-prime).
/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip.
///
/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a
/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms
/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by
/// a `highWater` shed that dropped a whole `2 × prefill` at once its own comment called that "one
/// audible blip". It is now the same two-stage scheme the Rust clients share
/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a
/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop.
/// Keep the constants here in step with `JitterTuning.COREAUDIO`.
final class AudioRing: @unchecked Sendable {
/// Mirrors `JitterTuning::COREAUDIO` see that type for the rationale.
private static let targetMS = 20
private static let headroomMS = 30
private static let hardCapMS = 90
private static let deprimeAfter = 4
/// The protocol's frame: the shed unit, and the slack added over a large device quantum.
private static let frameMS = 5
/// Depth average must exceed target by this before drift correction fires the middle of the
/// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims.
private static let shedExcessMS = 15
/// and must stay there for this much consumed audio. Long, because a shed is the only thing
/// here a listener could notice; it must never fire on a transient.
private static let shedSustainMS = 2_000
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
private var buf: [Float]
private var readIdx = 0
private var writeIdx = 0
private var primed = false
private var renderQuantum = 0
private let prefill: Int
private let highWater: Int
private var emptyReads = 0
private var depthAvg: Double = 0
private var overRun = 0
/// Reported, not acted on: short reads that actually starved the callback, and smooth drift
/// corrections. A rising underrun count means the ring is being starved (network or CPU),
/// which is a different problem from the depth being wrong.
private var underrunCount = 0
private var shedCount = 0
private let channels: Int
private let perMS: Int
private let lock = OSAllocatedUnfairLock()
/// `capacity`/`prefill` in samples (interleaved `channels` per frame, both whole frames).
init(capacity: Int, prefill: Int, channels: Int) {
/// `capacity` in samples (interleaved `channels` per frame, a whole number of frames).
/// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill.
init(capacity: Int, channels: Int) {
buf = [Float](repeating: 0, count: capacity)
self.prefill = prefill
self.channels = channels
highWater = prefill * 4
perMS = 48 * channels
}
/// Live target depth in interleaved samples, lifted so it can always serve one device quantum
/// plus a packet (a large-buffer device cannot sustain a target below its own quantum).
private var target: Int {
max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS)
}
func write(_ samples: UnsafePointer<Float>, count: Int) {
@@ -42,12 +80,12 @@ final class AudioRing: @unchecked Sendable {
buf[(writeIdx + i) % capacity] = samples[i]
}
writeIdx += count
// Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or
// creeping host-vs-DAC clock skew) never drains on its own without this, one
// 300 ms hiccup leaves audio 300 ms behind video for the rest of the session.
// Shedding down to 2× prefill costs one audible blip instead.
if writeIdx - readIdx > highWater {
readIdx = writeIdx - prefill * 2
// Backstop only: the smooth shed in `read` is what normally holds the depth down.
let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS)
if writeIdx - readIdx > cap {
readIdx = writeIdx - cap
depthAvg = Double(cap)
overRun = 0
}
}
@@ -57,16 +95,37 @@ final class AudioRing: @unchecked Sendable {
defer { lock.unlock() }
renderQuantum = max(renderQuantum, count)
let available = writeIdx - readIdx
// Depth average, weighted by the callback size so its time constant is independent of the
// device quantum.
let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS))
depthAvg += (Double(available) - depthAvg) * alpha
if !primed {
// One 5 ms host packet (240 frames × channels) of slack beyond the device's demand.
if available >= max(prefill, renderQuantum + 240 * channels) {
if available >= target {
primed = true
emptyReads = 0
} else {
for i in 0..<count { out[i] = 0 }
return
}
}
let n = min(available, count)
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
if depthAvg > Double(target + Self.shedExcessMS * perMS) {
overRun += count
if overRun >= Self.shedSustainMS * perMS {
overRun = 0
shedOneFrame()
shedCount += 1
depthAvg = Double(writeIdx - readIdx)
}
} else {
overRun = 0
}
let n = min(writeIdx - readIdx, count)
let capacity = buf.count
for i in 0..<n {
out[i] = buf[(readIdx + i) % capacity]
@@ -74,9 +133,63 @@ final class AudioRing: @unchecked Sendable {
readIdx += n
if n < count {
for i in n..<count { out[i] = 0 }
primed = false // underrun re-prime before resuming
// De-prime only after a RUN of short reads: a single transient drain must not
// manufacture a whole target's worth of fresh silence.
emptyReads += 1
underrunCount += 1
if emptyReads >= Self.deprimeAfter { primed = false }
} else {
emptyReads = 0
}
}
/// Drop one protocol frame from the front, linearly crossfading the seam so the correction is
/// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds
/// the lock.
private func shedOneFrame() {
let drop = Self.frameMS * perMS
let available = writeIdx - readIdx
guard available > drop else { return }
let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop))
let capacity = buf.count
if fade > 0 {
// The tail of what we discard fades out into the head of what survives.
for i in 0..<fade {
let old = buf[(readIdx + drop - fade + i) % capacity]
let new = buf[(readIdx + drop + i) % capacity]
let t = Float(i + 1) / Float(fade + 1)
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
}
}
readIdx += drop
}
/// Current buffered depth in milliseconds for the stats overlay and the drain thread's
/// periodic log.
var bufferedMS: Int {
lock.lock()
defer { lock.unlock() }
return (writeIdx - readIdx) / max(perMS, 1)
}
/// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in
/// a log line describe the same instant. Mirrors what the three Rust clients report.
struct Stats {
let bufferedMS: Int
let targetMS: Int
let underruns: Int
let sheds: Int
}
var stats: Stats {
lock.lock()
defer { lock.unlock() }
return Stats(
bufferedMS: (writeIdx - readIdx) / max(perMS, 1),
targetMS: target / max(perMS, 1),
underruns: underrunCount,
sheds: shedCount)
}
}
/// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for
@@ -317,10 +317,10 @@ public final class SessionAudio {
// Build the playback layout from the host-RESOLVED channel count (never the request):
// 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR.
let channels = Int(connection.resolvedAudioChannels)
// 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption
// before the first sample plays), both scaled by the channel count.
let ring = self.ring ?? AudioRing(
capacity: 48_000 * channels, prefill: 960 * channels, channels: channels)
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
@@ -403,6 +403,7 @@ public final class SessionAudio {
stateLock.unlock()
let thread = Thread { [connection, flag, drainDone] in
defer { drainDone.signal() }
var drained = 0
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order.
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
@@ -421,6 +422,17 @@ public final class SessionAudio {
ring.write(base, count: pcm.frameCount * pcm.channels)
}
}
// Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients
// log buffer depth and underruns; without this an Apple audio report latency or
// dropout arrives with no numbers at all, which is the position every platform
// was in before the 2026-08 audio work.
drained += 1
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)"
)
}
return true
}
}
@@ -98,9 +98,27 @@ public final class GamepadCapture {
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection, manager: GamepadManager) {
/// Forward this device's controllers to the host at all (`Settings.gamepadForwarding`,
/// default true). Off is for a couch whose controller reaches the host another way USB
/// passthrough such as VirtualHere, or a pad plugged into the host itself where
/// forwarding as well would give the host two pads for one pair of hands.
///
/// Off still opens slots and tracks button state; it just sends nothing (see `wire`). That
/// is deliberate, not laziness: the escape chord is read off the same slots, and on tvOS it
/// is the ONLY controller way out of a stream a session that silently lost its exit
/// because a forwarding preference was off would be a worse bug than the one this fixes.
/// Unlike pf-client-core's slots, GameController claims nothing exclusive, so holding one
/// open costs the host nothing and blocks no passthrough tool.
public let forwarding: Bool
/// The connection, or nil while forwarding is off every wire send goes through this, so
/// "don't forward" is one fact in one place rather than a condition at twelve call sites.
private var wire: PunktfunkConnection? { forwarding ? connection : nil }
public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) {
self.connection = connection
self.manager = manager
self.forwarding = forwarding
}
public func start() {
@@ -205,8 +223,8 @@ public final class GamepadCapture {
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
wire?.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
wire?.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
@@ -233,7 +251,7 @@ public final class GamepadCapture {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
connection.send(.gamepadRemove(pad: slot.pad))
wire?.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
@@ -275,7 +293,7 @@ public final class GamepadCapture {
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
slot.buttons = newButtons
}
@@ -288,7 +306,7 @@ public final class GamepadCapture {
Int32(g.rightTrigger.value * 255),
]
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
@@ -302,7 +320,7 @@ public final class GamepadCapture {
let bit = GamepadWire.guide
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
@@ -365,13 +383,13 @@ public final class GamepadCapture {
if lifted {
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
@@ -394,7 +412,7 @@ public final class GamepadCapture {
}
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
wire?.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
@@ -432,15 +450,15 @@ public final class GamepadCapture {
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
wire?.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
wire?.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
wire?.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
@@ -175,6 +175,56 @@ public final class StreamViewController: StreamViewControllerBase {
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
private var matchFollower: MatchWindowFollower?
// MARK: Escape-drop re-lock
//
// iPadOS releases the pointer lock BY ITSELF when the user presses Escape the platform's
// built-in "let me out", mirroring the web Pointer Lock API's default unlock gesture. Nothing
// in our code does it: a bare Esc never touches `captured`, so it keeps forwarding to the host
// as the game key it is. But the lock going away flips the mouse onto the absolute UIKit path
// and un-hides the iPadOS cursor, so hitting Esc for an in-game menu silently costs the capture
// until the user clicks to win it back. Esc is a GAME key here, not a request to hand the
// pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases
// (, Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock`
// is already false when their drop is observed and none of them are fought here.
//
// Recovery is TWO-STAGE, because either stage alone leaves a hole:
// 1. the burst below, fired the instant the drop is observed wins back a lock the system
// is willing to return immediately (a transient drop that wasn't Escape at all);
// 2. a CLICK into the video while still captured (`onPointerButton`) the fallback for the
// Escape case proper, where the platform declines during the moment right after its own
// release gesture and the burst therefore expires having achieved nothing.
// Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no
// other path would ever ask again, and the capture would spend the rest of its life on the
// absolute pointer clicking correctly, aiming not at all.
/// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back
/// never having been granted one means the scene doesn't qualify, not that Esc took it.
/// Cleared when capture ends, so each capture starts from a clean slate.
private var pointerLockWasEngaged = false
/// Attempts spent in the current re-lock burst, and when the burst began.
private var pointerRelockAttempt = 0
private var pointerRelockBurstStart: CFTimeInterval = 0
/// True from an unwanted drop until the lock is back (or the burst gives up). While pending,
/// the local cursor stays hidden and absolute pointer MOTION stays muted, so a re-lock that
/// lands a frame or two later is invisible instead of flashing the iPadOS cursor and
/// teleporting the host's to the pointer's absolute position.
private var pointerRelockPending = false
/// Forces `prefersPointerLocked` to report false for one resolve pass, so the escalated attempt
/// presents the system with a genuine falsetrue transition instead of re-asserting a value it
/// already holds. See `requestPointerRelock()`.
private var pointerLockForcedOff = false
/// A burst is 3 attempts, and a burst can't restart inside 2 s. A scene the system will never
/// lock (Stage Manager, Split View) therefore costs three cheap re-resolves and then falls back
/// to today's click-to-recapture, rather than retrying forever.
private static let pointerRelockAttemptLimit = 3
private static let pointerRelockBurstWindow: CFTimeInterval = 2
/// Gap between attempts in a burst long enough for the system to answer the previous
/// re-resolve, short enough that the whole burst fits in ~0.6 s. Must exceed
/// `pointerLockForcedOffHold` so an escalated attempt is back to preferring the lock before the
/// next attempt evaluates.
private static let pointerRelockRetryDelay: TimeInterval = 0.2
/// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back,
/// so the system observes a real transition instead of coalescing the flip away.
private static let pointerLockForcedOffHold: TimeInterval = 0.05
#endif
/// Reads whether the scene's pointer is actually locked right now; nil = state
@@ -260,7 +310,7 @@ public final class StreamViewController: StreamViewControllerBase {
captured && pointerCaptureEnabled && UIDevice.current.userInterfaceIdiom == .pad
}
public override var prefersPointerLocked: Bool { wantsPointerLock }
public override var prefersPointerLocked: Bool { wantsPointerLock && !pointerLockForcedOff }
public override var prefersHomeIndicatorAutoHidden: Bool { true }
// NOTE: we deliberately do NOT override `childViewControllerForPointerLock`. The default
@@ -383,6 +433,11 @@ public final class StreamViewController: StreamViewControllerBase {
// is the exact mirror of the GCMouse handlers, which fire only while locked.
streamView.onPointerMoveAbs = { [weak self] p in
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
// A re-lock is in flight after an Esc-drop: the absolute path would teleport the host
// cursor to wherever the local pointer sits, undoing the relative aiming we're about to
// resume. Motion only BUTTONS still forward (they carry no position, so a click during
// the couple of frames a re-lock takes must not be swallowed mid-firefight).
guard !self.pointerRelockPending else { return }
self.inputCapture?.sendMouseAbs(
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
}
@@ -401,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase {
}
guard self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendMouseButton(button, pressed: down)
// and if we're captured but NOT locked, this click is also the recovery gesture for an
// Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its
// own "let me out" gesture, so the burst fired at the drop can spend its whole budget
// and give up while the capture is still wanted. Nothing else would ever re-ask
// setCaptured is the only other requester and a bare Esc never clears `captured` so
// without this the session stays on the absolute path for the rest of the capture:
// clicks still land where you aim (absolute positions keep forwarding) but the game
// gets no relative deltas, so camera look is dead. A click is a real user gesture,
// which is exactly what the platform wants before it will hand the lock back.
//
// On the button UP, so the click has fully forwarded on ONE transport first: asking on
// the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse
// path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that
// never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not
// already being in flight a pending burst mutes absolute motion, so re-arming one on
// every click of a menu the user is still aiming around would freeze the cursor between
// clicks. Only once it has settled does a further click buy a fresh budget (clearing the
// attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst
// may have just spent).
if !down, self.wantsPointerLock, self.pointerLockWasEngaged,
!self.pointerRelockPending, self.pointerLockEngaged() != true {
self.pointerRelockAttempt = 0
self.updatePointerLockChain() // a reparent since the drop would break the walk to us
self.requestPointerRelock()
}
}
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
@@ -693,6 +773,24 @@ public final class StreamViewController: StreamViewControllerBase {
/// change and capture toggle. Main queue.
private func syncPointerLock() {
let locked = pointerLockEngaged() == true
// Wanted, previously HELD, and now gone is the Esc-drop signature. The "previously held"
// half matters: a lock that was never granted is a scene that doesn't qualify (Stage
// Manager, Split View), and burst-requesting there would hide the cursor for the burst's
// duration to win a lock that isn't coming. A first grant is already driven by the chain
// engage in setCaptured/viewDidAppear.
if locked {
pointerLockWasEngaged = true
pointerRelockPending = false
pointerRelockAttempt = 0
} else if wantsPointerLock, pointerLockWasEngaged {
requestPointerRelock()
} else {
// Capture is gone (or the lock was never ours) settle, and let the next capture
// start from a clean "never held" slate.
if !wantsPointerLock { pointerLockWasEngaged = false }
pointerRelockPending = false
pointerRelockAttempt = 0
}
let useGCMouse = captured && locked
// Lock dropped (or capture ended) while the GCMouse path held a button down: once
// gcMouseForwarding flips false its release handler is gated off, so flush any held
@@ -704,7 +802,83 @@ public final class StreamViewController: StreamViewControllerBase {
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
if iosInputDebug {
iosInputLog.debug(
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
"""
pointer lock isLocked=\(locked, privacy: .public) \
captured=\(self.captured, privacy: .public) \
relockPending=\(self.pointerRelockPending, privacy: .public) \
relockAttempt=\(self.pointerRelockAttempt, privacy: .public)
""")
}
}
/// Ask the system for the lock back after it dropped one we still want (see the Escape-drop
/// note on the state above). Bounded to a short burst; idempotent within it. Main queue.
private func requestPointerRelock() {
// Only a frontmost scene can hold the lock at all. Anywhere else the drop is the system
// saying we don't qualify, not the Esc key re-asking would be noise, and the qualifying
// states (foreground, appearance, reparent) each re-resolve on their own already.
guard view.window?.windowScene?.activationState == .foregroundActive else {
pointerRelockPending = false
return
}
let now = CACurrentMediaTime()
// attempt == 0 is a fresh burst (first drop, or one the settle branch cleared); the window
// is the backstop for the pathological case where a grant is immediately revoked again and
// re-arms us. Even then this stays timer-driven at a few Hz never a spin.
if pointerRelockAttempt == 0 || now - pointerRelockBurstStart > Self.pointerRelockBurstWindow {
pointerRelockBurstStart = now
pointerRelockAttempt = 0
}
guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else {
// Out of budget: fall back to exactly today's behavior the iPadOS cursor comes back
// and a click into the video re-captures. The caller invalidates the interaction, so
// the cursor can never stay hidden on a lock the system won't grant.
pointerRelockPending = false
return
}
pointerRelockAttempt += 1
pointerRelockPending = true
let escalate = pointerRelockAttempt > 1
// Deferred a turn so a whose GC keystroke lands after the system's unlock notification
// has already cleared `captured` then the guard below drops this attempt instead of
// fighting the user's own release.
DispatchQueue.main.async { [weak self] in
guard let self, self.pointerRelockPending else { return }
guard self.wantsPointerLock, self.pointerLockEngaged() != true else {
// The grant landed, or the capture went away under us ( / Q / resign).
// Settle through the one decision point rather than returning with `pending` still
// set that flag hides the cursor, so it must never outlive the burst.
self.syncPointerLock()
return
}
if escalate {
// Re-asserting a value the system already holds didn't take. Present a real
// falsetrue transition instead the documented way to change your mind about the
// lock and re-anchor the chain in case a reparent broke the downward walk to us.
// Held for a beat rather than cleared on the next turn: the system resolves the
// property asynchronously, and a same-turn flip back to true can be coalesced into
// no transition at all. We are already unlocked, so the false pass costs nothing.
self.pointerLockForcedOff = true
self.setNeedsUpdateOfPrefersPointerLocked()
self.updatePointerLockChain()
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) {
[weak self] in
guard let self else { return }
self.pointerLockForcedOff = false
self.setNeedsUpdateOfPrefersPointerLocked()
}
} else {
self.setNeedsUpdateOfPrefersPointerLocked()
}
// A GRANT arrives as a didChange syncPointerLock, which settles the burst and makes
// this retry a no-op. Routed back through syncPointerLock (not straight into another
// requestPointerRelock) so the give-up path re-resolves the cursor through the one
// place that does it.
DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerRelockRetryDelay) {
[weak self] in
guard let self, self.pointerRelockPending else { return }
self.syncPointerLock()
}
}
}
#endif
@@ -724,7 +898,11 @@ extension StreamViewController: UIPointerInteractionDelegate {
// host renders its own cursor from GCMouse deltas and a visible local one would just
// diverge. When the lock isn't held the cursor stays VISIBLE so the user can aim; the
// pointer is forwarded as an absolute position, both cursors tracking together.
captured && pointerLockEngaged() == true ? .hidden() : nil
// except across an Esc-drop we're actively re-locking (`pointerRelockPending`): staying
// hidden for those couple of frames is what turns the fix into "Esc did nothing to my
// mouse" rather than a cursor that blinks in and out. The burst is bounded and clears
// itself on give-up, so the cursor can never stay hidden on a lock that isn't coming.
captured && (pointerLockEngaged() == true || pointerRelockPending) ? .hidden() : nil
}
}
#endif
@@ -32,6 +32,12 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
/// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts
/// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built.
public static let gamepadForwarding = "punktfunk.gamepadForwarding"
public static let bitrateKbps = "punktfunk.bitrateKbps"
/// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
/// can capture; the resolved count drives the in-core decode + AVAudioEngine layout.
@@ -34,6 +34,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var mouseMode = "capture"
public var invertScroll = false
public var gamepadType = 0
public var gamepadForwarding = true
/// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see.
public var statsVerbosity = "normal"
public var fullscreenWhileStreaming = true
@@ -93,6 +94,7 @@ public struct EffectiveSettings: Equatable, Sendable {
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
gamepadType = int(DefaultsKey.gamepadType, gamepadType)
gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding)
statsVerbosity = Self.storedStatsVerbosity(defaults)
fullscreenWhileStreaming = bool(
DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming)
@@ -140,6 +142,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.mouseMode { s.mouseMode = v }
if let v = overlay.invertScroll { s.invertScroll = v }
if let v = overlay.gamepadType { s.gamepadType = v }
if let v = overlay.gamepadForwarding { s.gamepadForwarding = v }
if let v = overlay.statsVerbosity { s.statsVerbosity = v }
if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v }
if let v = overlay.enable444 { s.enable444 = v }
@@ -110,6 +110,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var mouseMode: String?
public var invertScroll: Bool?
public var gamepadType: Int?
public var gamepadForwarding: Bool?
/// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") the enum lives in
/// PunktfunkKit, which this module must not depend on.
public var statsVerbosity: String?
@@ -151,6 +152,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
case gamepadType = "gamepad"
case gamepadForwarding = "gamepad_forwarding"
case statsVerbosity = "stats_verbosity"
case fullscreenWhileStreaming = "fullscreen_on_stream"
case enable444 = "enable_444"
@@ -184,6 +186,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
gamepadType = int(.gamepadType)
gamepadForwarding = bool(.gamepadForwarding)
statsVerbosity = str(.statsVerbosity)
fullscreenWhileStreaming = bool(.fullscreenWhileStreaming)
enable444 = bool(.enable444)
@@ -219,6 +222,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue))
try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue))
try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue))
try c.encodeIfPresent(
gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue))
try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue))
try c.encodeIfPresent(
fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue))
@@ -271,6 +276,7 @@ public enum OverlayField {
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
case "gamepad": overlay.gamepadType = nil
case "gamepad_forwarding": overlay.gamepadForwarding = nil
case "stats_verbosity": overlay.statsVerbosity = nil
case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil
case "enable_444": overlay.enable444 = nil
@@ -306,6 +312,7 @@ public enum OverlayField {
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
case "gamepad": return o.gamepadType != nil
case "gamepad_forwarding": return o.gamepadForwarding != nil
case "stats_verbosity": return o.statsVerbosity != nil
case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil
case "enable_444": return o.enable444 != nil
@@ -0,0 +1,95 @@
// The Apple half of the shared de-jitter policy (`punktfunk_core::audio::JitterPolicy`, whose
// constants `AudioRing` mirrors). These pin the two behaviours a listener actually notices, in the
// one client where the policy is hand-written in a second language rather than shared as code so
// a divergence from the Rust side shows up here rather than as a field report.
//
// The defect being pinned: the ring primed *up* to a target and clamped at a ceiling, with nothing
// walking the depth back *down*. Host-vs-DAC clock skew of a few dozen ppm therefore added latency
// permanently, and the only correction was a `highWater` shed that dropped `2 x prefill` at once
// its own comment called that "one audible blip".
#if !os(tvOS)
import XCTest
@testable import PunktfunkKit
final class AudioRingDriftTests: XCTestCase {
private let channels = 2
private var perMS: Int { 48 * channels }
/// Run `ms` of audio through the ring at a `quantumMS` device where the producer delivers
/// `driftPPM` more than the consumer takes. Returns `(final ms, peak ms, silent callbacks)`.
private func simulate(ms: Int, quantumMS: Int, driftPPM: Int) -> (Int, Int, Int) {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = quantumMS * perMS
var scratch = [Float](repeating: 0, count: want)
// Non-zero so a silent callback is distinguishable from real audio.
let producer = [Float](repeating: 0.25, count: want + 8)
var carry = 0, peak = 0, final = 0, silent = 0
for i in 0..<(ms / quantumMS) {
carry += want * driftPPM
let extra = carry / 1_000_000
carry -= extra * 1_000_000
producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
// Skip the priming window at the very start.
if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
peak = max(peak, ring.bufferedMS)
final = ring.bufferedMS
}
return (final, peak, silent)
}
/// THE regression: with the host clock running fast, buffered latency must return to target
/// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately
/// harsher than real hardware (tens of ppm).
func testDriftDoesNotRatchetLatencyToTheCeiling() {
let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200)
// Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap.
XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet")
XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms")
XCTAssertEqual(silent, 0, "drift correction must never starve the callback")
}
/// The mirror case: a host clock running SLOW must keep audio flowing rather than being
/// "corrected" into a stutter.
func testNegativeDriftKeepsPlaying() {
let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200)
XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter")
}
/// A device that pulls a large quantum cannot sustain a target below it the ring must lift
/// its target rather than oscillating prime dropout re-prime forever.
func testLargeDeviceQuantumStillPlays() {
let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0)
XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target")
}
/// One transient drain must not manufacture a whole target's worth of fresh silence: the ring
/// de-primes only after a RUN of short reads.
func testSingleShortReadDoesNotDeprime() {
let ring = AudioRing(capacity: 48_000 * channels, channels: channels)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
// Prime well past target.
let big = [Float](repeating: 0.5, count: 60 * perMS)
big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming")
// Drain it dry with one oversized read, then feed a normal quantum again. The length comes
// off the buffer pointer, not off `huge`: touching the array inside the closure that is
// already holding it exclusively is an exclusivity violation.
var huge = [Float](repeating: 0, count: 200 * perMS)
huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) }
let feed = [Float](repeating: 0.5, count: want)
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) }
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
XCTAssertTrue(
scratch.contains { $0 != 0 },
"a single short read must not force a full re-prime")
}
}
#endif
+51
View File
@@ -0,0 +1,51 @@
# App Store copy
Source of truth for what goes into App Store Connect. Every character-limited field in here has
been counted with `check-limits.py`; run it after any edit.
```sh
python3 clients/apple/store/check-limits.py
```
| File | Covers |
|------|--------|
| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates |
| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) |
| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) |
| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist |
| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page |
German is primary throughout and uses the same informal "du" voice as the website
(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a
few lines diverge where the German idiom does not carry.
## Three things that contradicted the original brief
1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server
or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual
display, or encode backend. The macOS copy is client-only and says so explicitly.
2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a
language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is
the kind of thing that draws a reviewer's attention to analytics that have nothing to do with
the app. See `privacy-app-addendum.md` for the text to append.
3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The
template is 3919 and fits.
## Claims used, and where they come from
Everything asserted in the copy was checked against the source rather than the marketing site:
- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md`
- Entitlements and their justifications — `Config/Punktfunk.entitlements`,
`Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments)
- Background audio mode and its 2.5.4 constraints — `Config/Info.plist`
- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry
symbols in `Sources/`, `URLSession` used only against the paired host
- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md`
- Feature ship dates — `git tag --contains` on the relevant commits
## Not done here
`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a
required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left
alone because it is a code change, not copy.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Check every App Store copy block in this directory against its field limit.
App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the
easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters,
so `len()` on a `str` is the right measure do not switch this to a byte count.
Each fenced code block in the .md files here is one field. Which limit applies is inferred from the
nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it.
"""
from __future__ import annotations
import pathlib
import re
import sys
LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000}
def blocks(text: str):
"""Yield (heading, body) for every fenced block, tagged with the heading above it."""
heading = None
buf: list[str] | None = None
for line in text.split("\n"):
if line.startswith("#") and buf is None:
heading = line.lstrip("#").strip()
if line.strip() == "```":
if buf is None:
buf = []
else:
yield heading or "", "\n".join(buf)
buf = None
continue
if buf is not None:
buf.append(line)
def kind_of(heading: str, body: str) -> str:
low = heading.lower()
if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low):
return "KW"
if "template" in low:
return "NOTES"
return "DESC" if len(body) > 400 else "PROMO"
def main() -> int:
here = pathlib.Path(__file__).parent
failures = 0
stale = 0
for path in sorted(here.glob("*.md")):
found = list(blocks(path.read_text(encoding="utf-8")))
if not found:
continue
print(f"\n=== {path.name} ===")
for heading, body in found:
kind = kind_of(heading, body)
limit = LIMITS[kind]
n = len(body)
over = n > limit
failures += over
# Headings carry the count in parentheses; flag any that drifted from the real length.
claimed = re.search(r"\((\d+)\)\s*$", heading)
drift = ""
if claimed and int(claimed.group(1)) != n:
drift = f" [heading claims {claimed.group(1)}]"
stale += 1
status = "OVER" if over else "ok"
print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}")
if failures:
print(f"\n{failures} block(s) OVER the limit")
elif stale:
print(f"\nAll within limits, but {stale} heading count(s) are stale")
else:
print("\nAll blocks within limits, all heading counts accurate")
return 1 if failures or stale else 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
# iOS / iPadOS — App Store metadata
Existing, unchanged:
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
Only the Promotional Text is new here. It is the one field that can be changed **without** a new
build or a review, so it is the right place for "what landed most recently".
---
## Promotional Text (DE) — max 170 characters
### Primary (160)
```
Neu: Profile pro Host Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN.
```
### Alternate A — evergreen hook, no "new" claim (156)
```
Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem.
```
### Alternate B — leads on the DualSense (161)
```
Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus.
```
### Alternate C — leads on latency (153)
```
Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen.
```
---
## Promotional Text (EN) — max 170 characters
### Primary (152)
```
New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN.
```
### Alternate A — evergreen hook (159)
```
Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full.
```
### Alternate B — leads on the DualSense (160)
```
Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room.
```
---
## Notes on the claims
- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is
the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but
drop the word once 0.25 ships something newer.
- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to
*mention* but should not be called "neu".
- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch
(`b297542c`), which is too niche to headline.
+159
View File
@@ -0,0 +1,159 @@
# macOS — App Store metadata
> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host`
> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits
> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host
> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a
> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API.
> None of the copy below claims a Mac can host, and it should not until that ships.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (164)
```
Neu: Profile pro Host ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift.
```
### Alternate (156)
```
Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung.
```
## Promotional Text (EN) — max 170 characters
### Primary (161)
```
New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text.
```
### Alternate (156)
```
Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk streamt deinen Gaming-PC auf den Mac in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt.
DEIN MAC BEKOMMT SEIN EIGENES DISPLAY
Für jede Verbindung legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer).
FÜR DEN MAC GEMACHT
• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display
• Maus und Tastatur gehen vollständig durch Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben
• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden
• Mikrofon-Uplink mit Echounterdrückung dein Mac wird zum Headset am PC
• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt.
DEINE BIBLIOTHEK, DEIN NETZWERK
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN.
MESSEN STATT GLAUBEN
Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all.
YOUR MAC GETS A DISPLAY OF ITS OWN
For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later).
BUILT FOR THE MAC
• In a window or full screen, on any attached display
• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release
• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay
• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC
• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands.
YOUR LIBRARY, YOUR NETWORK
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN.
MEASURED, NOT PROMISED
A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and
the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately
absent — repeating them would waste characters.
### DE (97)
```
streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen
```
### EN (95)
```
streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play
```
**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is
GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the
metadata rules disallow third-party app, product, and company names in the **keyword** field — it is
a routine rejection. Saying it in the description is fine; the current descriptions avoid naming
Moonlight and mention Steam only as a factual statement about your own library.
The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces,
on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem.
+146
View File
@@ -0,0 +1,146 @@
# Privacy — what to link from App Store Connect
## The situation
You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current
(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers
server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and
self-hosted fonts. It does not mention the apps at all.
That is a problem for App Store Connect in two directions:
1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following
the link finds a page about a website.
2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes
analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible
Analytics" and has every reason to question the App Privacy answers.
**Recommendation:** keep the existing page and append an app-specific section to it (the text
below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also
works, but one URL is less to keep in sync.
The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the
`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a
code change.
## Confirming the "collects no data" framing
Checked against the source rather than taken on trust, and it holds:
- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency.
A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude|
mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in
haptics code (rumble amplitude), and one the English word "plausible" in a comment.
- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art
**from the paired host**, over a TLS session that pins the host's own certificate. The only
external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site,
the source on `git.unom.io`, and the Discord invite.
- **No account system.** Identity is a client keypair in the device keychain
(`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device.
- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite
(`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit
entitlement.
- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking.
So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth
stating in the policy text anyway, because they are true and pre-empt questions:
- The microphone uplink **is** audio leaving the device — but only to the host the user paired with,
encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your
third-party partners*; streaming to the user's own machine is not collection. Saying so plainly
is better than staying silent about a microphone permission.
- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is
Apple's processing, not yours, but naming it avoids looking like an omission.
---
## Text to append — Deutsch
> ## Die Punktfunk-Apps
>
> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und
> Android im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen.
>
> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine
> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder
> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App
> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte
> übermittelt.
>
> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie
> selbst betreiben in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und
> Controller-Eingaben sowie sofern Sie ihn einschalten Ihr Mikrofon werden ausschließlich
> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server
> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste
> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung.
>
> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen
> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen
> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist
> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht,
> wenn Sie die App entfernen.
>
> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den
> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und nur
> wenn Sie die Mikrofonübertragung nutzen das Mikrofon. Das Mikrofonsignal wird an den von Ihnen
> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht
> aufgezeichnet und nicht an uns gesendet.
>
> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen,
> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations-
> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die
> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder
> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu.
>
> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen
> Rechner betreiben. Welche Daten dabei anfallen etwa lokale Protokolldateien , bleibt
> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter
> git.unom.io/unom/punktfunk einsehbar.
---
## Text to append — English
> ## The Punktfunk apps
>
> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and
> Android — as distinct from the sections above, which concern this website.
>
> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in.
> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries.
> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no
> data is transmitted to us or to any third party.
>
> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run
> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and
> your microphone, if you switch it on — travel only between your device and that host, encrypted,
> without passing through any server of ours. We operate no brokering, relay, or cloud service for
> streaming, and we have no access to the contents of a session at any point.
>
> **What stays on your device.** The app stores locally on your device: the hosts you have added or
> discovered on your network, your settings and profiles, and a cryptographic key your device uses
> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave
> your device and is removed when you delete the app.
>
> **Permissions.** The app requests only the permissions it needs to work: access to the local
> network, in order to find hosts and connect to them, and — only if you use microphone streaming —
> the microphone. The microphone signal is sent to the host you paired with, where it appears as a
> virtual microphone; it is not recorded and is not sent to us.
>
> **Distribution through app stores.** If you obtain the app from the App Store or Google Play,
> Apple or Google process their own data as part of distributing it (such as purchase, installation,
> and crash statistics). We have no influence over this, and the respective provider's privacy
> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do
> not allow any individual to be identified.
>
> **The host.** The Punktfunk host is open source software that you run on your own machine. Any
> data it produces — local log files, for instance — remains entirely under your control, and none
> of it reaches us. The source is available at git.unom.io/unom/punktfunk.
---
## Also update
- Bump **Stand: / Effective date:** on the page when you add this.
- App Store Connect → App Privacy → **Data Not Collected** for all four platforms.
- The same URL works for Google Play's Data safety declaration; the wording above already covers it.
+132
View File
@@ -0,0 +1,132 @@
# App Review notes
## The core problem, stated plainly
Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a
pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release
build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG`
and are compiled out of anything you ship. A reviewer who launches the App Store build with no host
on their network sees an empty "On this network" list.
Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must
attach **one** of:
- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host
exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client
can add a host by IP or hostname, so mDNS discovery is not required for this path.
- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a
reviewer who cannot reproduce is a reviewer who can reject on something unrelated.
**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you
pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots.
> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST"
> section with the video URL and say explicitly that no host can be provided.
---
## Notes template — paste into App Store Connect
The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**,
and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is
longer than the values that replace it). If you add to it, re-check the count — an over-long note
is silently truncated, and what gets cut is the end, where the privacy and entitlement answers
live.
```
WHAT THIS APP IS
Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user
installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is
separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold,
and this app has no purchases.
This app is the client half only: it renders video and audio from the user's own machine and
sends input back. There is no content library and no server of ours in a session.
IMPORTANT: THIS APP NEEDS A HOST
With no reachable host, the app can only show its host list, the pairing screen and settings --
inherent to what it is, not an incomplete build. We have provided a live host for review.
CONNECTING TO OUR DEMO HOST
1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so
add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen.
2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]]
Name it anything, then confirm.
3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]]
A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN.
4. The host's game library appears as a grid. Select any title to stream; video and audio start
within a few seconds.
5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release
mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D.
6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host
"Speed test" suggests a bitrate for the link.
The host stays reachable throughout review. If you cannot reach it, please contact
[[FILL IN: contact email]] and we will restore it promptly.
WHY THE APP ASKS FOR WHAT IT ASKS FOR
- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's
entire purpose.
- Microphone (optional, off by default): audio goes to the user's own paired host, appearing
there as a virtual microphone for voice chat. Never recorded, never sent to us.
- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address:
a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else.
- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers
through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also
drives DualSense rumble, which CoreHaptics will not. Without these, no controller input.
- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our
QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams;
without this, no video, audio or rumble arrives.
- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host,
and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only
the real audio keeps rendering, and a bounded timer disconnects automatically. We never play
silence to stay alive, nor use the mode outside an audible session.
REGARDING BUILD 0.4.2 (3384)
That build was rejected under 2.4.5(i) for a temporary-exception entitlement
(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics
rumble under the App Sandbox. We have since verified rumble works without it; this build carries
no temporary exception.
ACCOUNTS, PURCHASES, DATA
No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics,
tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a
session. Device identity is a keychain keypair used only to authenticate to the user's own host.
Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]]
```
---
## Before you submit — checklist
- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five.
- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular,
not on the LAN it lives on. This is the failure mode that wastes a review cycle.
- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the
review window, and that pairing is left open (it is on-demand in the web console).
- [ ] Put at least one launchable title in the demo host's library. An empty grid after a
successful pairing looks like a broken app.
- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A
reviewer will not have a controller paired, and "requires an accessory to navigate" is a
tvOS rejection.
- [ ] Attach the demo video as a URL in the notes if you are going the (b) route.
## Separately worth checking: the privacy manifest
There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use
`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of
Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps
missing a declaration typically get an automated **ITMS-91053** notice on upload.
This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it
is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code
`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next
submission. Confirm the current reason codes against Apple's documentation rather than taking the
code above on trust; the list has changed since it was introduced.
+145
View File
@@ -0,0 +1,145 @@
# tvOS — App Store metadata
Client only, living-room framing. Things the other platforms have that the **Apple TV does not**,
and which the copy therefore avoids claiming:
- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the
headset" line does not transfer.
- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and
macOS only — tvOS uses the native focus engine instead.
- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only.
- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out.
Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers
including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single
best Apple TV feature, because it is what removes the trip to the other room.
- **Name:** Punktfunk
- **Subtitle (DE):** Schnell, lokal & offen.
- **Subtitle (EN):** Fast, local & open.
---
## Promotional Text (DE) — max 170 characters
### Primary (161)
```
Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller.
```
### Alternate A — leads on the picture (157)
```
Dein Gaming-PC am großen Bildschirm in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk.
```
### Alternate B — leads on the DualSense (160)
```
Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN.
```
## Promotional Text (EN) — max 170 characters
### Primary (160)
```
Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands.
```
### Alternate A — leads on the picture (148)
```
Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network.
```
---
## Description (DE) — max 4000 characters
```
Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud.
Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 auch headless auf einem Rechner, an dem gar kein Monitor hängt.
VOM SOFA AUS, VON ANFANG BIS ENDE
Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen.
DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN
Für den Apple TV legt der Host ein echtes virtuelles Display an in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen.
CONTROLLER, VOLLSTÄNDIG
DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus.
DEINE BIBLIOTHEK AUF DEM FERNSEHER
Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet.
SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT
Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor.
WAS DU BRAUCHST
Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos Anleitungen und Quellcode findest du auf punktfunk.unom.io.
Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich.
```
---
## Description (EN) — max 4000 characters
```
Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud.
Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all.
FROM THE COUCH, START TO FINISH
Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen.
THE PICTURE YOUR TV CAN ACTUALLY SHOW
For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened.
CONTROLLERS, IN FULL
DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse.
YOUR LIBRARY ON THE BIG SCREEN
Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk.
FAST, BECAUSE WE OWN THE WHOLE PATH
The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network.
WHAT YOU NEED
A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io.
No account. No cloud. No telemetry. This app collects no data about you.
```
---
## Keywords — max 100 characters
### DE (93)
```
streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch
```
### EN (91)
```
streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play
```
Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field.
+10 -3
View File
@@ -24,8 +24,15 @@ the panel looks and feels native to Gaming Mode.
browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive
plugin reinstalls (stored next to the client's config) and follow a host across IP changes
(matched by certificate fingerprint).
5. **Settings**resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
5. **Settings**the client's whole settings store, written to its config. Laid out like SteamOS's
own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs
scrolling. The categories and their order are the console settings screen's — Stream (resolution
/ refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4),
Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic
device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake /
library / fullscreen). The device pickers are populated
from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where
there is more than one adapter.
6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
@@ -93,7 +100,7 @@ restart is required for an out-of-band install to appear.
| --- | --- |
| `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
+152 -4
View File
@@ -21,6 +21,10 @@ The backend's jobs are the things Steam can't do:
the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
``set_settings`` MERGES onto the file: it is shared with the desktop client and the console.
* **list_devices() / refresh_devices()** the GPUs and audio endpoints the settings tab's
device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``)
and cached, since enumerating them costs a Vulkan + PipeWire init.
* **kill_stream()** force-stop a wedged stream (``flatpak kill``).
* **check_update()** report pending updates for BOTH the plugin and the client. The plugin's
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
@@ -343,6 +347,9 @@ def _flatpak() -> str | None:
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
# home), so nothing else in this file has to care which one answered.
NATIVE_BIN = "punktfunk-client"
# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate
# this device's GPUs and audio endpoints for the settings pickers.
SESSION_BIN = "punktfunk-session"
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
@@ -398,6 +405,25 @@ def _client_argv() -> list[str] | None:
return [native] if native else None
def _session_argv() -> list[str] | None:
"""The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there.
The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on
`punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and
shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The
flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native
install puts them in the same bindir, so the session is the client's sibling.
"""
prefix = _client_argv()
if not prefix:
return None
if prefix[0] == _flatpak():
# `flatpak run --command=<bin> <app>` — the app id must stay LAST.
return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]]
sibling = Path(prefix[0]).with_name(SESSION_BIN)
return [str(sibling)] if sibling.exists() else None
def _client_is_flatpak() -> bool:
"""Is the client this plugin actually drives the FLATPAK one?
@@ -511,6 +537,63 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
return -1, "", ""
def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]:
"""Split `punktfunk-session --list-audio` into ``(sinks, sources)``.
Its format is one endpoint per line, ``sink|source<TAB>node.name<TAB>description``. The
node.name is what gets STORED (it is the stable id the client resolves against), so a line
without one is unusable and dropped; a missing description falls back to the name rather than
rendering a picker entry with no label. Anything else on the line is ignored, so an extra
trailing column in a future client can't break this.
"""
sinks: list[dict] = []
sources: list[dict] = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 3 or not parts[1].strip():
continue
kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
entry = {"name": name, "description": description or name}
if kind == "sink":
sinks.append(entry)
elif kind == "source":
sources.append(entry)
return sinks, sources
async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]:
"""Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when
it isn't installed or the call errors/times out.
Only ever used for the two read-only device enumerations the launch path goes through the
Steam shortcut and the wrapper script, never through here. The timeout is generous because
`--list-adapters` initialises Vulkan on a cold flatpak."""
prefix = _session_argv()
if not prefix:
return -1, ""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*prefix, *session_args,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
env=_flatpak_env(),
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return rc, (out or b"").decode("utf-8", "replace")
except asyncio.TimeoutError:
decky.logger.warning("session %s timed out", " ".join(session_args))
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, ""
except Exception: # noqa: BLE001
decky.logger.exception("session %s failed", " ".join(session_args))
return -1, ""
# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the
# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability
# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any
@@ -518,6 +601,11 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in
_HOSTS_TTL_S = 12.0
_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None}
# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading
# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices`
# clears it for the user who just plugged a headset in.
_devices_cache: dict = {"data": None}
def _invalidate_hosts_cache() -> None:
_hosts_cache["data"] = None
@@ -1044,24 +1132,84 @@ class Plugin:
try:
return json.loads(_settings_path().read_text())
except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad).
# The client's own defaults (native display, host-default bitrate, auto pad,
# stats overlay at Normal — `Settings::default` is `show_stats: true`).
return {
"width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
"bitrate_kbps": 0, "codec": "auto", "gamepad": "auto",
"gamepad_forwarding": True, "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False,
"stats_verbosity": "normal", "show_stats": True,
}
async def set_settings(self, settings: dict) -> dict:
"""Write the stream settings JSON the (sandboxed) client reads on launch."""
"""Write the stream settings JSON the (sandboxed) client reads on launch.
MERGED onto whatever is on disk, never a wholesale replace: this file is shared with
the desktop client and the console's settings screen, and it holds far more keys than
this panel models (decoder, GPU, profiles, touch/mouse model). The panel reads it once
when it mounts, so a straight write would post a snapshot that predates anything those
other editors stored in the meantime silently reverting it.
"""
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
_settings_path().write_text(json.dumps(settings, indent=2))
try:
on_disk = json.loads(_settings_path().read_text())
if not isinstance(on_disk, dict):
on_disk = {}
except (OSError, json.JSONDecodeError):
on_disk = {} # no file yet (or an unreadable one): this write creates it
on_disk.update(settings)
_settings_path().write_text(json.dumps(on_disk, indent=2))
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write settings")
return {"ok": False, "error": str(exc)}
async def list_devices(self) -> dict:
"""GPUs + audio endpoints for the settings tab's device pickers.
Two subprocesses that initialise Vulkan and PipeWire, so the result is cached for the
Decky session: hardware doesn't come and go often enough to justify paying that on every
remount of the page, and a stale entry is harmless a picked device that has since
vanished falls back to the OS default in the client anyway. `refresh_devices` clears it.
Best-effort in the same way every other client call here is: no session binary (an old
flatpak that predates the two-binary split, or a native install missing its sibling) just
means empty lists and `ok: false`, which the UI shows as "couldn't read" rather than as
"you have no devices".
"""
if _devices_cache["data"] is not None:
return _devices_cache["data"]
adapters: list[str] = []
sinks: list[dict] = []
sources: list[dict] = []
rc_a, out_a = await _run_session(["--list-adapters"])
if rc_a == 0:
adapters = [ln.strip() for ln in out_a.splitlines() if ln.strip()]
rc_d, out_d = await _run_session(["--list-audio"])
if rc_d == 0:
sinks, sources = _parse_audio_endpoints(out_d)
result = {
"ok": rc_a == 0 or rc_d == 0,
"adapters": adapters,
"sinks": sinks,
"sources": sources,
}
# Only a run that actually answered is worth remembering — caching a failure would make
# a client installed after the page was first opened stay invisible until a Decky restart.
if result["ok"]:
_devices_cache["data"] = result
return result
async def refresh_devices(self) -> dict:
"""Drop the cached enumeration and read it again (a headset was just plugged in)."""
_devices_cache["data"] = None
return await self.list_devices()
# ---- Shared known-hosts store (the SAME file the desktop client reads/writes) ----
async def list_hosts(self, probe: bool = True) -> dict:
+27
View File
@@ -144,6 +144,33 @@ got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
# ---- `--list-audio` parsing (the settings tab's device pickers) --------------------------
sinks, sources = main._parse_audio_endpoints(
"sink\talsa_output.pci-0000_04_00.6.analog-stereo\tSteam Deck Speakers\n"
"sink\tbluez_output.AC_12_2F.1\tWH-1000XM4\n"
"source\talsa_input.pci-0000_04_00.6.analog-stereo\tSteam Deck Microphone\n"
)
check("audio: sinks parsed", [d["name"] for d in sinks] == [
"alsa_output.pci-0000_04_00.6.analog-stereo", "bluez_output.AC_12_2F.1"
])
check("audio: sources parsed", len(sources) == 1)
check("audio: description kept", sinks[1]["description"] == "WH-1000XM4")
# Junk the picker must not offer: no node.name is unusable (it is the id that gets stored), a
# short line is malformed, and an unknown kind belongs to neither list. A blank description
# falls back to the name so no entry renders unlabelled.
sinks, sources = main._parse_audio_endpoints(
"sink\t\tNo node name\n"
"sink\tonly-two-columns\n"
"monitor\tsome.monitor\tNot a sink or source\n"
"source\tbare.node\t\n"
"\n"
)
check("audio: junk lines dropped", sinks == [])
check("audio: blank description falls back to the node name", sources == [
{"name": "bare.node", "description": "bare.node"}
])
print()
if failures:
print(f"{failures} check(s) FAILED")
+88 -10
View File
@@ -101,24 +101,97 @@ export interface RunnerInfo {
client_bin?: string;
}
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client
// and the console's settings screen own, so a value changed in any of them shows in the others.
//
// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that
// cannot be answered from a plugin backend or aren't settings at all:
// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only
// the client process has; there is no CLI that enumerates pads.
// • `last_window_w/h` — the session's remembered window size, written BY the client, not a
// preference anyone sets.
// Both round-trip untouched: get_settings returns the whole parsed file, patches are object
// spreads, and set_settings merges onto what's on disk.
//
// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before
// that key existed simply lacks it. Read those through the same fallback the client uses —
// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here
// while the stream runs with it on.
export interface StreamSettings {
// ---- Stream mode ----
width: number; // 0 = native
height: number; // 0 = native
refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
// Round-trips only — deliberately NOT offered as a row here. It decides whether the session
// grabs the keyboard so Alt+Tab/Super reach the host, and Game Mode is gamescope: it has no
// compositor shortcuts to inhibit and hands the focused window every key already. A toggle
// here would be a dead one. The desktop client's row still edits this same file.
inhibit_shortcuts: boolean;
// Stream mode follows the session window instead of width/height, renegotiating on resize.
// Overrides width/height while on; degenerates to the display's native mode on fullscreen.
match_window?: boolean;
// ---- Video ----
codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files)
decoder?: string; // "auto" | "vulkan" | "vaapi" | "software"
hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10
enable_444?: boolean; // default off — ask for full chroma
adapter?: string; // decode/present GPU by marketing name; "" = automatic
// ---- Presentation ----
// What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared
// with the Apple and Android clients under this name, so one profile reads the same everywhere.
present_priority?: string;
smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 13
vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort)
allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream
// ---- Audio ----
audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1)
speaker_device?: string; // PipeWire node.name for playback; "" = system default
mic_enabled: boolean;
mic_device?: string; // PipeWire node.name for capture; "" = system default
echo_cancel?: boolean; // default ON; only meaningful while mic_enabled
// ---- Controllers ----
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
// Forward this device's controllers at all. Absent in pre-forwarding files, where the
// client's own serde default (true) applies — so `?? true` at every read, never `!!`.
gamepad_forwarding?: boolean;
// ---- Touchscreen, mouse & keyboard ----
touch_mode?: string; // "trackpad" | "pointer" | "touch"
mouse_mode?: string; // "capture" | "desktop"
invert_scroll?: boolean;
// Whether the session grabs the keyboard so Alt+Tab/Super reach the host.
inhibit_shortcuts: boolean;
// ---- Interface & behaviour ----
// Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file,
// which resolves through `show_stats` — read both the way the client's
// `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does.
stats_verbosity?: string;
// The legacy on/off the tier supersedes; kept written in sync so a client that predates the
// tiers still honours an Off chosen here.
show_stats?: boolean;
fullscreen_on_stream?: boolean;
auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting
library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own)
}
// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the
// human name to show.
export interface AudioDevice {
name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store
description: string; // human label ("Steam Deck Speakers")
}
// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`).
// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the
// pickers stay on their stored value rather than pretending the device is gone.
export interface DeviceLists {
ok: boolean;
adapters: string[]; // Vulkan physical devices, discrete first
sinks: AudioDevice[]; // playback endpoints
sources: AudioDevice[]; // capture endpoints
}
export interface UpdateInfo {
@@ -185,6 +258,11 @@ export const getSettings = callable<[], StreamSettings>("get_settings");
export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>(
"set_settings",
);
// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and
// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path.
export const listDevices = callable<[], DeviceLists>("list_devices");
// The same, bypassing the backend's cache — for the user who just plugged in a headset.
export const refreshDevices = callable<[], DeviceLists>("refresh_devices");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is
// up by the time the stream connects. The MAC is looked up from the flatpak client's own
+7 -1
View File
@@ -334,8 +334,14 @@ const HostsTab: FC<{
</div>
);
// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail +
// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an
// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and
// keeps its hands off the overflow. The footer inset lives inside the pages instead.
const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" };
const SettingsTab: FC = () => (
<div style={tabScroll}>
<div style={settingsPane}>
<SettingsSection />
</div>
);
+608 -152
View File
@@ -1,10 +1,59 @@
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on
// launch (main.py set_settings, merged onto what's on disk). This is the same
// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value
// changed in any of the three shows in the other two.
//
// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split
// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings
// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is
// the whole point of the split: the rail is the index, so nothing is more than one hop away.
//
// The categories, their order, and the wording of the rows are the console's settings screen
// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user
// reaches without leaving Gaming Mode, and two different orders for one store is how people stop
// trusting either. It shows them as one steppable list because it has no pointer and no room for
// a rail; here they become the rail's pages, same groups, same sequence. Three more rules:
//
// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the
// console dims those rows rather than dropping them, and a row that vanishes as you toggle
// the one above it is a moving target for a thumbstick.
// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a
// one-GPU Deck). A dead control is worse than an absent one.
// • Anything that behaves differently *here* than it does on a desktop says so in its own
// description, rather than being silently dropped from the screen.
//
// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name`
// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` /
// `MouseMode` enums, which serialize lowercase.
import {
DialogButton,
Dropdown,
Field,
SidebarNavigation,
SliderField,
Spinner,
ToggleField,
} from "@decky/ui";
import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react";
import {
FaDesktop,
FaGamepad,
FaHandPointer,
FaSlidersH,
FaTv,
FaVideo,
FaVolumeUp,
} from "react-icons/fa";
import {
AudioDevice,
DeviceLists,
getSettings,
listDevices,
refreshDevices,
setSettings,
StreamSettings,
} from "./backend";
import { actionButton, RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
@@ -17,50 +66,543 @@ const selectShell: CSSProperties = {
maxWidth: "24em",
};
// ----------------------------------------------------------------------------------------
// Option tables — the console's, so the two Gaming-Mode editors offer the same choices.
// ----------------------------------------------------------------------------------------
// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on.
// Match window is offered even though this plugin's launches are always fullscreen (where it
// degenerates to the display's native mode) — leaving it out would make the row lie about a
// store the desktop client can set it in.
const MATCH_WINDOW = "match";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
[3840, 2160, "3840 × 2160"],
];
const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`);
const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
const COMPOSITORS: [string, string][] = [
["auto", "Automatic"],
["kwin", "KDE Plasma (KWin)"],
["wlroots", "Sway (wlroots)"],
["mutter", "GNOME (Mutter)"],
["gamescope", "gamescope"],
];
const CODECS: [string, string][] = [
["auto", "Automatic"],
["hevc", "HEVC (H.265)"],
["h264", "H.264 (AVC)"],
["av1", "AV1"],
// Opt-in wired-LAN low-latency codec (100400 Mbit/s class, 8-bit SDR). Only ever selected
// when the host advertises it too; anything else falls back to HEVC.
["pyrowave", "PyroWave (wired LAN)"],
];
const DECODERS: [string, string][] = [
["auto", "Automatic"],
["vulkan", "Vulkan Video"],
["vaapi", "VAAPI"],
["software", "Software"],
];
// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so
// one profile reads the same on every device.
const PRESENT_PRIORITIES: [string, string][] = [
["latency", "Lowest latency"],
["smooth", "Smoothness"],
];
// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [number, string][] = [
[0, "Automatic"],
[1, "1 frame"],
[2, "2 frames"],
[3, "3 frames"],
];
const AUDIO_CHANNELS: [number, string][] = [
[2, "Stereo"],
[6, "5.1 surround"],
[8, "7.1 surround"],
];
const GAMEPADS: [string, string][] = [
["auto", "Automatic"],
["xbox360", "Xbox 360"],
["xboxone", "Xbox One"],
["dualsense", "DualSense"],
["dualshock4", "DualShock 4"],
["steamdeck", "Steam Deck"],
];
const TOUCH_MODES: [string, string][] = [
["trackpad", "Trackpad"],
["pointer", "Direct pointer"],
["touch", "Touch passthrough"],
];
const MOUSE_MODES: [string, string][] = [
["capture", "Capture (games)"],
["desktop", "Desktop (absolute)"],
];
const STATS_TIERS: [string, string][] = [
["off", "Off"],
["compact", "Compact"],
["normal", "Normal"],
["detailed", "Detailed"],
];
// ----------------------------------------------------------------------------------------
// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the
// twelve of them below stay one line each and can't drift apart.
// ----------------------------------------------------------------------------------------
const SelectRow = <T extends string | number>({
label,
description,
options,
value,
onChange,
formatUnknown,
disabled,
indent,
}: {
label: string;
description?: ReactNode;
options: [T, string][];
value: T;
onChange: (v: T) => void;
// How to name a stored value this table doesn't list (see below); defaults to the raw value.
formatUnknown?: (v: T) => string;
disabled?: boolean;
indent?: boolean;
}): ReactElement => {
// A Dropdown can only display a value that is one of its options, and this store has four other
// writers — the desktop client, the console, a settings profile, a newer client with presets
// this build doesn't know. Rather than render a blank control (or, worse, silently show a
// different value than the stream will actually use), carry the stored one as its own entry.
const shown: [T, string][] = options.some(([v]) => v === value)
? options
: [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]];
return (
<Field
label={label}
description={description}
disabled={disabled}
indentLevel={indent ? 1 : undefined}
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
disabled={disabled}
rgOptions={shown.map(([data, l]) => ({ data, label: l }))}
selectedOption={value}
onChange={(o) => onChange(o.data as T)}
/>
</div>
</RowActions>
</Field>
);
};
// Mirrors the desktop client's picker (ui_settings.rs CODECS) — a soft preference the host
// falls back from when its GPU can't encode it.
const CODECS = ["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: Record<string, string> = {
auto: "Automatic",
hevc: "HEVC (H.265)",
h264: "H.264 (AVC)",
av1: "AV1",
// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS
// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is
// a real preference that simply isn't plugged in right now, and dropping it would silently
// re-point the next stream at the default without ever showing the user why.
const DeviceRow: FC<{
label: string;
description: string;
devices: AudioDevice[] | null;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
indent?: boolean;
}> = ({ label, description, devices, value, onChange, disabled, indent }) => {
const options: [string, string][] = [["", "System default"]];
for (const d of devices ?? []) options.push([d.name, d.description]);
if (value && !options.some(([name]) => name === value)) {
options.push([value, `${value} (not connected)`]);
}
return (
<SelectRow
label={label}
description={devices === null ? "Reading this device's audio endpoints…" : description}
options={options}
value={value}
onChange={onChange}
disabled={disabled || devices === null}
indent={indent}
/>
);
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
// ----------------------------------------------------------------------------------------
// The pages. One settings object, seven views on it — every page takes the same context rather
// than fetching or holding state of its own, so a change on one page is visible on the others
// the moment you switch.
// ----------------------------------------------------------------------------------------
interface PageCtx {
s: StreamSettings;
patch: (p: Partial<StreamSettings>) => void;
devices: DeviceLists | null;
reading: boolean;
readDevices: (again: boolean) => void;
}
// SidebarNavigation gives each page Steam's own padding, but the routed page still renders
// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same
// inset the tabs use).
const pageBody: CSSProperties = { paddingBottom: "80px" };
const StreamPage: FC<PageCtx> = ({ s, patch }) => {
const renderScale = s.render_scale ?? 1;
const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height);
return (
<div style={pageBody}>
<SelectRow
label="Resolution"
description="The host creates a virtual display at exactly this size — no scaling. Match window follows the stream window instead, which in Gaming Mode means the Deck's native size."
options={[
...RESOLUTIONS.map(([w, h, label]) => [resolutionKey(w, h), label] as [string, string]),
[MATCH_WINDOW, "Match window"] as [string, string],
]}
value={resolution}
// A size set from a desktop profile that isn't one of these presets, spelled the way the
// presets are rather than left as the raw "1600x900" key.
formatUnknown={(v) => v.replace("x", " × ")}
onChange={(v) => {
if (v === MATCH_WINDOW) {
// The tri-state the console stores: the flag on, the explicit size cleared.
patch({ match_window: true, width: 0, height: 0 });
return;
}
const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v);
patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 });
}}
/>
<SelectRow
label="Refresh rate"
description="Native follows the display the stream is on."
options={REFRESH.map((r) => [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])}
value={s.refresh_hz}
formatUnknown={(v) => `${v} Hz`}
onChange={(v) => patch({ refresh_hz: v })}
/>
<SelectRow
label="Render scale"
description="The host renders larger or smaller than the stream mode and the Deck resamples — above 1× supersamples for sharpness, below 1× saves bandwidth."
options={RENDER_SCALES.map((x) => [x, renderScaleLabel(x)] as [number, string])}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
value={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best,
)}
onChange={(v) => patch({ render_scale: v })}
/>
<SliderField
label="Bitrate"
description="0 = the host's own default (20 Mbit/s)."
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<SelectRow
label="Host compositor"
description="Which compositor drives the virtual display — honoured only if it's available on the host. Automatic suits almost every host."
options={COMPOSITORS}
value={s.compositor}
onChange={(v) => patch({ compositor: v })}
/>
</div>
);
};
const VideoPage: FC<PageCtx> = ({ s, patch, devices }) => {
// Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a
// picker with a single option is a control that can't do anything.
const showGpuRow = (devices?.adapters.length ?? 0) > 1;
return (
<div style={pageBody}>
<SelectRow
label="Video codec"
description="A preference — the host falls back when its GPU can't encode this one."
options={CODECS}
value={s.codec ?? "auto"}
onChange={(v) => patch({ codec: v })}
/>
<SelectRow
label="Video decoder"
description="How the Deck decodes the stream. Automatic prefers Vulkan Video, then VAAPI, then software."
options={DECODERS}
value={s.decoder ?? "auto"}
onChange={(v) => patch({ decoder: v })}
/>
{showGpuRow && (
<SelectRow
label="Decode GPU"
description="Which adapter decodes and presents the stream. Automatic picks the discrete GPU where there is one."
options={[
["", "Automatic"],
...(devices?.adapters ?? []).map((a) => [a, a] as [string, string]),
]}
value={s.adapter ?? ""}
onChange={(v) => patch({ adapter: v })}
/>
)}
<ToggleField
label="10-bit HDR"
description="Advertise HDR10 so the host sends 10-bit when the content is HDR. Off means never ask for 10-bit."
checked={s.hdr_enabled ?? true}
onChange={(v) => patch({ hdr_enabled: v })}
/>
<ToggleField
label="Full chroma (4:4:4)"
description="Full-colour video: crisp small text and thin lines, at more bandwidth. Needs an NVIDIA host (NVENC) or the PyroWave codec — other encoders stream 4:2:0 and the session falls back silently."
checked={s.enable_444 ?? false}
onChange={(v) => patch({ enable_444: v })}
/>
</div>
);
};
const PresentationPage: FC<PageCtx> = ({ s, patch }) => {
const smooth = (s.present_priority ?? "latency") === "smooth";
return (
<div style={pageBody}>
<SelectRow
label="Prioritize"
description="What to optimise for when a decoded frame is ready. Lowest latency shows each frame the moment the display can take it — a network hiccup becomes an occasional repeated or skipped frame. Smoothness buffers a little to even those out."
options={PRESENT_PRIORITIES}
value={s.present_priority ?? "latency"}
onChange={(v) => patch({ present_priority: v })}
/>
<SelectRow
label="Smoothness buffer"
description="Frames held back before showing. Each one absorbs about a refresh of network hiccup and adds a refresh of delay. Automatic holds two."
options={SMOOTH_BUFFERS}
value={s.smooth_buffer ?? 0}
formatUnknown={(v) => `${v} frames`}
onChange={(v) => patch({ smooth_buffer: v })}
disabled={!smooth}
indent
/>
<ToggleField
label="V-Sync"
description="Tear-free. Off removes the wait for the screen's refresh — the lowest possible delay, at the cost of visible tearing. Best-effort: not every driver offers it, and the Detailed stats overlay names the mode actually in use."
checked={s.vsync ?? true}
onChange={(v) => patch({ vsync: v })}
/>
<ToggleField
label="Follow variable refresh"
description="On a VRR screen, let the panel refresh in step with the stream instead of on a fixed cadence. Applies to fullscreen sessions — which a Gaming-Mode stream always is — and is harmless on a fixed-refresh screen."
checked={s.allow_vrr ?? true}
onChange={(v) => patch({ allow_vrr: v })}
/>
</div>
);
};
const AudioPage: FC<PageCtx> = ({ s, patch, devices, reading, readDevices }) => {
const micOn = s.mic_enabled;
// What the pickers get: null while the enumeration is in flight (they show a loading state),
// [] when it answered but couldn't read the endpoints (System default plus whatever is
// stored), and the real list otherwise.
const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null =>
reading || !devices ? null : devices.ok ? (list ?? []) : [];
return (
<div style={pageBody}>
<SelectRow
label="Audio channels"
description="The speaker layout requested from the host, which clamps it to what it can capture."
options={AUDIO_CHANNELS}
value={s.audio_channels ?? 2}
formatUnknown={(v) => `${v} channels`}
onChange={(v) => patch({ audio_channels: v })}
/>
<DeviceRow
label="Output device"
description="Where stream audio plays. System default follows whatever the Deck is using, including a headset you plug in mid-stream."
devices={endpoints(devices?.sinks)}
value={s.speaker_device ?? ""}
onChange={(v) => patch({ speaker_device: v })}
/>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic. Ctrl+Alt+Shift+V mutes and unmutes it mid-stream."
checked={micOn}
onChange={(v) => patch({ mic_enabled: v })}
/>
<DeviceRow
label="Microphone device"
description="Which input the mic uplink captures from."
devices={endpoints(devices?.sources)}
value={s.mic_device ?? ""}
onChange={(v) => patch({ mic_device: v })}
disabled={!micOn}
indent
/>
<ToggleField
label="Echo cancellation"
description="Stops the host's audio, playing from the Deck's speakers, being picked up and sent back. Turn it off if your microphone already runs its own processing."
checked={s.echo_cancel ?? true}
onChange={(v) => patch({ echo_cancel: v })}
disabled={!micOn}
indentLevel={1}
/>
{/* The escape hatch for a headset plugged in after this page was opened, and the honest
answer when the enumeration failed outright (a client too old to ship the session
binary). Rendered unconditionally, including while it is reading: a row that comes and
goes under a thumbstick is a moving target, so only its wording changes. */}
<Field
label={
!reading && devices && !devices.ok ? "Couldn't read this device's hardware" : "Devices"
}
description={
reading
? "Reading this device's audio endpoints and GPUs…"
: devices && !devices.ok
? "The output, microphone and GPU pickers fall back to Automatic. Reading them needs the client's session binary, which a client older than the two-binary split doesn't ship — update it from the About tab."
: "Plugged something in just now? Read the audio endpoints and GPUs again."
}
childrenContainerWidth="max"
>
<RowActions>
<DialogButton style={actionButton} disabled={reading} onClick={() => readDevices(true)}>
{reading ? <Spinner style={{ height: "1em" }} /> : "Refresh"}
</DialogButton>
</RowActions>
</Field>
</div>
);
};
const ControllersPage: FC<PageCtx> = ({ s, patch }) => {
const forwarding = s.gamepad_forwarding ?? true;
return (
<div style={pageBody}>
<ToggleField
label="Forward controllers"
description="Send controllers connected to the Deck to the host. Turn it off when your controller already reaches the host another way — USB passthrough such as VirtualHere, or a pad plugged into the host — so games don't see two of them."
checked={forwarding}
onChange={(v) => patch({ gamepad_forwarding: v })}
/>
<SelectRow
label="Controller type"
description="The virtual pad the host creates. Automatic matches the controller you're holding."
options={GAMEPADS}
value={s.gamepad}
onChange={(v) => patch({ gamepad: v })}
disabled={!forwarding}
indent
/>
{forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
indentLevel={1}
/>
)}
</div>
);
};
const PointerPage: FC<PageCtx> = ({ s, patch }) => (
<div style={pageBody}>
<SelectRow
label="Touch mode"
description="How the touchscreen drives the host: Trackpad (relative cursor, tap to click), Direct pointer (the cursor jumps to your finger), or Touch passthrough (every finger is a host contact — only helps apps that understand touch)."
options={TOUCH_MODES}
value={s.touch_mode ?? "trackpad"}
onChange={(v) => patch({ touch_mode: v })}
/>
<SelectRow
label="Mouse mode"
description="How a physical mouse drives the host: Capture locks the pointer for games, Desktop leaves it free and sends absolute positions. Ctrl+Alt+Shift+M switches it live mid-stream."
options={MOUSE_MODES}
value={s.mouse_mode ?? "capture"}
onChange={(v) => patch({ mouse_mode: v })}
/>
<ToggleField
label="Invert scroll direction"
description="Reverses the wheel and trackpad scroll direction sent to the host."
checked={s.invert_scroll ?? false}
onChange={(v) => patch({ invert_scroll: v })}
/>
<ToggleField
label="Capture system shortcuts"
description="Sends Alt+Tab, Super and friends to the host while input is captured, instead of leaving them to the local desktop. Gaming Mode is gamescope, which has no shortcuts to hold back — this is for a keyboard attached to the Deck in Desktop Mode, and for the desktop client sharing these settings."
checked={s.inhibit_shortcuts}
onChange={(v) => patch({ inhibit_shortcuts: v })}
/>
</div>
);
const InterfacePage: FC<PageCtx> = ({ s, patch }) => {
// `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool,
// which itself defaults to true.
const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off");
return (
<div style={pageBody}>
<SelectRow
label="Statistics overlay"
description="How much the in-stream overlay shows: Compact (fps · latency · bitrate on one line) → Normal → Detailed. A three-finger tap on the touchscreen cycles it mid-stream."
options={STATS_TIERS}
value={statsTier}
// Both keys, in sync — the same pairing `Settings::set_stats_verbosity` keeps, so a
// client too old for the tiers still honours an Off chosen here.
onChange={(v) => patch({ stats_verbosity: v, show_stats: v !== "off" })}
/>
<ToggleField
label="Wake hosts automatically"
description="Send Wake-on-LAN to a sleeping host before connecting and wait for it to boot. Turn it off for hosts reached over a VPN, where an offline-looking host is really just unreachable by broadcast and the wait only adds delay."
checked={s.auto_wake ?? true}
onChange={(v) => patch({ auto_wake: v })}
/>
<ToggleField
label="Show game library in the client"
description="Lets the client's own host cards browse a paired host's games. This plugin's library browser works either way — this is for the client's screens."
checked={s.library_enabled ?? false}
onChange={(v) => patch({ library_enabled: v })}
/>
<ToggleField
label="Start streams fullscreen"
description="Streams open fullscreen instead of windowed. Launches from this plugin are always fullscreen whatever this says — it's here because the desktop client reads the same settings."
checked={s.fullscreen_on_stream ?? true}
onChange={(v) => patch({ fullscreen_on_stream: v })}
/>
</div>
);
};
// ----------------------------------------------------------------------------------------
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
// null until the enumeration answers — the pickers show a loading state rather than briefly
// claiming this device has no endpoints.
const [devices, setDevices] = useState<DeviceLists | null>(null);
const [reading, setReading] = useState(true);
const readDevices = (again: boolean) => {
setReading(true);
void (again ? refreshDevices() : listDevices())
.then(setDevices)
.finally(() => setReading(false));
};
useEffect(() => {
void getSettings().then(setS);
// Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan
// takes seconds, and the rest of the screen must not wait for it.
readDevices(false);
}, []);
const patch = (p: Partial<StreamSettings>) => {
@@ -74,128 +616,42 @@ export const SettingsSection: FC = () => {
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
const ctx: PageCtx = { s, patch, devices, reading, readDevices };
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field>
<Field
label="Render scale"
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
selectedOption={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
)}
onChange={(o) => patch({ render_scale: o.data as number })}
/>
</div>
</RowActions>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Video codec"
description="Preferred stream codec — the host falls back when its GPU can't encode it"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={CODECS.map((c) => ({ data: c, label: CODEC_LABELS[c] ?? c }))}
selectedOption={s.codec ?? "auto"}
onChange={(o) => patch({ codec: o.data as string })}
/>
</div>
</RowActions>
</Field>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field
label="⚠ Disable Steam Input"
description="On a Deck, Automatic forwards the built-in controller as a Steam Deck pad — paddles, both trackpads, and gyro included. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
<SidebarNavigation
// We are already inside the plugin's own `/punktfunk` route, rendered in a tab. Route
// reporting would have this nav push entries of its own onto the router and fight the
// page for the back gesture; the pages are addressed by `identifier` instead.
disableRouteReporting
pages={[
{ title: "Stream", identifier: "stream", icon: <FaDesktop />, content: <StreamPage {...ctx} /> },
{ title: "Video", identifier: "video", icon: <FaVideo />, content: <VideoPage {...ctx} /> },
{
title: "Presentation",
identifier: "presentation",
icon: <FaTv />,
content: <PresentationPage {...ctx} />,
},
{ title: "Audio", identifier: "audio", icon: <FaVolumeUp />, content: <AudioPage {...ctx} /> },
{
title: "Controllers",
identifier: "controllers",
icon: <FaGamepad />,
content: <ControllersPage {...ctx} />,
},
{
title: "Touch & mouse",
identifier: "pointer",
icon: <FaHandPointer />,
content: <PointerPage {...ctx} />,
},
{
title: "Interface",
identifier: "interface",
icon: <FaSlidersH />,
content: <InterfacePage {...ctx} />,
},
]}
/>
);
};
+163
View File
@@ -156,6 +156,20 @@ mod index {
pub fn gamepad(s: &Settings) -> u32 {
GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0) as u32
}
pub fn present_priority(s: &Settings) -> u32 {
// Unknown values (a newer client's intent) read as the default, exactly as
// `PresentPriority::resolve` treats them.
PRESENT_PRIORITIES
.iter()
.position(|&p| p == s.present_priority)
.unwrap_or(0) as u32
}
pub fn smooth_buffer(s: &Settings) -> u32 {
// The index IS the stored value: 0 = Automatic, 1..3 = frames.
u32::from(s.smooth_buffer).min(SMOOTH_BUFFER_LABELS.len() as u32 - 1)
}
}
/// The chip palette a profile can carry (`StreamProfile.accent`). Eight entries rather than a
@@ -625,12 +639,27 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("gamepad") {
o.gamepad = Some(values.gamepad.clone());
}
if touched.has("gamepad_forwarding") {
o.gamepad_forwarding = Some(values.gamepad_forwarding);
}
if touched.has("stats_verbosity") {
o.stats_verbosity = Some(values.stats_verbosity());
}
if touched.has("fullscreen_on_stream") {
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
}
if touched.has("present_priority") {
o.present_priority = Some(values.present_priority.clone());
}
if touched.has("smooth_buffer") {
o.smooth_buffer = Some(values.smooth_buffer);
}
if touched.has("vsync") {
o.vsync = Some(values.vsync);
}
if touched.has("allow_vrr") {
o.allow_vrr = Some(values.allow_vrr);
}
// Resets are not handled here: they clear the field and re-seed their row the moment the
// user asks, so by the time this runs the catalog already reflects them and the row is no
// longer marked touched.
@@ -684,6 +713,20 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[
"The cursor jumps to your finger — a tap clicks there",
"Real multi-touch reaches the host — for touch-native apps",
];
/// Presentation-intent values (persisted under the `present_priority` key the Apple and
/// Android clients share) + labels + dynamic captions. Captions stay ONE line, like the
/// touch/mouse rows.
const PRESENT_PRIORITIES: &[&str] = &["latency", "smooth"];
const PRESENT_PRIORITY_LABELS: &[&str] = &["Lowest latency", "Smoothness"];
const PRESENT_PRIORITY_CAPTIONS: &[&str] = &[
"Each frame shows the moment the display can take it",
"Buffers a little to even out network hiccups",
];
/// Smoothness buffer depth, in frames — the index IS the stored `smooth_buffer` value
/// (0 = Automatic, which resolves to 2). No millisecond hints: the cost is one refresh
/// per frame, and the session's refresh isn't known here when the mode is Native.
const SMOOTH_BUFFER_LABELS: &[&str] = &["Automatic", "1 frame", "2 frames", "3 frames"];
/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as
/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream.
const MOUSE_MODES: &[&str] = &["capture", "desktop"];
@@ -1213,6 +1256,50 @@ pub fn show_scoped(
row
});
// ---- Display: Presentation ----
// The intent pair the Apple and Android clients already carry. The buffer row only
// means anything under Smoothness, so it hides itself the rest of the time rather
// than sitting there inert.
let present_row = ChoiceRow::new(
&dialog,
inline,
"Prioritize",
PRESENT_PRIORITY_CAPTIONS[0],
PRESENT_PRIORITY_LABELS,
);
let buffer_row = ChoiceRow::new(
&dialog,
inline,
"Smoothness buffer",
"Each frame held absorbs one refresh of hiccup and adds one of delay",
SMOOTH_BUFFER_LABELS,
);
{
let w = present_row.widget().clone();
let buffer = buffer_row.widget().clone();
present_row.connect_changed(move |i| {
let i = (i as usize).min(PRESENT_PRIORITY_CAPTIONS.len() - 1);
set_row_subtitle(&w, PRESENT_PRIORITY_CAPTIONS[i]);
buffer.set_visible(PRESENT_PRIORITIES[i] == "smooth");
});
}
let vsync_row = adw::SwitchRow::builder()
.title("V-Sync")
.subtitle(
"Tear-free. Turning it off removes the wait for the screen's refresh — the \
lowest possible delay, at the cost of visible tearing. Not every driver \
offers it; the stats overlay names the mode actually in use",
)
.build();
let vrr_row = adw::SwitchRow::builder()
.title("Follow variable refresh rate")
.subtitle(
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with the \
stream instead of on a fixed cadence. Applies to fullscreen sessions; \
harmless on a fixed-refresh screen",
)
.build();
// ---- Display: Host output ----
let compositor_row = ChoiceRow::new(
&dialog,
@@ -1376,6 +1463,17 @@ pub fn show_scoped(
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
// Off = this device's controllers are not sent at all, because they reach the host
// another way (USB passthrough such as VirtualHere, or a pad plugged into the host).
// It also stops the session OPENING the pad, which is what frees the device for a
// passthrough tool to bind — so the two rows below have nothing to act on while it is
// off, and are desensitised to say so.
let pad_forward_row = adw::SwitchRow::builder()
.title("Forward controllers")
.subtitle(
"Send this device's controllers to the host — off if it already has them another way",
)
.build();
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
@@ -1444,6 +1542,18 @@ pub fn show_scoped(
"Steam Deck",
],
);
// Both pad rows only mean something while something is being forwarded (the same
// relationship mic → echo cancellation draws just above, initial state included: the
// seed's `set_active` fires this only when it CHANGES the switch).
{
let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone());
f.set_sensitive(seed.gamepad_forwarding);
t.set_sensitive(seed.gamepad_forwarding);
pad_forward_row.connect_active_notify(move |r| {
f.set_sensitive(r.is_active());
t.set_sensitive(r.is_active());
});
}
// ---- Seed from the effective settings for this scope ----
{
@@ -1454,6 +1564,7 @@ pub fn show_scoped(
hz_row.set_selected(index::refresh(s));
scale_row.set_selected(index::render_scale(s));
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
pad_forward_row.set_active(s.gamepad_forwarding);
pad_row.set_selected(index::gamepad(s));
let touch_i = index::touch(s);
touch_row.set_selected(touch_i);
@@ -1479,6 +1590,19 @@ pub fn show_scoped(
let codec_i = index::codec(s);
codec_row.set_selected(codec_i);
set_row_subtitle(codec_row.widget(), codec_caption(codec_i));
let present_i = index::present_priority(s);
present_row.set_selected(present_i);
set_row_subtitle(
present_row.widget(),
PRESENT_PRIORITY_CAPTIONS[present_i as usize],
);
buffer_row.set_selected(index::smooth_buffer(s));
// `set_selected` never fires the changed hook, so mirror its visibility rule here.
buffer_row
.widget()
.set_visible(PRESENT_PRIORITIES[present_i as usize] == "smooth");
vsync_row.set_active(s.vsync);
vrr_row.set_active(s.allow_vrr);
}
// ---- Override markers, per-row reset, and the touch that creates an override ----
@@ -1671,6 +1795,26 @@ pub fn show_scoped(
index::surround
);
choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad);
toggle!(
pad_forward_row,
"gamepad_forwarding",
o.gamepad_forwarding.is_some(),
gamepad_forwarding
);
choice!(
present_row,
"present_priority",
o.present_priority.is_some(),
index::present_priority
);
choice!(
buffer_row,
"smooth_buffer",
o.smooth_buffer.is_some(),
index::smooth_buffer
);
toggle!(vsync_row, "vsync", o.vsync.is_some(), vsync);
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
@@ -1775,6 +1919,11 @@ pub fn show_scoped(
if let (Some(r), false) = (&gpu_row, profile_mode) {
quality_group.add(r.widget());
}
let presentation_group = group("Presentation", "");
presentation_group.add(present_row.widget());
presentation_group.add(buffer_row.widget());
presentation_group.add(&vsync_row);
presentation_group.add(&vrr_row);
// The one form-level note (deliberately not repeated on every row).
let output_group = group(
"Host output",
@@ -1783,6 +1932,7 @@ pub fn show_scoped(
output_group.add(compositor_row.widget());
display.add(&resolution_group);
display.add(&quality_group);
display.add(&presentation_group);
display.add(&output_group);
let input = page("Input", "input-keyboard-symbolic");
@@ -1843,6 +1993,10 @@ pub fn show_scoped(
controllers_group.add(&row);
}
}
// Profileable, so it shows in both scopes — unlike the pin below it, which is about
// which of THIS device's pads goes first: a "Work" profile can decline to forward
// controllers to a host that a "Game" profile forwards them to.
controllers_group.add(&pad_forward_row);
if !profile_mode {
controllers_group.add(forward_row.widget());
}
@@ -1915,6 +2069,7 @@ pub fn show_scoped(
s.auto_wake = wake_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active();
s.invert_scroll = invert_row.is_active();
s.gamepad_forwarding = pad_forward_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
@@ -1925,6 +2080,14 @@ pub fn show_scoped(
_ => 2,
};
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
s.present_priority = PRESENT_PRIORITIES
[(present_row.selected() as usize).min(PRESENT_PRIORITIES.len() - 1)]
.to_string();
// The index IS the value (0 = Automatic).
s.smooth_buffer =
(buffer_row.selected() as u8).min(SMOOTH_BUFFER_LABELS.len() as u8 - 1);
s.vsync = vsync_row.is_active();
s.allow_vrr = vrr_row.is_active();
s.library_enabled = library_row.is_active();
};
+4
View File
@@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> {
} else {
0
},
// Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so
// the probe accepts a mid-session shard change (and jumbo growth) up to the
// receive ceiling — and it's exactly the tool to measure both.
max_shard_payload: punktfunk_core::config::max_shard_payload() as u16,
}
.encode(),
)
+91 -12
View File
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
can_wake: false,
last_used: k.and_then(|h| h.last_used),
os: k.map(|h| h.os.clone()).unwrap_or_default(),
pin: None,
bound_profile: None,
};
let label = row.name.clone();
if k.is_none() {
seed = Some(row.clone());
}
if row.paired {
(ConsoleEntry::Library(row), Some(label))
(ConsoleEntry::Library(Box::new(row)), Some(label))
} else {
(ConsoleEntry::Home, Some(label))
}
}
None if fake => {
let row = fake_host_row();
(ConsoleEntry::Library(row), None)
(ConsoleEntry::Library(Box::new(row)), None)
}
None => (ConsoleEntry::Home, None),
};
@@ -169,6 +171,11 @@ pub fn run(target: Option<&str>) -> u8 {
mouse_mode: settings_at_start.mouse_mode(),
invert_scroll: settings_at_start.invert_scroll,
inhibit_shortcuts: settings_at_start.inhibit_shortcuts,
// Presentation-tier like the rows above: latched at console start, a per-host
// profile cannot move it in this mode (the documented P4 gap).
present_priority: settings_at_start.present_priority(),
vsync: settings_at_start.vsync,
allow_vrr: settings_at_start.allow_vrr,
json_status,
on_connected: Some(Box::new(move |fingerprint: [u8; 32]| {
let fp_hex = trust::hex(&fingerprint);
@@ -202,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
launch,
title,
request_access,
profile,
} => {
let Some(pin) = trust::parse_hex32(&fp_hex) else {
// Connect (and request-access) pin the host's advertised fingerprint;
@@ -216,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
// have changed the defaults since the last stream, and the host may carry
// a profile binding. Console (and therefore Decky, which spawns this
// binary) honors bindings with no console-side work — the resolver is the
// same one `--connect` goes through. No one-off here: picking a profile is
// a desktop-shell affordance in v1, pinned cards are the console's.
let (settings, profile) = trust::effective_settings(&addr, port, None);
// same one `--connect` goes through. A pinned card's connect arrives as a
// one-off profile id; the resolver prefers it over the binding, and a
// dangling id falls back to the defaults without blocking the connect.
let (settings, profile) =
trust::effective_settings(&addr, port, profile.as_deref());
let mut params = session_params(
&settings,
profile.map(|p| p.name),
@@ -298,6 +308,8 @@ fn fake_host_row() -> HostRow {
can_wake: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
bound_profile: None,
}
}
@@ -501,6 +513,38 @@ impl ServiceState {
ConsoleCmd::Probe => {
self.last_probe = Instant::now() - Duration::from_secs(60);
}
ConsoleCmd::SetPin {
key,
profile_id,
pin,
} => {
// Presentation only (design §5.2a): order = card order, appended at the
// end; never touches `profile_id` (the default binding). Idempotent, so
// a repeated press inside one refresh window can't double-pin.
let mut known = trust::KnownHosts::load();
let idx = known
.hosts
.iter()
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
.or_else(|| {
let (addr, port) = key.rsplit_once(':')?;
known.index_by_addr(addr, port.parse().ok()?)
});
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
return;
};
if pin && !h.pinned_profiles.contains(&profile_id) {
h.pinned_profiles.push(profile_id);
} else if !pin {
h.pinned_profiles.retain(|id| *id != profile_id);
}
if let Err(e) = known.save() {
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
}
// `run` refreshes the rows right after this drain, so the carousel and
// the pin screen reflect the new card within the same service pass.
}
}
}
@@ -539,12 +583,21 @@ impl ServiceState {
})
}
/// The console home's rows: saved hosts (most recent first), then
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
/// The console home's rows: saved hosts (most recent first) — each followed by its
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
/// still-uncovered `--browse` seed.
fn rows(&self) -> Vec<HostRow> {
let known = trust::KnownHosts::load();
let catalog = pf_client_core::profiles::ProfilesFile::load();
let probed = self.probed.lock().unwrap();
let mut rows: Vec<HostRow> = known
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
id: p.id.clone(),
name: p.name.clone(),
accent: p.accent.clone(),
};
// Primary rows paired with their pinned cards, so the sort below can order hosts
// while every host's cards stay glued behind its primary tile.
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
.hosts
.iter()
.map(|h| {
@@ -558,8 +611,8 @@ impl ServiceState {
|| (d.addr == h.addr && d.port == h.port)
});
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
HostRow {
key,
let row = HostRow {
key: key.clone(),
name: host_display_name(&h.name, &h.addr),
addr: h.addr.clone(),
port: h.port,
@@ -576,10 +629,34 @@ impl ServiceState {
.filter(|d| !d.os.is_empty())
.map(|d| d.os.clone())
.unwrap_or_else(|| h.os.clone()),
}
pin: None,
bound_profile: h
.profile_id
.as_deref()
.and_then(|id| catalog.find_by_id(id))
.map(chip),
};
// A pinned card shares the primary tile's live state; its key rides the
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
// so cursor-follow and the wake path address the card itself.
let pins = h
.resolved_pins(&catalog)
.into_iter()
.map(|p| HostRow {
key: format!("{key}\0{}", p.id),
pin: Some(chip(p)),
bound_profile: None,
..row.clone()
})
.collect();
(row, pins)
})
.collect();
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
let mut rows: Vec<HostRow> = saved
.into_iter()
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
.collect();
let mut extra: Vec<HostRow> = self
.discovered
@@ -607,6 +684,8 @@ impl ServiceState {
can_wake: false,
last_used: None,
os: d.os.clone(),
pin: None,
bound_profile: None,
})
.collect();
extra.sort_by(|a, b| a.name.cmp(&b.name));
+9
View File
@@ -188,6 +188,12 @@ mod session_main {
if !settings.forward_pad.is_empty() {
gamepad.set_pinned(Some(settings.forward_pad.clone()));
}
// Whether to forward controllers AT ALL (off = the pad reaches the host by some other
// route — VirtualHere and friends). Set unconditionally, not only when off: browse mode
// reuses one service across launches, so a stream that follows one with it off must put
// it back. It goes on before the attach below, so a non-forwarding session never opens
// — never grabs — the device.
gamepad.set_forwarding(settings.gamepad_forwarding);
let mode = Mode {
width: if settings.width == 0 {
native.width
@@ -617,6 +623,9 @@ mod session_main {
mouse_mode: settings.mouse_mode(),
invert_scroll: settings.invert_scroll,
inhibit_shortcuts: settings.inhibit_shortcuts,
present_priority: settings.present_priority(),
vsync: settings.vsync,
allow_vrr: settings.allow_vrr,
json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now.
+8 -2
View File
@@ -623,8 +623,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
actions.push(
icon_btn("Settings", Symbol::Setting)
.on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Re-base the settings snapshot on the file before the page
// renders — this process is not its only writer (see
// settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
})
.into(),
);
+10 -4
View File
@@ -2,7 +2,8 @@
//! Settings).
use super::style::*;
use super::Screen;
use super::{AppCtx, Screen};
use std::sync::Arc;
use windows_reactor::*;
/// punktfunk's own license (MIT OR Apache-2.0).
@@ -15,10 +16,15 @@ const APP_LICENSE: &str = concat!(
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../../THIRD-PARTY-NOTICES.txt");
pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
pub(crate) fn licenses_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
let back_btn = button("Back").accent().icon(Symbol::Back).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
let (c, ss) = (ctx.clone(), set_screen.clone());
move || {
// Back RE-ENTERS the settings page — re-base its snapshot on the file, same
// as the hosts page's Settings button (see settings::refresh_snapshot).
super::settings::refresh_snapshot(&c);
ss.call(Screen::Settings)
}
});
let app_card = card(
+5 -1
View File
@@ -172,6 +172,10 @@ pub(crate) struct Shared {
pub struct AppCtx {
pub(crate) identity: (String, String),
/// The settings snapshot the UI renders from. Loaded once at startup, and RE-BASED on
/// the file when the settings page is (re)entered (`settings::refresh_snapshot`) and
/// inside every `commit` — this process is not the file's only writer (session resize,
/// console UI, Decky), so a plain process-lifetime snapshot goes stale on screen.
pub(crate) settings: Mutex<Settings>,
pub(crate) gamepad: GamepadService,
pub(crate) shared: Arc<Shared>,
@@ -688,7 +692,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
&set_settings_rev,
nav_progress,
),
Screen::Licenses => licenses::licenses_page(&set_screen),
Screen::Licenses => licenses::licenses_page(ctx, &set_screen),
Screen::Help => help::help_page(&set_screen),
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
+196 -5
View File
@@ -101,6 +101,19 @@ const MOUSE_MODES: &[(&str, &str)] = &[
("capture", "Capture (games)"),
("desktop", "Desktop (absolute)"),
];
/// Presentation intent: `(stored value, display label)` — the `present_priority` key the
/// Apple and Android clients share, so one profile means the same thing everywhere.
const PRESENT_PRIORITIES: &[(&str, &str)] =
&[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames: `(stored value, display label)`. `0` = Automatic,
/// which resolves to 2 (`PresentPriority::resolve`). No millisecond hints — the cost is
/// one refresh per frame, and the refresh isn't known here when the mode is Native.
const SMOOTH_BUFFERS: &[(u8, &str)] = &[
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[
@@ -411,7 +424,16 @@ fn commit(
return;
}
let mut catalog = ProfilesFile::load();
let base = ctx.settings.lock().unwrap().clone();
// The same rebase as the global arm above: `base` is what `absorb`'s before/after
// effective settings derive from, and the snapshot is not the file — another process
// (session resize, console UI, Decky) may have moved a global under us. The historical
// rebase fix ("settings saves stop reverting each other") covered the whole-file
// writers but missed this arm.
let base = {
let mut s = ctx.settings.lock().unwrap();
*s = Settings::load();
s.clone()
};
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
return; // deleted from under us; the next render falls back to the defaults scope
};
@@ -425,6 +447,17 @@ fn commit(
rev.1.call(rev.0 + 1);
}
/// Re-base the process-lifetime settings snapshot on the file — called from the navigation
/// handlers that (re)enter this page, NOT per render pass. `ctx.settings` is loaded once at
/// process start and this process is not the file's only writer (a spawned session persists
/// its match-window size, the console UI and Decky save too — profiles.rs documents the
/// family), so without this the page opens showing values another process already replaced,
/// which then visibly "jump" the moment a row is touched and `commit`'s rebase pulls the
/// file in. The field report this fixes: a codec setting that "changed by itself".
pub(crate) fn refresh_snapshot(ctx: &Arc<AppCtx>) {
*ctx.settings.lock().unwrap() = Settings::load();
}
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
#[derive(Default)]
@@ -445,8 +478,13 @@ struct OverrideFlags {
invert_scroll: bool,
inhibit_shortcuts: bool,
gamepad: bool,
gamepad_forwarding: bool,
stats_verbosity: bool,
fullscreen_on_stream: bool,
present_priority: bool,
smooth_buffer: bool,
vsync: bool,
allow_vrr: bool,
}
impl OverrideFlags {
@@ -473,8 +511,13 @@ impl OverrideFlags {
invert_scroll: o.invert_scroll.is_some(),
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
gamepad: o.gamepad.is_some(),
gamepad_forwarding: o.gamepad_forwarding.is_some(),
stats_verbosity: o.stats_verbosity.is_some(),
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
present_priority: o.present_priority.is_some(),
smooth_buffer: o.smooth_buffer.is_some(),
vsync: o.vsync.is_some(),
allow_vrr: o.allow_vrr.is_some(),
}
}
}
@@ -851,6 +894,32 @@ pub(crate) fn settings_page(
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
s.enable_444 = on
});
// Presentation intent (design/desktop-presentation-rebuild.md). The buffer row is
// rendered only under Smoothness — `commit` bumps the revision, so flipping the
// intent re-renders the section and the row appears/disappears with it.
let (present_names, present_i) = presets(PRESENT_PRIORITIES, |v| *v == s.present_priority);
let present_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
present_names,
present_i,
|s, i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string(),
);
let smoothing = s.present_priority == "smooth";
let (buffer_names, buffer_i) = presets(SMOOTH_BUFFERS, |v| *v == s.smooth_buffer);
let buffer_combo = setting_combo(
ctx,
scope,
(rev, set_rev),
buffer_names,
buffer_i,
|s, i| s.smooth_buffer = SMOOTH_BUFFERS[i].0,
);
let vsync_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.vsync, |s, on| s.vsync = on);
let vrr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.allow_vrr, |s, on| {
s.allow_vrr = on
});
// --- Input -----------------------------------------------------------------------------
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
@@ -898,6 +967,10 @@ pub(crate) fn settings_page(
s.save();
})
};
let pad_forward_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| {
s.gamepad_forwarding = on
});
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
});
@@ -972,6 +1045,21 @@ pub(crate) fn settings_page(
let ss = set_screen.clone();
button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
};
// The client log's home — the file every "check the client log" message means, which until
// this row had no way in from the UI at all. The folder rather than the file so the rotated
// `.old` generation is in reach too.
//
// `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX
// container: handed a path the package redirection keeps from ever existing, it silently
// opens the user's Documents folder instead of failing, which is precisely what this button
// shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever
// comes back wrong, the click does nothing rather than landing somewhere misleading.
// Best-effort otherwise, like the log itself: a failed spawn stays silent.
let logs_button = button("Open log folder").on_click(|| {
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
let library_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.library_enabled, |s, on| {
s.library_enabled = on
});
@@ -1065,8 +1153,9 @@ pub(crate) fn settings_page(
"HDR10, when the host has HDR content and this display supports it. \
HEVC only; otherwise the stream stays SDR.",
),
// Wording shared with the GTK client (its chroma_row) — same setting,
// same constraints.
// First sentence shared with the GTK client (its chroma_row); the
// constraint sentence names the real gate (host: PyroWave || NVENC) —
// "where the host can encode it" cost field users the discovery time.
described_overridable(
(rev, set_rev),
scope,
@@ -1075,7 +1164,8 @@ pub(crate) fn settings_page(
over.enable_444,
chroma_toggle,
"Full-colour video: crisp small text and thin lines, at more \
bandwidth. HEVC only, and only where the host can encode it.",
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
codec \u{2014} other encoders stream 4:2:0.",
),
],
None,
@@ -1105,6 +1195,60 @@ pub(crate) fn settings_page(
},
None,
));
out.extend(group(
Some("Presentation"),
{
let mut fields = vec![described_overridable(
(rev, set_rev),
scope,
"present_priority",
"Prioritize",
over.present_priority,
present_combo,
"Lowest latency shows each frame the moment the display can take \
it \u{2014} a network hiccup becomes an occasional repeated or \
skipped frame. Smoothness buffers a little to even those out.",
)];
if smoothing {
fields.push(described_overridable(
(rev, set_rev),
scope,
"smooth_buffer",
"Smoothness buffer",
over.smooth_buffer,
buffer_combo,
"Frames held back before showing. Each one absorbs about a \
refresh of network hiccup and adds a refresh of delay. \
Automatic holds two.",
));
}
fields.push(described_overridable(
(rev, set_rev),
scope,
"vsync",
"V-Sync",
over.vsync,
vsync_toggle,
"Tear-free. Turning it off removes the wait for the screen\u{2019}s \
refresh \u{2014} the lowest possible delay, at the cost of visible \
tearing. Not every driver offers it; the stats overlay names the \
mode actually in use.",
));
fields.push(described_overridable(
(rev, set_rev),
scope,
"allow_vrr",
"Follow variable refresh rate",
over.allow_vrr,
vrr_toggle,
"On a VRR/FreeSync/G-Sync screen, let the panel refresh in step with \
the stream instead of on a fixed cadence. Applies to fullscreen \
sessions; harmless on a fixed-refresh screen.",
));
fields
},
None,
));
out.extend(group(
Some("Host output"),
vec![described_overridable(
@@ -1223,6 +1367,23 @@ pub(crate) fn settings_page(
"Plug in or pair a controller and it appears here.",
)
}),
// Whether ANY controller is forwarded — profileable, so it renders in
// both scopes (a "Work" profile can decline what "Game" forwards),
// unlike the device-fact picker below it.
Some(described_overridable(
(rev, set_rev),
scope,
"gamepad_forwarding",
"Forward controllers",
over.gamepad_forwarding,
pad_forward_toggle,
"Sends controllers connected to this PC to the host. Turn it off when \
your controller already reaches the host another way \u{2014} USB \
passthrough such as VirtualHere, or a pad plugged into the host \
itself \u{2014} so games don't see two of them. Off, this PC never \
opens the controller at all, which is what leaves it free for a \
passthrough tool to claim.",
)),
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
// forwards every controller as its own player. Same picker, different rule.
// Which physical pad this device forwards is a device fact (tier G), so it
@@ -1325,7 +1486,16 @@ pub(crate) fn settings_page(
"About",
group(
None,
vec![about_identity.into(), licenses_button.into()],
vec![
about_identity.into(),
described_labeled(
"Diagnostics",
logs_button,
"The client log (client.log, plus the session\u{2019}s whole \
receive/decode/present trail) \u{2014} attach it to a bug report.",
),
licenses_button.into(),
],
None,
),
),
@@ -1727,5 +1897,26 @@ mod tests {
let f3 = OverrideFlags::of(Some(&p3));
assert!(f3.echo_cancel);
assert!(!f3.mic_enabled);
// The presentation pair, likewise independent: pinning the intent doesn't claim
// the buffer (a "Smoothness, whatever the global buffer is" profile is valid).
let mut p4 = StreamProfile::new("t4".to_string());
p4.overrides = SettingsOverlay {
present_priority: Some("smooth".into()),
..Default::default()
};
let f4 = OverrideFlags::of(Some(&p4));
assert!(f4.present_priority);
assert!(!f4.smooth_buffer);
// V-Sync and VRR are independent of each other and of the intent pair.
let mut p5 = StreamProfile::new("t5".to_string());
p5.overrides = SettingsOverlay {
vsync: Some(false),
..Default::default()
};
let f5 = OverrideFlags::of(Some(&p5));
assert!(f5.vsync);
assert!(!f5.allow_vrr && !f5.present_priority);
}
}
+131 -2
View File
@@ -10,6 +10,10 @@
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
//!
//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where
//! they actually land. Under MSIX those differ, and only the second one is fit to show a user
//! or hand to Explorer.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
@@ -21,13 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`.
///
/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`].
/// Anything shown to a user or handed to another process wants that one instead.
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in
/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner.
///
/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's
/// `%LOCALAPPDATA%` writes into its private `…\Packages\<family>\LocalCache\Local\…`. We create
/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path
/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the
/// container: it resolves the literal path, finds nothing, and silently falls back to the user's
/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and
/// what the two "check <path>" messages pointed at. An unpackaged dev run creates the literal
/// directory for real, which is why this only ever showed up in the field.
///
/// Canonicalizing the directory we just created resolves through the redirection on a packaged
/// run and changes nothing on an unpackaged one, so there is no package identity to detect.
pub(crate) fn real_dir() -> Option<PathBuf> {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim))
}
/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim
/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid —
/// and it is noise in a line a user is meant to read and act on.
fn strip_verbatim(p: PathBuf) -> PathBuf {
use std::path::{Component, Prefix};
// Scoped so the borrow ends before the `return p` below can move it.
let head = match p.components().next() {
Some(Component::Prefix(pre)) => match pre.kind() {
// `\\?\C:\…` → `C:\…`
Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))),
// `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share).
// Built through `OsString`, which appends verbatim — `PathBuf::push` would apply
// separator logic to the bare `\\` and mangle it.
Prefix::VerbatimUNC(server, share) => {
let mut unc = std::ffi::OsString::from(r"\\");
unc.push(server);
unc.push(r"\");
unc.push(share);
Some(PathBuf::from(unc))
}
// Already a plain path — nothing to undo.
_ => None,
},
_ => None,
};
let Some(mut out) = head else { return p };
// `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`.
out.extend(
p.components()
.skip(1)
.filter(|c| !matches!(c, Component::RootDir)),
);
out
}
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk.
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
Some(real_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
@@ -96,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\`
/// path as unresolvable and opens Documents instead, so the prefix has to come off.
#[test]
fn verbatim_disk_prefix_comes_off() {
let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs");
assert_eq!(
strip_verbatim(p),
PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs")
);
}
/// The MSIX-redirected form is what the fix is for: same treatment, longer path.
#[test]
fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() {
let p = PathBuf::from(
r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs",
);
assert_eq!(
strip_verbatim(p),
PathBuf::from(
r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs"
)
);
}
/// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what
/// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting
/// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing.
#[test]
fn verbatim_unc_prefix_becomes_a_plain_unc_path() {
let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs");
assert_eq!(
strip_verbatim(p),
PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs")
);
}
/// An unpackaged dev run resolves to a path that was never verbatim — leave it alone.
#[test]
fn plain_path_is_untouched() {
let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs");
assert_eq!(strip_verbatim(p.clone()), p);
}
/// Whatever the run, the resolved directory is one Explorer can open: it exists, and it
/// carries no verbatim prefix. This is the button's actual precondition.
#[test]
fn real_dir_is_an_openable_directory() {
let Some(dir) = real_dir() else {
return; // no LOCALAPPDATA (not a normal user session) — nothing to assert
};
assert!(dir.is_dir(), "{} is not a directory", dir.display());
assert!(
!dir.to_string_lossy().starts_with(r"\\?\"),
"{} kept its verbatim prefix",
dir.display()
);
}
}
+8 -1
View File
@@ -105,7 +105,14 @@ fn parse_line(line: &str) -> Option<ChildLine> {
/// connect that silently drops back to the host list.
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
(code != 0 && code != -1).then(|| {
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
// Name the log's actual location — "check the client log" without a path is a
// scavenger hunt (Settings ▸ About's "Open log folder" reaches it too).
let log = crate::logfile::path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the client log".into());
format!(
"The session didn't start (punktfunk-session exited with code {code}). Check {log}."
)
})
}
+6 -1
View File
@@ -612,7 +612,10 @@ pub fn open_portal_monitor(
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
/// session that negotiated PQ cannot fall back to SDR afterwards.
/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the
/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites
/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative
/// "pointer hidden" the composited/forwarded cursor must honor.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -625,6 +628,7 @@ pub fn open_virtual_output(
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::from_virtual_output(
remote_fd,
@@ -636,6 +640,7 @@ pub fn open_virtual_output(
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
cursor_id0_hides,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
+14 -1
View File
@@ -72,6 +72,11 @@ struct CaptureOpts {
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
/// gamescope fixates its own — gating those would starve legitimate first frames).
expect_exact_dims: bool,
/// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an
/// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for
/// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see
/// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract.
cursor_id0_hides: bool,
}
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
@@ -301,6 +306,10 @@ impl PortalCapturer {
want_444: false,
want_hdr,
expect_exact_dims: false,
// The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta
// id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes
// one through here yet; the virtual-output path below carries the real flag.
cursor_id0_hides: false,
},
policy,
)?
@@ -316,7 +325,8 @@ impl PortalCapturer {
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
/// [`crate::open_virtual_output`] for who is allowed to pass it.
/// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares
/// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]).
#[allow(clippy::too_many_arguments)]
pub fn from_virtual_output(
remote_fd: Option<OwnedFd>,
@@ -328,6 +338,7 @@ impl PortalCapturer {
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<PortalCapturer> {
tracing::info!(
node_id,
@@ -335,6 +346,7 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
"connecting PipeWire to virtual output"
);
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
@@ -350,6 +362,7 @@ impl PortalCapturer {
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
},
policy,
)?
+2 -1
View File
@@ -811,6 +811,7 @@ pub fn pipewire_thread(
want_444,
want_hdr,
expect_exact_dims,
cursor_id0_hides,
..
} = opts;
crate::pwinit::ensure_init();
@@ -985,7 +986,7 @@ pub fn pipewire_thread(
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
cursor: CursorState::default(),
cursor: CursorState::new(cursor_id0_hides),
expect_dims: if expect_exact_dims {
preferred.map(|(w, h, _)| (w, h))
} else {
+75 -8
View File
@@ -39,9 +39,23 @@ pub(super) struct CursorState {
/// negotiated). Per-stream deliberately — a host serves many sessions per process, and a
/// process-wide latch made the second session's triage read as "no meta".
seen_meta: bool,
/// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is
/// an authoritative "pointer hidden / off this output" rather than a stale recycled region.
/// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see
/// [`note_cursor_id`].
id0_hides: bool,
}
impl CursorState {
/// The per-stream state, declaring which `id == 0` contract the producer follows
/// ([`Self::id0_hides`]).
pub(super) fn new(id0_hides: bool) -> CursorState {
CursorState {
id0_hides,
..CursorState::default()
}
}
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
@@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
}
}
/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the
/// meta region (position, bitmap) is worth parsing.
///
/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued
/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream —
/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry
/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game
/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual
/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report).
/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers
/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor
/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that
/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal —
/// Windows has its own CURSOR_SUPPRESSED source).
fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool {
if id == 0 {
if cursor.id0_hides {
cursor.visible = false;
}
return false;
}
cursor.visible = true;
true
}
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
@@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
(*cur).bitmap_offset,
)
};
if id == 0 {
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
if !note_cursor_id(cursor, id) {
return;
}
cursor.visible = true;
cursor.x = pos_x - hot_x;
cursor.y = pos_y - hot_y;
cursor.hot_x = hot_x;
@@ -367,9 +399,44 @@ mod tests {
hot_x: 0,
hot_y: 0,
seen_meta: true,
id0_hides: false,
}
}
// ---- note_cursor_id: the two producer id-0 contracts --------------------------------------
#[test]
fn id_zero_hides_only_on_a_rewriting_producer() {
// KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide —
// a game or Big Picture hiding the pointer must reach the stream.
let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255);
kwin.id0_hides = true;
assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further");
let o = kwin.overlay().expect("bitmap stays cached across a hide");
assert!(!o.visible, "KWin id 0 must hide the overlay");
// The pointer coming back re-shows the SAME cached bitmap.
assert!(note_cursor_id(&mut kwin, 1));
assert!(kwin.overlay().expect("still cached").visible);
// Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds
// (honoring them flickered the cursor off between hovers, on-glass round 5).
let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255);
assert!(!note_cursor_id(&mut mutter, 0));
assert!(
mutter.overlay().expect("cached").visible,
"a stale-meta producer's id 0 must NOT hide"
);
}
#[test]
fn id_zero_before_any_bitmap_yields_no_overlay() {
// A KWin stream whose pointer was never on the output: hides arrive before any bitmap —
// `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor.
let mut c = CursorState::new(true);
assert!(!note_cursor_id(&mut c, 0));
assert!(c.overlay().is_none());
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
+50 -16
View File
@@ -168,9 +168,18 @@ struct PlayerData {
/// Drained chunk Vecs go back here for the decode side to refill (allocation pool).
recycle: SyncSender<Vec<f32>>,
ring: VecDeque<f32>,
primed: bool,
/// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime
/// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph
/// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where
/// one transient drain manufactured a whole target's worth of fresh silence.
policy: punktfunk_core::audio::JitterPolicy,
/// Interleaved channel count this stream was opened with (2/6/8).
channels: usize,
/// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a
/// client log, so a latency or dropout report had nothing to go on.
underruns: u64,
sheds: u64,
callbacks: u64,
}
fn pw_thread(
@@ -223,8 +232,14 @@ fn pw_thread(
rx: pcm_rx,
recycle: recycle_tx,
ring: VecDeque::new(),
primed: false,
policy: punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::PIPEWIRE,
channels as u8,
),
channels,
underruns: 0,
sheds: 0,
callbacks: 0,
};
let _listener = stream
@@ -252,23 +267,29 @@ fn pw_thread(
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want = want_frames * ud.channels;
// Adaptive jitter buffer (same shape as the host's virtual mic): prime to
// ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a
// genuine drain.
let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels);
while ud.ring.len() > target.max(want) + want {
ud.ring.pop_front();
}
if !ud.primed && ud.ring.len() >= target {
ud.primed = true;
// Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction
// (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting,
// and a hard cap as the backstop.
let step = ud.policy.step(ud.ring.len(), want);
if step.drop_front > 0 {
ud.sheds += 1;
punktfunk_core::audio::crossfade_drop(
&mut ud.ring,
step.drop_front,
step.crossfade,
);
}
let mut ran_short = false;
let n_frames = if let Some(slice) = data.data() {
for k in 0..want {
let s = if ud.primed {
ud.ring.pop_front().unwrap_or(0.0)
} else {
let s = if step.silence {
0.0
} else {
ud.ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
})
};
let off = k * 4;
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
@@ -277,8 +298,21 @@ fn pw_thread(
} else {
0
};
if ud.ring.is_empty() {
ud.primed = false;
// No-op while un-primed (the policy ignores it), so a deliberate priming silence
// is never miscounted as an underrun.
ud.policy.note_read(ran_short);
ud.underruns += u64::from(ran_short);
ud.callbacks += 1;
// ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the
// plane stops being invisible.
if ud.callbacks % 2_000 == 0 {
tracing::debug!(
buffer_ms = ud.policy.avg_depth_ms(),
target_ms = ud.policy.target_ms(),
underruns = ud.underruns,
drift_sheds = ud.sheds,
"audio playback"
);
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
+50 -29
View File
@@ -3,14 +3,15 @@
//!
//! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/
//! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the
//! session pump compiles against one `crate::audio` on both OSes. Adapted from
//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its
//! built-in streaming path is deleted).
//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the
//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted,
//! so this is now the only WASAPI client ring.
//!
//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session
//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread
//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before
//! producing, cap the ring so latency stays bounded, re-prime after a real drain.
//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI
//! render thread pulls whole event-driven quanta on the device clock. The depth policy between
//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in
//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave
//! the same way and none of them can ratchet latency upward.
//!
//! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated
//! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag
@@ -250,10 +251,20 @@ fn render_thread(
audio_client.start_stream().context("start render stream")?;
let _ = ready.send(Ok(()));
// Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic).
let mut ring: VecDeque<u8> = VecDeque::new();
let mut primed = false;
// De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the
// depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade
// helper with the other three clients).
let mut ring: VecDeque<f32> = VecDeque::new();
// Shared ms-denominated policy: prime depth, crossfaded drift correction so latency
// returns to target instead of ratcheting, and de-prime hysteresis — the last replacing
// the old `if ring.is_empty()`, where a single transient drain manufactured a whole
// target's worth of fresh silence.
let mut policy = punktfunk_core::audio::JitterPolicy::new(
punktfunk_core::audio::JitterTuning::WASAPI,
channels,
);
let mut out = Vec::new(); // per-quantum scratch, reused across iterations
let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64);
while !stop.load(Ordering::Relaxed) {
if h_event.wait_for_event(100).is_err() {
@@ -262,9 +273,7 @@ fn render_thread(
// Drain everything the pump has queued into the ring, returning each drained
// Vec to the pool (a full/closed pool drops it).
while let Ok(mut chunk) = pcm_rx.try_recv() {
for s in chunk.iter() {
ring.extend(s.to_le_bytes());
}
ring.extend(chunk.iter().copied());
chunk.clear();
let _ = recycle_tx.try_send(chunk);
}
@@ -274,28 +283,40 @@ fn render_thread(
if avail_frames == 0 {
continue;
}
let want_bytes = avail_frames * block_align;
let want = avail_frames * channels as usize;
// Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain.
let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align);
let cap = target.max(want_bytes) + want_bytes;
if ring.len() > cap {
ring.drain(..ring.len() - cap);
}
if !primed && ring.len() >= target {
primed = true;
let step = policy.step(ring.len(), want);
if step.drop_front > 0 {
sheds += 1;
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
}
out.clear();
out.resize(want_bytes, 0);
if primed {
let n = ring.len().min(want_bytes);
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
*dst = b;
out.resize(avail_frames * block_align, 0);
let mut ran_short = false;
if !step.silence {
// `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes).
for dst in out.chunks_exact_mut(4) {
let s = ring.pop_front().unwrap_or_else(|| {
ran_short = true;
0.0
});
dst.copy_from_slice(&s.to_le_bytes());
}
}
if ring.is_empty() {
primed = false;
// No-op while un-primed (the policy ignores it), so a deliberate priming silence is
// never miscounted as an underrun.
policy.note_read(ran_short);
underruns += u64::from(ran_short);
callbacks += 1;
if callbacks % 1_000 == 0 {
tracing::debug!(
buffer_ms = policy.avg_depth_ms(),
target_ms = policy.target_ms(),
underruns,
drift_sheds = sheds,
"audio playback"
);
}
render_client
.write_to_device(avail_frames, &out, None)
+66 -4
View File
@@ -336,6 +336,7 @@ enum Ctl {
Detach,
Pin(Option<String>),
KindOverride(GamepadPref),
Forwarding(bool),
MenuMode(bool),
MenuRumble(MenuPulse),
}
@@ -482,6 +483,26 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
/// Forward this device's controllers to the host at all ([`Settings::gamepad_forwarding`],
/// default on). Off is for a couch whose pad reaches the host another way — a USB
/// passthrough tool like VirtualHere, or a controller plugged into the host itself —
/// where forwarding as well would give the host two pads for one pair of hands.
///
/// Off holds no slot open, so nothing is sent AND nothing is *grabbed*: no arrival, no
/// virtual pad host-side, and the hidraw node stays free for the passthrough tool to
/// bind (SDL's HIDAPI drivers take it at open — a held device cannot be bound away).
/// It follows that the escape chord, which only listens on forwarded pads, is not
/// available while off; the keyboard chord and the client's own UI still end a session.
///
/// Menu navigation is untouched: the launcher still opens the active pad to drive its
/// UI, and a session — which supersedes menu mode whether it forwards or not — releases
/// it again, so the pad is free for the whole time a stream is up.
///
/// [`Settings::gamepad_forwarding`]: crate::trust::Settings::gamepad_forwarding
pub fn set_forwarding(&self, on: bool) {
let _ = self.ctl.send(Ctl::Forwarding(on));
}
pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector));
}
@@ -721,6 +742,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>,
/// Forward controllers to an attached session at all ([`GamepadService::set_forwarding`]).
/// Off makes [`Self::forwarded_ids`] empty, so a session opens no slot — the whole point
/// being that the hardware stays ungrabbed for a USB passthrough tool.
forwarding: bool,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
@@ -815,6 +840,11 @@ impl Worker {
/// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck
/// game-mode case — otherwise its gyro/paddles/input would have nowhere to land).
fn forwarded_ids(&self) -> Vec<u32> {
// Forwarding off: nothing is forwarded, so nothing is opened either — the device stays
// free for whatever route the user's controller actually takes to the host.
if !self.forwarding {
return Vec::new();
}
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
@@ -1243,10 +1273,16 @@ impl Worker {
Ok(Ctl::Attach(c)) => {
self.attached = Some(c);
self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
set_valve_hidapi(true);
// The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open opens a slot per forwarded pad.
// Not with forwarding off: this session opens no slot, and the drivers'
// mere enumeration both kills the Deck's trackpad-mouse and is the
// opposite of leaving the hardware alone for a passthrough tool.
if self.forwarding {
set_valve_hidapi(true);
}
self.sync_open();
}
Ok(Ctl::Detach) => {
@@ -1269,6 +1305,31 @@ impl Worker {
self.refresh_active();
}
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::Forwarding(on)) => {
if self.forwarding == on {
continue;
}
self.forwarding = on;
self.reset_chord(); // no forwarded pad can be mid-chord across the flip
// Applied live rather than at attach only, so a mid-session flip (an
// in-stream settings screen) takes effect on the pad in your hands.
//
// The Valve HIDAPI drivers are an in-session-only thing (see
// set_valve_hidapi), and forwarding off is — for their purpose — not in
// session. Order matters and differs by direction: ON must enable them
// BEFORE `sync_open`, or a Deck's built-in pad opens under its old
// identity; OFF must disable them AFTER, so no slot outlives the driver
// that opened it.
let attached = self.attached.is_some();
if on && attached {
set_valve_hidapi(true);
}
self.sync_open();
if !on && attached {
set_valve_hidapi(false);
}
}
Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on;
if on {
@@ -1608,6 +1669,7 @@ impl Worker {
menu_open: None,
order: Vec::new(),
pinned: None,
forwarding: true,
kind_override: GamepadPref::Auto,
attached: None,
escape_tx,
+4
View File
@@ -982,6 +982,10 @@ mod tests {
height: 1440,
bitrate_kbps: 55000,
codec: "av1".into(),
present_priority: "smooth".into(),
smooth_buffer: 2,
vsync: false,
allow_vrr: false,
..Default::default()
},
clipboard: true,
+139
View File
@@ -74,9 +74,23 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamepad_forwarding: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fullscreen_on_stream: Option<bool>,
/// The presentation cluster — the keys the Apple client already writes into this
/// same catalog shape (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`;
/// Android carries the first two). First-class here so a profile authored on any
/// client applies on all of them instead of riding `extra` unapplied.
#[serde(skip_serializing_if = "Option::is_none")]
pub present_priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_buffer: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vsync: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_vrr: Option<bool>,
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
/// load→save round-trip untouched.
#[serde(flatten)]
@@ -142,6 +156,9 @@ impl SettingsOverlay {
if let Some(v) = &self.gamepad {
s.gamepad = v.clone();
}
if let Some(v) = self.gamepad_forwarding {
s.gamepad_forwarding = v;
}
if let Some(v) = self.stats_verbosity {
// Through the setter so the legacy `show_stats` bool stays coherent for
// pre-tier binaries reading the same settings file.
@@ -150,6 +167,18 @@ impl SettingsOverlay {
if let Some(v) = self.fullscreen_on_stream {
s.fullscreen_on_stream = v;
}
if let Some(v) = &self.present_priority {
s.present_priority = v.clone();
}
if let Some(v) = self.smooth_buffer {
s.smooth_buffer = v;
}
if let Some(v) = self.vsync {
s.vsync = v;
}
if let Some(v) = self.allow_vrr {
s.allow_vrr = v;
}
s
}
@@ -220,12 +249,27 @@ impl SettingsOverlay {
if after.gamepad != before.gamepad {
self.gamepad = Some(after.gamepad.clone());
}
if after.gamepad_forwarding != before.gamepad_forwarding {
self.gamepad_forwarding = Some(after.gamepad_forwarding);
}
if after.stats_verbosity() != before.stats_verbosity() {
self.stats_verbosity = Some(after.stats_verbosity());
}
if after.fullscreen_on_stream != before.fullscreen_on_stream {
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
}
if after.present_priority != before.present_priority {
self.present_priority = Some(after.present_priority.clone());
}
if after.smooth_buffer != before.smooth_buffer {
self.smooth_buffer = Some(after.smooth_buffer);
}
if after.vsync != before.vsync {
self.vsync = Some(after.vsync);
}
if after.allow_vrr != before.allow_vrr {
self.allow_vrr = Some(after.allow_vrr);
}
}
/// Drop one override by its overlay field name, putting the row back to inheriting. The
@@ -257,8 +301,13 @@ impl SettingsOverlay {
"invert_scroll" => self.invert_scroll = None,
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
"gamepad" => self.gamepad = None,
"gamepad_forwarding" => self.gamepad_forwarding = None,
"stats_verbosity" => self.stats_verbosity = None,
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
"present_priority" => self.present_priority = None,
"smooth_buffer" => self.smooth_buffer = None,
"vsync" => self.vsync = None,
"allow_vrr" => self.allow_vrr = None,
_ => return false,
}
true
@@ -433,6 +482,10 @@ mod tests {
assert_eq!((out.width, out.height), (1920, 1080));
assert_eq!(out.bitrate_kbps, 20000);
assert_eq!(out.codec, "hevc");
assert!(
out.gamepad_forwarding,
"default on, and an empty overlay leaves it alone"
);
assert!(empty.is_empty());
let overlay = SettingsOverlay {
@@ -452,9 +505,14 @@ mod tests {
invert_scroll: Some(true),
inhibit_shortcuts: Some(false),
gamepad: Some("dualsense".into()),
gamepad_forwarding: Some(false),
match_window: Some(true),
fullscreen_on_stream: Some(false),
stats_verbosity: Some(StatsVerbosity::Detailed),
present_priority: Some("smooth".into()),
smooth_buffer: Some(3),
vsync: Some(false),
allow_vrr: Some(false),
..Default::default()
};
assert!(!overlay.is_empty());
@@ -473,9 +531,14 @@ mod tests {
assert!(out.invert_scroll);
assert!(!out.inhibit_shortcuts);
assert_eq!(out.gamepad, "dualsense");
assert!(!out.gamepad_forwarding);
assert!(out.match_window);
assert!(!out.fullscreen_on_stream);
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
assert_eq!(out.present_priority, "smooth");
assert_eq!(out.smooth_buffer, 3);
assert!(!out.vsync);
assert!(!out.allow_vrr);
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
// stays coherent with it.
assert!(out.show_stats);
@@ -573,6 +636,59 @@ mod tests {
assert!(o.is_empty());
}
/// The presentation cluster is first-class, not `extra` passengers: it applies,
/// absorbs, clears, and serialises under the exact keys the Apple client already
/// writes (`present_priority`/`smooth_buffer`/`vsync`/`allow_vrr`) — one catalog
/// has to round-trip through every platform, and a mismatched key would be carried
/// but never applied.
#[test]
fn presentation_cluster_is_first_class() {
let base = Settings::default();
let mut o = SettingsOverlay::default();
let before = o.apply(&base);
let mut after = before.clone();
after.present_priority = "smooth".into();
o.absorb(&before, &after);
let before = o.apply(&base);
let mut after = before.clone();
after.smooth_buffer = 1;
o.absorb(&before, &after);
assert_eq!(o.present_priority.as_deref(), Some("smooth"));
assert_eq!(o.smooth_buffer, Some(1));
assert!(
o.extra.is_empty(),
"modelled fields must never land in the passthrough"
);
let out = o.apply(&base);
assert_eq!(
out.present_priority(),
crate::trust::PresentPriority::Smooth { buffer: 1 }
);
// Serialised under the shared keys, and read back from a foreign client's file.
let text = serde_json::to_string(&o).unwrap();
assert!(text.contains("\"present_priority\":\"smooth\""), "{text}");
assert!(text.contains("\"smooth_buffer\":1"), "{text}");
let from_apple: SettingsOverlay = serde_json::from_str(
r#"{"present_priority":"latency","smooth_buffer":2,"vsync":true,"allow_vrr":false}"#,
)
.unwrap();
assert_eq!(from_apple.present_priority.as_deref(), Some("latency"));
assert_eq!(from_apple.smooth_buffer, Some(2));
assert_eq!(from_apple.vsync, Some(true));
assert_eq!(from_apple.allow_vrr, Some(false));
assert!(from_apple.extra.is_empty());
assert!(o.clear("present_priority"));
assert!(o.clear("smooth_buffer"));
assert_eq!(o.present_priority, None);
assert!(o.is_empty());
let mut vrr = from_apple;
assert!(vrr.clear("vsync"));
assert!(vrr.clear("allow_vrr"));
assert_eq!((vrr.vsync, vrr.allow_vrr), (None, None));
}
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
#[test]
fn clear_drops_one_override() {
@@ -591,6 +707,29 @@ mod tests {
assert!(!o.clear("no_such_field"));
}
/// Controller forwarding defaults ON, so its interesting override is the FALSE one — and a
/// `false` that `apply` dropped would silently forward a pad the profile said not to.
/// `absorb` must record it, `clear` must undo it, and the serialized name both carry is the
/// one every client's reset button sends.
#[test]
fn gamepad_forwarding_overrides_off_and_resets_back() {
let base = Settings::default();
assert!(base.gamepad_forwarding, "the shipped default");
let mut o = SettingsOverlay::default();
let mut after = base.clone();
after.gamepad_forwarding = false;
o.absorb(&base, &after);
assert_eq!(o.gamepad_forwarding, Some(false));
assert!(!o.apply(&base).gamepad_forwarding);
assert!(o.clear("gamepad_forwarding"));
assert_eq!(o.gamepad_forwarding, None);
assert!(o.is_empty());
// Back to inheriting: the global's live value, not a remembered false.
assert!(o.apply(&base).gamepad_forwarding);
}
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
#[test]
+164
View File
@@ -14,6 +14,7 @@ use anyhow::{anyhow, Context, Result};
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn config_dir() -> Result<PathBuf> {
@@ -787,6 +788,45 @@ impl MouseMode {
}
}
/// Presentation intent — what the presenter optimizes for
/// (design/desktop-presentation-rebuild.md; the Apple/Android clients' shared
/// `present_priority`/`smooth_buffer` pair). Stored stringly in
/// [`Settings::present_priority`] + [`Settings::smooth_buffer`]; resolved with
/// [`PresentPriority::resolve`], whose rules match the Android reference
/// (`decode/presenter.rs`): anything but an explicit `"smooth"` is latency, and a
/// smooth buffer outside 1..=3 (including 0 = Automatic) becomes 2.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PresentPriority {
/// Every frame presents the moment the display can take it; a network hiccup is an
/// occasional repeated or skipped frame. The default.
Latency,
/// A small frame buffer (13 frames) evens out network/decode jitter, at the
/// buffer's worth of added display latency.
Smooth { buffer: u8 },
}
impl PresentPriority {
/// The shared cross-client resolution rule — pure, so every embedder agrees on what
/// a foreign profile's values mean.
pub fn resolve(name: &str, buffer: u8) -> PresentPriority {
if name == "smooth" {
PresentPriority::Smooth {
buffer: if (1..=3).contains(&buffer) { buffer } else { 2 },
}
} else {
PresentPriority::Latency
}
}
/// Frames the smoothing store holds; `0` = newest-wins (the latency intent).
pub fn fifo_capacity(self) -> u8 {
match self {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -808,6 +848,21 @@ pub struct Settings {
/// container `#[serde(default)]`.
pub render_scale: f64,
pub gamepad: String,
/// Forward this device's controllers to the host at all. Default ON — that was the
/// unconditional behaviour before this became a setting.
///
/// Off is for the couch whose controller reaches the host by some *other* route: a USB
/// passthrough tool (VirtualHere and friends), or a pad simply plugged into the host
/// itself. Leaving forwarding on there gives the host two controllers for one pair of
/// hands, and games read both.
///
/// It is deliberately stronger than "send no input": with it off the client never
/// *opens* the controller, and opening is what grabs the hardware (SDL's HIDAPI drivers
/// take the hidraw node) — a held device is one a passthrough tool cannot bind. Menu
/// navigation in the launcher still opens the active pad, and the session releases it;
/// see [`crate::gamepad::GamepadService::set_forwarding`].
#[serde(default = "default_true")]
pub gamepad_forwarding: bool,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
@@ -874,6 +929,32 @@ pub struct Settings {
/// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")]
pub hdr_enabled: bool,
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
/// clients' shared `present_priority` profile key, resolved with
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
/// unknown reads as latency, so a newer client's future value degrades safely.
#[serde(default = "default_present_priority")]
pub present_priority: String,
/// Smoothness buffer size in frames: `0` = Automatic (resolves to 2), else 13.
/// Only meaningful under `present_priority = "smooth"` (the shared `smooth_buffer`
/// key). Each buffered frame absorbs about one refresh of jitter and adds one
/// refresh of display latency.
#[serde(default)]
pub smooth_buffer: u8,
/// Tear-free presentation (default ON = today's behavior: MAILBOX, FIFO fallback).
/// Off asks for a tearing present mode (IMMEDIATE) for the lowest possible latch
/// latency — best-effort: platforms/drivers without tearing silently stay tear-free
/// and the active mode is visible in the detailed stats. The shared `vsync` profile
/// key; the desktop default differs from macOS's (`false` there) deliberately —
/// sync-off means something different on each platform, the key is the contract.
#[serde(default = "default_true")]
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence: prefers the present
/// mode that drives VRR panels directly when fullscreen. Inert on fixed-refresh
/// displays (detection is measured from on-glass timestamps, not queried). The
/// shared `allow_vrr` profile key. Default ON, like the Apple client.
#[serde(default = "default_true")]
pub allow_vrr: bool,
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
@@ -925,6 +1006,14 @@ pub struct Settings {
/// the user will be looking at. `0` = never stored → the 1280×720 default.
pub last_window_w: u32,
pub last_window_h: u32,
/// Settings keys this build doesn't model (a newer client's field), carried through a
/// load→save round-trip untouched — [`crate::profiles::SettingsOverlay`]'s `extra`
/// pattern extended to the globals. Without it, every whole-file writer of this store
/// (two shells, the console settings screen, the session's resize callback, Decky)
/// running as an OLDER binary silently drops what a newer one persisted. Empty on
/// every existing store, and an empty map serializes to nothing, so files don't churn.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
fn default_codec() -> String {
@@ -939,6 +1028,10 @@ fn default_mouse_mode() -> String {
"capture".into()
}
fn default_present_priority() -> String {
"latency".into()
}
fn default_true() -> bool {
true
}
@@ -970,6 +1063,12 @@ impl Settings {
MouseMode::from_name(&self.mouse_mode)
}
/// The presentation intent for this session (the resolved
/// `present_priority` × `smooth_buffer` pair).
pub fn present_priority(&self) -> PresentPriority {
PresentPriority::resolve(&self.present_priority, self.smooth_buffer)
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() {
@@ -994,6 +1093,7 @@ impl Default for Settings {
bitrate_kbps: 0,
render_scale: 1.0,
gamepad: "auto".into(),
gamepad_forwarding: true,
forward_pad: String::new(),
compositor: "auto".into(),
touch_mode: "trackpad".into(),
@@ -1007,6 +1107,10 @@ impl Default for Settings {
adapter: String::new(),
enable_444: false,
hdr_enabled: true,
present_priority: "latency".into(),
smooth_buffer: 0,
vsync: true,
allow_vrr: true,
show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true,
@@ -1018,6 +1122,7 @@ impl Default for Settings {
match_window: false,
last_window_w: 0,
last_window_h: 0,
extra: BTreeMap::new(),
}
}
}
@@ -1144,6 +1249,43 @@ mod tests {
}
}
/// A settings file predating the presentation cluster loads with the shipped
/// defaults (latency intent, Automatic buffer, tear-free, VRR allowed), and the
/// resolution rules match the Apple/Android reference: anything but an explicit
/// `"smooth"` is latency, and a smooth buffer outside 1..=3 becomes 2.
#[test]
fn settings_presentation_defaults_and_resolution() {
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.present_priority, "latency");
assert_eq!(s.smooth_buffer, 0);
assert!(s.vsync);
assert!(s.allow_vrr);
assert_eq!(s.present_priority(), PresentPriority::Latency);
assert_eq!(
PresentPriority::resolve("smooth", 0),
PresentPriority::Smooth { buffer: 2 },
"Automatic resolves to 2"
);
assert_eq!(
PresentPriority::resolve("smooth", 3),
PresentPriority::Smooth { buffer: 3 }
);
assert_eq!(
PresentPriority::resolve("smooth", 9),
PresentPriority::Smooth { buffer: 2 },
"out-of-range pins to the Automatic resolution"
);
assert_eq!(
PresentPriority::resolve("balanced-from-the-future", 2),
PresentPriority::Latency,
"unknown intents degrade to latency"
);
assert_eq!(PresentPriority::Latency.fifo_capacity(), 0);
assert_eq!(PresentPriority::Smooth { buffer: 3 }.fifo_capacity(), 3);
}
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
@@ -1192,6 +1334,28 @@ mod tests {
assert!(s.echo_cancel);
}
/// A key this build doesn't model (a newer client's setting) survives a load→save
/// round trip instead of being dropped by the next whole-file write — the same
/// contract `SettingsOverlay.extra` gives profiles. And when there are no unknown
/// keys, the flatten map adds nothing, so existing files don't churn.
#[test]
fn settings_unknown_keys_survive_round_trip() {
let newer = r#"{"width":1920,"height":1080,"frob_mode":"fancy","frob_level":3}"#;
let s: Settings = serde_json::from_str(newer).unwrap();
assert_eq!((s.width, s.height), (1920, 1080));
assert_eq!(
s.extra.get("frob_mode").and_then(|v| v.as_str()),
Some("fancy")
);
let out = serde_json::to_string(&s).unwrap();
assert!(out.contains(r#""frob_mode":"fancy""#), "{out}");
assert!(out.contains(r#""frob_level":3"#), "{out}");
// No unknown keys → no artifact of the passthrough field in the file.
let plain = serde_json::to_string(&Settings::default()).unwrap();
assert!(!plain.contains("extra"), "{plain}");
assert!(!plain.contains("frob"), "{plain}");
}
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
+3 -1
View File
@@ -35,7 +35,9 @@ mod widgets;
#[cfg(any(target_os = "linux", windows))]
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
#[cfg(any(target_os = "linux", windows))]
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
pub use model::{
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
};
#[cfg(any(target_os = "linux", windows))]
pub use shell::ConsoleOptions;
#[cfg(any(target_os = "linux", windows))]
+33 -2
View File
@@ -7,9 +7,20 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
/// thread resolves these against the catalog; the shell never opens the profiles file.
#[derive(Clone, Debug, PartialEq)]
pub struct ProfileChip {
pub id: String,
pub name: String,
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
pub accent: Option<String>,
}
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
/// the shell renders it verbatim.
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
/// the service thread; the shell renders it verbatim.
#[derive(Clone, Debug, PartialEq)]
pub struct HostRow {
/// Stable identity across refreshes: the pinned fingerprint when known, else
@@ -35,6 +46,14 @@ pub struct HostRow {
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
/// follow-up — the Skia glyph set doesn't exist yet.
pub os: String,
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
/// after its host's primary tile, sharing its live state, that connects with THIS
/// profile. `None` = the host's primary tile.
pub pin: Option<ProfileChip>,
/// The primary tile's default-profile chip: the profile bound as this host's default
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
/// Always `None` on pinned rows — there the profile IS `pin`.
pub bound_profile: Option<ProfileChip>,
}
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
@@ -143,6 +162,16 @@ pub enum ConsoleCmd {
CancelWake,
/// Sweep reachability now (the home screen refreshes its presence pips).
Probe,
/// Pin (or unpin) a profile as an extra connect card on a saved host
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
/// (fingerprint or `addr:port`); presentation only — never touches the host's
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
/// (or unpinning an absent one) is a no-op.
SetPin {
key: String,
profile_id: String,
pin: bool,
},
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
@@ -184,6 +213,8 @@ mod tests {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
};
shared.set_hosts(vec![row.clone()]);
let g1 = shared.hosts_gen();
+9
View File
@@ -7,6 +7,7 @@ pub(crate) mod add_host;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod pair;
pub(crate) mod pin_hosts;
pub(crate) mod settings;
use crate::glyphs::Hint;
@@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent {
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
/// binary parks on a long budget and persists the host as paired once let in.
pub request_access: bool,
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
/// keeps the host's default binding.
pub profile: Option<String>,
}
pub(crate) enum Nav {
@@ -91,6 +95,7 @@ pub(crate) enum Screen {
Settings(settings::SettingsScreen),
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
}
impl Screen {
@@ -106,6 +111,7 @@ impl Screen {
Screen::Settings(s) => s.menu(ev, ctx, fx),
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
}
}
@@ -152,6 +158,7 @@ impl Screen {
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(_) => "Add Host".into(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
}
}
@@ -162,6 +169,7 @@ impl Screen {
Screen::Settings(s) => s.hints(ctx),
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
}
}
@@ -183,6 +191,7 @@ impl Screen {
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
}
}
+117 -11
View File
@@ -94,13 +94,19 @@ impl HomeScreen {
Some(h) => {
// Dial-first even when the presence pips say offline — a
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
// A pinned card connects with ITS profile (one-off, §5.2a);
// the primary tile keeps the host's default binding.
fx.connect = Some(ConnectIntent {
addr: h.addr.clone(),
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
title: match &h.pin {
Some(p) => format!("{} · {}", h.name, p.name),
None => h.name.clone(),
},
request_access: false,
profile: h.pin.as_ref().map(|p| p.id.clone()),
});
}
}
@@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
let max_w = f64::from(rect.width()) - 2.0 * pad;
let sub_base = f64::from(rect.bottom) - pad;
fonts.draw_clipped(
canvas,
&format!("{}:{}", h.addr, h.port),
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
match (&h.pin, &h.bound_profile) {
// A pinned card: the profile name IS the subtitle, tinted with its accent —
// the card's whole point is "this host, with these settings" (§5.2a).
(Some(p), _) => {
fonts.draw_clipped(
canvas,
&p.name,
l,
sub_base,
W::SemiBold,
13.0 * k,
accent_color(p.accent.as_deref()),
max_w,
);
}
// The primary tile says which profile a plain press uses, after the address.
(None, Some(b)) => {
let addr = format!("{}:{}", h.addr, h.port);
let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k));
fonts.draw_clipped(
canvas,
&addr,
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
let x = l + addr_w + 8.0 * k;
if x < l + max_w {
fonts.draw_clipped(
canvas,
&format!("· {}", b.name),
x,
sub_base,
W::SemiBold,
13.0 * k,
accent_color(b.accent.as_deref()),
l + max_w - x,
);
}
}
(None, None) => {
fonts.draw_clipped(
canvas,
&format!("{}:{}", h.addr, h.port),
l,
sub_base,
W::Regular,
13.0 * k,
white(0.55),
max_w,
);
}
}
fonts.draw_clipped(
canvas,
&h.name,
@@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
);
}
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
let Some(hex) = accent
.and_then(|a| a.strip_prefix('#'))
.filter(|h| h.len() == 6)
else {
return BRAND;
};
let Ok(v) = u32::from_str_radix(hex, 16) else {
return BRAND;
};
skia_safe::Color4f::new(
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
1.0,
)
}
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
crate::theme::panel(
canvas,
@@ -484,6 +556,8 @@ mod tests {
can_wake,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
}
}
@@ -551,6 +625,38 @@ mod tests {
));
}
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
/// connecting takeover says which settings are coming (§5.2a).
#[test]
fn pinned_card_connects_with_its_profile() {
let mut settings = ctx_settings();
let mut pinned = host("ab\0p1", true, true, false);
pinned.name = "Tower".into();
pinned.pin = Some(crate::model::ProfileChip {
id: "p1".into(),
name: "Work".into(),
accent: None,
});
let hosts = [pinned];
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "test",
t: 0.0,
};
let mut s = HomeScreen::new();
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
let intent = fx.connect.expect("a pinned card connects");
assert_eq!(intent.profile.as_deref(), Some("p1"));
assert_eq!(intent.title, "Tower · Work");
}
#[test]
fn add_tile_is_always_last() {
let mut settings = ctx_settings();
@@ -120,6 +120,8 @@ impl LibraryScreen {
launch: Some(g.id.clone()),
title: g.title.clone(),
request_access: false,
// Game launches follow the host's default binding.
profile: None,
});
Some(MenuPulse::Confirm)
}
+3
View File
@@ -221,6 +221,7 @@ impl PairScreen {
launch: None,
title: self.host_name.clone(),
request_access: true,
profile: None,
});
fx.pop();
}
@@ -430,6 +431,8 @@ mod tests {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
}
}
@@ -0,0 +1,266 @@
//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card
//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's
//! Profiles section. One toggle row per saved host; a toggle rides
//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles`
//! and refreshes the rows — the row's shown state follows the model, so what the list
//! says is always what the store holds (and what Decky's host list will render).
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::screens::{Ctx, Outbox};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use skia_safe::{Canvas, Rect};
pub(crate) struct PinHostsScreen {
profile_id: String,
profile_name: String,
list: MenuList,
}
/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the
/// OUTPUT of this screen, not a row in it), in the model's carousel order.
fn host_indices(ctx: &Ctx) -> Vec<usize> {
ctx.hosts
.iter()
.enumerate()
.filter(|(_, h)| h.saved && h.pin.is_none())
.map(|(i, _)| i)
.collect()
}
impl PinHostsScreen {
pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen {
PinHostsScreen {
profile_id,
profile_name,
list: MenuList::new(),
}
}
pub(crate) fn profile_name(&self) -> &str {
&self.profile_name
}
/// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from
/// the model — the pinned card's row IS the state, so the toggle can never disagree
/// with what the carousel shows.
fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool {
let host = &ctx.hosts[host_idx];
ctx.hosts.iter().any(|r| {
r.addr == host.addr
&& r.port == host.port
&& r.pin.as_ref().is_some_and(|p| p.id == self.profile_id)
})
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let indices = host_indices(ctx);
let (msg, pulse) = self.list.menu(ev, indices.len());
let Some(&host_idx) = indices.get(self.list.cursor) else {
return pulse;
};
// Toggle semantics shared with the settings rows: left = unpin, right = pin,
// A flips; asking for the state it's already in is a boundary thud.
let target = match msg {
ListMsg::Adjust(delta) => delta > 0,
ListMsg::Activate => !self.pinned(ctx, host_idx),
ListMsg::None => return pulse,
};
if self.pinned(ctx, host_idx) == target {
return Some(MenuPulse::Boundary);
}
fx.cmds.push(ConsoleCmd::SetPin {
key: ctx.hosts[host_idx].key.clone(),
profile_id: self.profile_id.clone(),
pin: target,
});
Some(MenuPulse::Move)
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
if host_indices(ctx).is_empty() {
return vec![Hint::new(HintKey::Back, "Done")];
}
vec![
Hint::new(HintKey::Confirm, "Pin / Unpin"),
Hint::new(HintKey::Back, "Done"),
]
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
let indices = host_indices(ctx);
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
if indices.is_empty() {
fonts.centered(
canvas,
"No saved hosts yet — pair with a host first, then pin this profile to it.",
W::Regular,
14.0 * k,
DIM,
cx,
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
f64::from(rect.width()) * 0.7,
);
return;
}
// The explainer band under the list, like the settings screen's detail text.
let detail_h = 34.0 * k;
let list_rect = Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
);
let rows: Vec<RowSpec> = indices
.iter()
.map(|&i| {
let h = &ctx.hosts[i];
let pinned = self.pinned(ctx, i);
RowSpec {
header: None,
label: h.name.clone(),
value: Some(if pinned {
"Pinned".into()
} else {
"Off".into()
}),
value_dim: !pinned,
caret: false,
adjustable: true,
enabled: true,
}
})
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
fonts.centered(
canvas,
"A pinned profile appears as its own card on the host — one press connects with it.",
W::Regular,
13.0 * k,
DIM,
cx,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{HostRow, ProfileChip};
use crate::screens::Outbox;
use pf_client_core::trust::Settings;
fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow {
HostRow {
key: key.into(),
name: key.into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: key.into(),
paired: true,
saved,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
os: String::new(),
pin: pin.map(|id| ProfileChip {
id: id.into(),
name: "Work".into(),
accent: None,
}),
bound_profile: None,
}
}
#[test]
fn toggling_sends_set_pin_for_the_focused_host() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
let hosts = [host("aa", true, None), host("bb", true, None)];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetPin {
key: "aa".into(),
profile_id: "p1".into(),
pin: true,
}]
);
// Left on an unpinned host = already off = boundary, no command.
let mut fx = Outbox::default();
let pulse = s.menu(
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left),
&mut ctx,
&mut fx,
);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
#[test]
fn state_reads_from_the_models_pinned_rows() {
let mut settings = Settings::default();
let pads = Vec::new();
let library = crate::library::LibraryShared::default();
// Host "aa" already carries a pinned card for p1; its primary row toggles OFF.
let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
// Only the primary row is a toggle row.
assert_eq!(host_indices(&ctx).len(), 1);
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::SetPin {
key: "aa".into(),
profile_id: "p1".into(),
pin: false,
}]
);
}
}
+383 -22
View File
@@ -6,7 +6,7 @@
//! read the same file, so values round-trip freely.
use crate::glyphs::{Hint, HintKey};
use crate::screens::{Ctx, Outbox};
use crate::screens::{Ctx, Outbox, Screen};
use crate::theme::{Fonts, DIM, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
@@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect};
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
/// index when the pad list under the "Use controller" row churns.
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RowId {
/// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens
/// the pin-to-hosts screen. The console never edits profiles (design §5.4).
Profile(usize),
/// The Profiles section's placeholder while the catalog is empty.
NoProfiles,
Resolution,
Refresh,
RenderScale,
@@ -26,9 +31,14 @@ enum RowId {
Decoder,
Hdr,
Chroma444,
PresentPriority,
SmoothBuffer,
Vsync,
AllowVrr,
Audio,
Mic,
EchoCancel,
PadForward,
Pad,
PadType,
Touch,
@@ -45,8 +55,9 @@ enum RowId {
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
const ROWS: [RowId; 22] = [
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
const ROWS: [RowId; 27] = [
RowId::Resolution,
RowId::Refresh,
RowId::RenderScale,
@@ -56,9 +67,14 @@ const ROWS: [RowId; 22] = [
RowId::Decoder,
RowId::Hdr,
RowId::Chroma444,
RowId::PresentPriority,
RowId::SmoothBuffer,
RowId::Vsync,
RowId::AllowVrr,
RowId::Audio,
RowId::Mic,
RowId::EchoCancel,
RowId::PadForward,
RowId::Pad,
RowId::PadType,
RowId::Touch,
@@ -117,6 +133,17 @@ const DECODERS: [(&str, &str); 4] = [
("software", "Software"),
];
const AUDIO: [(u8, &str); 3] = [(2, "Stereo"), (6, "5.1"), (8, "7.1")];
/// Presentation intent — the `present_priority` key shared with the Apple and Android
/// clients, so one profile reads the same on every device.
const PRESENT_PRIORITIES: [(&str, &str); 2] =
[("latency", "Lowest latency"), ("smooth", "Smoothness")];
/// Smoothness buffer depth in frames; `0` = Automatic (resolves to 2).
const SMOOTH_BUFFERS: [(u8, &str); 4] = [
(0, "Automatic"),
(1, "1 frame"),
(2, "2 frames"),
(3, "3 frames"),
];
const PAD_TYPES: [(&str, &str); 6] = [
("auto", "Automatic"),
("xbox360", "Xbox 360"),
@@ -128,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [
pub(crate) struct SettingsScreen {
list: MenuList,
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
/// for the screen's lifetime.
profiles: Vec<(String, String)>,
}
impl SettingsScreen {
pub(crate) fn new() -> SettingsScreen {
Self::with_profiles(
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect(),
)
}
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
SettingsScreen {
list: MenuList::new(),
profiles,
}
}
/// The full row list: the fixed settings rows, then the Profiles section — one row
/// per catalog profile, or the explainer placeholder while there are none.
fn row_ids(&self) -> Vec<RowId> {
let mut ids = ROWS.to_vec();
if self.profiles.is_empty() {
ids.push(RowId::NoProfiles);
} else {
ids.extend((0..self.profiles.len()).map(RowId::Profile));
}
ids
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
@@ -147,7 +201,31 @@ impl SettingsScreen {
fx.pop();
return None;
}
let (msg, pulse) = self.list.menu(ev, ROWS.len());
let ids = self.row_ids();
let (msg, pulse) = self.list.menu(ev, ids.len());
// The Profiles rows navigate instead of editing the settings file.
match ids[self.list.cursor] {
RowId::Profile(i) => {
return match msg {
ListMsg::Activate => {
let (id, name) = self.profiles[i].clone();
fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new(
id, name,
)));
pulse
}
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
}
RowId::NoProfiles => {
return match msg {
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
}
}
_ => {}
}
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
// screen is one of the settings file's several whole-file writers (profiles.rs
// documents the no-merge debt), and adjusting a stale snapshot would silently
@@ -159,7 +237,7 @@ impl SettingsScreen {
}
match msg {
ListMsg::Adjust(delta) => {
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
if changed {
ctx.settings.save();
Some(MenuPulse::Move)
@@ -169,7 +247,7 @@ impl SettingsScreen {
}
ListMsg::Activate => {
// A cycles forward WRAPPING, so every option is reachable one-handed.
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
if adjust(ids[self.list.cursor], 1, true, ctx) {
ctx.settings.save();
}
pulse
@@ -179,11 +257,18 @@ impl SettingsScreen {
}
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
]
match self.row_ids()[self.list.cursor] {
RowId::Profile(_) => vec![
Hint::new(HintKey::Confirm, "Pin to hosts…"),
Hint::new(HintKey::Back, "Done"),
],
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
_ => vec![
Hint::new(HintKey::Adjust, "Adjust"),
Hint::new(HintKey::Confirm, "Change"),
Hint::new(HintKey::Back, "Done"),
],
}
}
pub(crate) fn render(
@@ -203,10 +288,14 @@ impl SettingsScreen {
rect.right,
rect.bottom - detail_h as f32,
);
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
let ids = self.row_ids();
let rows: Vec<RowSpec> = ids
.iter()
.map(|id| row_spec(*id, ctx, &self.profiles))
.collect();
self.list
.render(canvas, list_rect, &rows, fonts, k, dt, true);
let detail = detail(ROWS[self.list.cursor]);
let detail = detail(ids[self.list.cursor]);
fonts.centered(
canvas,
detail,
@@ -220,11 +309,51 @@ impl SettingsScreen {
}
}
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
match id {
RowId::Profile(i) => {
let (pid, name) = &profiles[i];
let pins = ctx
.hosts
.iter()
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
.count();
return RowSpec {
header: (i == 0).then_some("Profiles"),
label: name.clone(),
value: Some(match pins {
0 => "Not pinned".into(),
1 => "Pinned to 1 host".into(),
n => format!("Pinned to {n} hosts"),
}),
value_dim: pins == 0,
caret: false,
adjustable: false,
enabled: true,
};
}
RowId::NoProfiles => {
let mut row = RowSpec::action("No profiles yet", false);
row.header = Some("Profiles");
return row;
}
_ => {}
}
let s = &ctx.settings;
// Echo cancellation only means anything while the mic streams — dimmed and inert while it
// doesn't, the same relationship the desktop shells draw with a greyed-out row.
let enabled = !matches!(id, RowId::EchoCancel) || s.mic_enabled;
// Several rows follow another: echo cancellation only means anything while the mic
// streams, the pad rows only while any controller is forwarded at all, and the
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
// — the same relationship the desktop shells draw by greying a row out (they hide the
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
// move everything under the cursor).
let enabled = match id {
RowId::EchoCancel => s.mic_enabled,
RowId::Pad | RowId::PadType => s.gamepad_forwarding,
RowId::SmoothBuffer => s.present_priority == "smooth",
_ => true,
};
let (header, label, value): (Option<&'static str>, &str, String) = match id {
RowId::Resolution => (
Some("Stream"),
@@ -279,6 +408,22 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
RowId::Decoder => (None, "Decoder", label_for(&DECODERS, &s.decoder).into()),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::PresentPriority => (
Some("Presentation"),
"Prioritize",
label_for(&PRESENT_PRIORITIES, &s.present_priority).into(),
),
RowId::SmoothBuffer => (
None,
"Smoothness buffer",
SMOOTH_BUFFERS
.iter()
.find(|(v, _)| *v == s.smooth_buffer)
.map_or("Automatic", |(_, l)| l)
.into(),
),
RowId::Vsync => (None, "V-Sync", on_off(s.vsync).into()),
RowId::AllowVrr => (None, "Follow variable refresh", on_off(s.allow_vrr).into()),
RowId::Audio => (
Some("Audio"),
"Audio channels",
@@ -290,8 +435,13 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
RowId::Pad => (
RowId::PadForward => (
Some("Controller"),
"Forward controllers",
on_off(s.gamepad_forwarding).into(),
),
RowId::Pad => (
None,
"Use controller",
if s.forward_pad.is_empty() {
"Automatic".into()
@@ -331,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
),
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
};
RowSpec {
header,
@@ -368,6 +519,24 @@ fn detail(id: RowId) -> &'static str {
Needs an NVIDIA host (NVENC) or the PyroWave codec other encoders \
stream 4:2:0 and the session falls back silently."
}
RowId::PresentPriority => {
"Lowest latency shows each frame the moment the display can take it — a \
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
buffers a little to even those out."
}
RowId::SmoothBuffer => {
"Frames held back before showing. Each one absorbs about a refresh of network \
hiccup and adds a refresh of delay. Automatic holds two."
}
RowId::Vsync => {
"Tear-free. Off removes the wait for the screen's refresh — the lowest \
possible delay, at the cost of visible tearing. Not every driver offers it; \
the stats overlay names the mode actually in use."
}
RowId::AllowVrr => {
"On a VRR screen, let the panel refresh in step with the stream instead of on \
a fixed cadence. Applies to fullscreen sessions; harmless on a fixed screen."
}
RowId::Audio => "The speaker layout requested from the host.",
RowId::Mic => {
"Send this device's microphone to the host's virtual mic. \
@@ -377,6 +546,11 @@ fn detail(id: RowId) -> &'static str {
"Stops the host's audio, playing from this device's speakers, being picked up \
and sent back. Turn it off if your microphone already runs its own processing."
}
RowId::PadForward => {
"Send controllers connected to this device to the host. Turn it off when your \
controller already reaches the host another way USB passthrough such as \
VirtualHere, or a pad plugged into the host so games don't see two of them."
}
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
RowId::Touch => {
@@ -403,6 +577,16 @@ fn detail(id: RowId) -> &'static str {
reached over a VPN, where the wake wait only adds delay."
}
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
RowId::Profile(_) => {
"Pin this profile to a host and it appears as its own card — one press \
connects with these settings. Profiles are created and edited in the \
Punktfunk desktop app."
}
RowId::NoProfiles => {
"Profiles bundle stream settings for different uses (a low-latency one, a \
quality one). Create them in the Punktfunk desktop app, then pin them \
here as one-press connect cards."
}
}
}
@@ -463,6 +647,27 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Decoder => step_str(&DECODERS, &mut s.decoder, delta, wrap),
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::PresentPriority => {
let cur = PRESENT_PRIORITIES
.iter()
.position(|(v, _)| *v == s.present_priority);
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
}
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
RowId::SmoothBuffer => {
if s.present_priority == "smooth" {
let cur = SMOOTH_BUFFERS
.iter()
.position(|(v, _)| *v == s.smooth_buffer);
step_option(cur, SMOOTH_BUFFERS.len(), delta, wrap)
.map(|i| s.smooth_buffer = SMOOTH_BUFFERS[i].0)
} else {
None
}
}
RowId::Vsync => toggle(&mut s.vsync, delta, wrap),
RowId::AllowVrr => toggle(&mut s.allow_vrr, delta, wrap),
RowId::Audio => {
let cur = AUDIO.iter().position(|(v, _)| *v == s.audio_channels);
step_option(cur, AUDIO.len(), delta, wrap).map(|i| s.audio_channels = AUDIO[i].0)
@@ -476,7 +681,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
None
}
}
RowId::PadForward => toggle(&mut s.gamepad_forwarding, delta, wrap),
RowId::Pad => {
if !s.gamepad_forwarding {
return false;
}
// Automatic first, then every connected pad by stable key.
let keys: Vec<String> = std::iter::once(String::new())
.chain(ctx.pads.iter().map(|p| p.key.clone()))
@@ -484,7 +693,12 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
let cur = keys.iter().position(|c| *c == s.forward_pad);
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
}
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
RowId::PadType => {
if !s.gamepad_forwarding {
return false;
}
step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap)
}
RowId::Touch => {
let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode());
step_option(cur, TouchMode::ALL.len(), delta, wrap)
@@ -507,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
// Navigation rows, handled before the settings path in `menu` — never a value edit.
RowId::Profile(_) | RowId::NoProfiles => None,
}
.is_some()
}
@@ -632,7 +848,7 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::EchoCancel, &ctx).enabled);
assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
assert!(
!adjust(RowId::EchoCancel, -1, false, &mut ctx),
"mic off = thud"
@@ -641,13 +857,52 @@ mod tests {
assert!(ctx.settings.echo_cancel, "and nothing was written");
ctx.settings.mic_enabled = true;
assert!(row_spec(RowId::EchoCancel, &ctx).enabled);
assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx));
assert!(!ctx.settings.echo_cancel);
assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx));
assert!(ctx.settings.echo_cancel);
}
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
/// row list dims it, because a row vanishing mid-list would shift everything under the
/// cursor.
#[test]
fn smoothness_buffer_follows_the_intent() {
let (mut settings, pads) = ctx_parts();
assert_eq!(settings.present_priority, "latency", "the shipped default");
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
);
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
// Stepping the intent to Smoothness brings the buffer row to life.
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "smooth");
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
assert_eq!(ctx.settings.smooth_buffer, 1);
// The intent wraps back and the row goes inert again.
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
assert_eq!(ctx.settings.present_priority, "latency");
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
}
#[test]
fn touch_mode_steps_and_wraps() {
let (mut settings, pads) = ctx_parts();
@@ -721,4 +976,110 @@ mod tests {
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
}
/// The Profiles section trails the settings rows: one row per catalog profile whose
/// value counts the pinned cards in the live model, activating opens the pin screen,
/// and left/right (which edits every other row) is a boundary — a profile row
/// navigates, it must never fall into the settings save path.
#[test]
fn profile_rows_navigate_instead_of_editing() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut pinned = crate::model::HostRow {
key: "aa\0p1".into(),
name: "Tower".into(),
addr: "10.0.0.9".into(),
port: 9777,
fp_hex: "aa".into(),
paired: true,
saved: true,
online: true,
mgmt_port: 47990,
can_wake: false,
last_used: None,
os: String::new(),
pin: Some(crate::model::ProfileChip {
id: "p1".into(),
name: "Work".into(),
accent: None,
}),
bound_profile: None,
};
let hosts = [pinned.clone(), {
pinned.key = "aa".into();
pinned.pin = None;
pinned
}];
let mut ctx = Ctx {
hosts: &hosts,
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(vec![
("p1".into(), "Work".into()),
("p2".into(), "Game".into()),
]);
let ids = s.row_ids();
assert_eq!(ids.len(), ROWS.len() + 2);
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
assert_eq!(spec.header, Some("Profiles"));
assert_eq!(spec.label, "Work");
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
assert_eq!(spec.header, None, "only the first row carries the header");
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
s.list.cursor = ROWS.len(); // onto "Work"
let mut fx = Outbox::default();
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(
matches!(fx.nav, Some(crate::screens::Nav::Push(b))
if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")),
"A on a profile row opens its pin screen"
);
let mut fx = Outbox::default();
let pulse = s.menu(
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
&mut ctx,
&mut fx,
);
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
assert!(fx.nav.is_none() && fx.cmds.is_empty());
}
/// An empty catalog shows the explainer placeholder — present, inert, and dimmed —
/// so the section still tells the user where profiles come from.
#[test]
fn empty_catalog_shows_the_placeholder() {
let (mut settings, pads) = ctx_parts();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut s = SettingsScreen::with_profiles(Vec::new());
let ids = s.row_ids();
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
assert_eq!(spec.header, Some("Profiles"));
assert!(!spec.enabled);
s.list.cursor = ids.len() - 1;
let mut fx = Outbox::default();
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
assert!(fx.nav.is_none());
}
}
+8 -1
View File
@@ -239,8 +239,14 @@ impl Shell {
port: h.port,
fp_hex: h.fp_hex.clone(),
launch: None,
title: h.name.clone(),
// A wake started from a pinned card carries its profile
// through to the connect (the row's key found it again).
title: match &h.pin {
Some(p) => format!("{} · {}", h.name, p.name),
None => h.name.clone(),
},
request_access: false,
profile: h.pin.as_ref().map(|p| p.id.clone()),
})
});
self.bus.send(ConsoleCmd::CancelWake);
@@ -269,6 +275,7 @@ impl Shell {
launch: intent.launch,
title: intent.title,
request_access: intent.request_access,
profile: intent.profile,
});
}
+2
View File
@@ -32,6 +32,8 @@ fn hosts() -> Vec<HostRow> {
can_wake: false,
last_used: None,
os: String::new(),
pin: None,
bound_profile: None,
};
vec![
HostRow {
+3 -2
View File
@@ -88,8 +88,9 @@ pub enum ConsoleEntry {
/// The host list (bare `--browse`).
Home,
/// Home with this host's library already pushed (`--browse host` — the Decky
/// per-host launch; B backs out to Home).
Library(HostRow),
/// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless
/// `Home` variant when it learned its profile chips.
Library(Box<HostRow>),
}
/// The binary's ends of the console: models to write, commands to serve.
+143
View File
@@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option<bool> {
})
}
/// Where desktop audio should be audible — which decides the render endpoint the loopback captures.
///
/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`,
/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env`
/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an
/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when
/// the only way to express it is an undocumented environment variable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioOutputMode {
/// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not
/// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry
/// the mix without narrowing it — otherwise real hardware wins anyway.
#[default]
ClientOnly,
/// Prefer real hardware: audio plays on the host as well as the client. The old
/// `PUNKTFUNK_HOST_AUDIO=1`.
HostAndClient,
/// Touch nothing — capture whatever the operator's own default playback device is, and never
/// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`.
FollowDefault,
}
impl AudioOutputMode {
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default`
/// first (it is the more restrictive promise — "do not touch my devices" must not be overridden
/// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`).
fn from_env() -> AudioOutputMode {
if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") {
if !raw.trim().is_empty() {
if let Some(m) = AudioOutputMode::parse(&raw) {
return m;
}
// Never silently fall through to a different routing than the operator asked for.
eprintln!(
"punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \
client_only/host_and_client/follow_default using client_only"
);
}
}
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
return AudioOutputMode::FollowDefault;
}
if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() {
return AudioOutputMode::HostAndClient;
}
AudioOutputMode::ClientOnly
}
pub fn parse(s: &str) -> Option<AudioOutputMode> {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"client_only" | "client" => Some(AudioOutputMode::ClientOnly),
"host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient),
"follow_default" | "follow" => Some(AudioOutputMode::FollowDefault),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
AudioOutputMode::ClientOnly => "client_only",
AudioOutputMode::HostAndClient => "host_and_client",
AudioOutputMode::FollowDefault => "follow_default",
}
}
/// The loopback plan should prefer real hardware over a silent sink.
pub fn prefers_host_hardware(self) -> bool {
matches!(self, AudioOutputMode::HostAndClient)
}
/// Leave the operator's default playback/recording devices completely alone.
pub fn keeps_default(self) -> bool {
matches!(self, AudioOutputMode::FollowDefault)
}
}
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
@@ -99,6 +175,24 @@ pub struct HostConfig {
/// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM.
/// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables.
pub chacha20: bool,
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which
/// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`).
///
/// A first-class setting because the 2026-08-03 field report needed one: the default
/// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual
/// endpoint for 25 sessions, and the only way to change it was an undocumented environment
/// variable. See [`AudioOutputMode`].
pub audio_output_mode: AudioOutputMode,
/// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default
/// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and
/// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it
/// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than
/// silently downgrading someone's audio.
pub audio_quality: Option<String>,
/// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None`
/// (the default) = automatic: sent only to a client that asked for it, and only while the link
/// is actually losing packets.
pub audio_redundancy: Option<bool>,
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
pub perf: bool,
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a
@@ -246,6 +340,9 @@ impl HostConfig {
// Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real
// per-session switch; see the field doc).
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
audio_output_mode: AudioOutputMode::from_env(),
audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()),
audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"),
perf: flag("PUNKTFUNK_PERF"),
// Default ON while the interval-stutter field program runs (see the field doc).
stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true),
@@ -348,4 +445,50 @@ mod tests {
// An invalid rate stays invalid rather than being laundered into a real one.
assert_eq!(c.game_fps(0), 0);
}
#[test]
fn audio_output_mode_parses_its_spellings() {
for (s, want) in [
("client_only", AudioOutputMode::ClientOnly),
("client-only", AudioOutputMode::ClientOnly),
(" CLIENT ", AudioOutputMode::ClientOnly),
("host_and_client", AudioOutputMode::HostAndClient),
("both", AudioOutputMode::HostAndClient),
("follow_default", AudioOutputMode::FollowDefault),
("follow", AudioOutputMode::FollowDefault),
] {
assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}");
}
// Unknown spellings are rejected so the caller can say so, not silently re-routed.
for s in ["", "silent", "off", "true"] {
assert_eq!(AudioOutputMode::parse(s), None, "{s:?}");
}
// Round-trip through the canonical spelling.
for m in [
AudioOutputMode::ClientOnly,
AudioOutputMode::HostAndClient,
AudioOutputMode::FollowDefault,
] {
assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m));
}
}
/// The two predicates are what the wiring plan and the capture loop actually branch on, and
/// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different
/// promises, and conflating them would either silence the host or stomp the operator's devices.
#[test]
fn audio_output_mode_predicates_are_disjoint() {
assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly);
for m in [
AudioOutputMode::ClientOnly,
AudioOutputMode::HostAndClient,
AudioOutputMode::FollowDefault,
] {
assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}");
}
assert!(AudioOutputMode::HostAndClient.prefers_host_hardware());
assert!(AudioOutputMode::FollowDefault.keeps_default());
assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware());
assert!(!AudioOutputMode::ClientOnly.keeps_default());
}
}
@@ -669,6 +669,11 @@ impl GamepadManager {
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the uinput node would outlive the controller. The swept
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
// rumble internally); if that ever changes, consume it like the other two backends do.
self.slots.reap();
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high);
+96 -20
View File
@@ -62,15 +62,30 @@ impl<P> PadSlots<P> {
self.label
}
/// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the
/// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask
/// so the caller resets its per-index sibling state; an index another manager owns is `None`
/// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches
/// clear for a few frames and returns re-arms nothing.
/// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out
/// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its
/// per-index sibling state; an index another manager owns is `None` here, so it is never
/// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few
/// frames and returns re-arms nothing.
///
/// A frame can only ARM the grace, never complete it — no time has passed at the instant the
/// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the
/// manager's periodic pump is what actually finishes the unplug; a backend that only ever
/// called `sweep` would keep the detached pad alive for the rest of the session.
pub fn sweep(&mut self, active_mask: u16) -> u16 {
self.sweep_at(active_mask, Instant::now())
}
/// Drop every allocated pad whose grace has run out, logging each — the half of the unplug
/// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`].
///
/// This can only ever *complete* an unplug some frame already started: it never arms a clock,
/// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear.
/// That is what makes it safe to call from a hot pump loop.
pub fn reap(&mut self) -> u16 {
self.reap_at(Instant::now())
}
/// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads
/// whose bits are still clear — consumer tests (the managers') drive the debounce without
/// wall-clock sleeps. Test-only: production code has no business expiring the grace.
@@ -81,26 +96,37 @@ impl<P> PadSlots<P> {
}
}
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window).
/// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm
/// each slot's clock from the mask, then reap whatever has already run out.
fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 {
let mut swept = 0u16;
for (i, slot) in self.pads.iter_mut().enumerate() {
for i in 0..MAX_PADS {
if active_mask & (1 << i) != 0 {
self.inactive_since[i] = None; // active (again): a glitch never reaches the drop
} else if self.pads[i].is_some() && self.inactive_since[i].is_none() {
self.inactive_since[i] = Some(now); // newly inactive — start the grace
}
}
self.reap_at(now)
}
/// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads
/// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out
/// and cannot be dropped here.
fn reap_at(&mut self, now: Instant) -> u16 {
let mut swept = 0u16;
for i in 0..MAX_PADS {
let Some(since) = self.inactive_since[i] else {
continue; // active, or never went clear — nothing to complete
};
if self.pads[i].is_none() {
self.inactive_since[i] = None; // the slot went away by some other route
continue;
}
if slot.is_none() {
continue;
}
match self.inactive_since[i] {
None => self.inactive_since[i] = Some(now), // newly inactive — start the grace
Some(since) if now.duration_since(since) >= SWEEP_GRACE => {
tracing::info!(index = i, "controller unplugged ({})", self.label);
*slot = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
Some(_) => {} // inside the grace — hold
if now.duration_since(since) >= SWEEP_GRACE {
tracing::info!(index = i, "controller unplugged ({})", self.label);
self.pads[i] = None;
self.inactive_since[i] = None;
swept |= 1 << i;
}
}
swept
@@ -161,6 +187,56 @@ mod tests {
PadSlots::new("Test", "test pad", "")
}
#[test]
fn a_single_frame_plus_a_reap_completes_the_unplug() {
// The shape production actually produces: ONE cleared-mask frame, then time, then a reap
// with no further frame. Before the arm/reap split the pad survived here forever.
let mut s = slots();
assert!(s.ensure(2, |i| Ok(i as u32)));
assert_eq!(
s.sweep(0b0),
0,
"a frame arms the grace but cannot itself drop"
);
assert!(s.get(2).is_some());
s.expire_grace();
assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug");
assert!(s.get(2).is_none());
assert_eq!(s.reap(), 0, "nothing left to reap");
}
#[test]
fn reap_never_drops_a_pad_no_frame_ever_deactivated() {
// Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear
// has no armed clock, so any number of reaps — even with the clock backdated — leaves it.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
for _ in 0..10 {
assert_eq!(s.reap(), 0);
s.expire_grace();
}
assert!(
s.get(0).is_some(),
"reap dropped a pad that never went inactive"
);
}
#[test]
fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() {
// The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and
// comes back must not churn a PnP devnode.
let mut s = slots();
assert!(s.ensure(0, |i| Ok(i as u32)));
assert_eq!(s.sweep(0b0), 0); // bit clears — arms only
for _ in 0..5 {
assert_eq!(s.reap(), 0, "dropped a pad inside its grace");
}
assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms
s.expire_grace();
assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed");
assert!(s.get(0).is_some());
}
#[test]
fn ensure_creates_once_and_reports_freshness() {
let mut s = slots();
+52 -14
View File
@@ -217,13 +217,10 @@ impl<B: PadProto> UhidManager<B> {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return; // this event WAS the unplug
}
@@ -282,6 +279,12 @@ impl<B: PadProto> UhidManager<B> {
mut hidout: impl FnMut(HidOutput),
) {
let now = Instant::now();
// Finish any unplug whose removal frame only armed the grace. The producer emits that
// frame exactly once, so without this a detached pad — the single-pad session being the
// common case — would never be destroyed. Runs BEFORE the loop so a reaped index is
// already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick.
let swept = self.slots.reap();
self.reset_swept(swept);
for i in 0..MAX_PADS {
let Some(pad) = self.slots.get_mut(i) else {
continue;
@@ -360,6 +363,18 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the
/// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on
/// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and
/// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.reset_pad(i);
}
}
}
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
@@ -494,18 +509,36 @@ mod tests {
}
#[test]
fn removal_frame_never_recreates_the_pad_it_swept() {
fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() {
// The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on
// the bit still being set — so the teardown has to finish on the periodic pump. The
// previous version of this test hand-fed a SECOND removal frame, which is what let the
// never-reaped pad hide: with one frame and no pump, the device outlived the session.
let mut m = mgr();
m.handle(&frame(1, 0b10, 0));
assert!(m.slots.get(1).is_some());
// Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a
// mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE).
// The one removal frame: arms the devnode-churn grace, drops nothing.
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
// Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure).
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_some(),
"a tick inside the grace dropped it"
);
// Grace elapsed: the next tick completes the unplug, with no further frame.
m.slots.expire_grace();
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_none(),
"the pump tick never completed the unplug"
);
// …and a further cleared-mask frame must not resurrect it (the arm branch early-returns).
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_none());
assert!(
m.slots.get(1).is_none(),
"a cleared-mask frame recreated the pad"
);
}
#[test]
@@ -551,10 +584,15 @@ mod tests {
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again.
m.handle(&frame(0, 0b0, 0)); // arms the sweep grace
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
m.slots.expire_grace();
m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
assert!(
m.slots.get(0).is_none(),
"the pump tick completed the unplug"
);
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
@@ -318,14 +318,10 @@ impl GamepadManager {
if idx >= MAX_PADS {
return;
}
// Unplugs: drop any allocated pad whose mask bit cleared.
// Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands
// on a later `pump_rumble` tick — this frame is the only one the producer sends).
let swept = self.slots.sweep(f.active_mask);
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
self.reset_swept(swept);
if f.active_mask & (1 << idx) == 0 {
return;
}
@@ -345,10 +341,25 @@ impl GamepadManager {
}
}
/// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the
/// unplug clear the same things.
fn reset_swept(&mut self, swept: u16) {
for i in 0..MAX_PADS {
if swept & (1 << i) != 0 {
self.last_rumble[i] = (0, 0);
self.last_active[i] = Instant::now();
}
}
}
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the XUSB devnode would outlive the controller.
let swept = self.slots.reap();
self.reset_swept(swept);
for (i, pad) in self.slots.iter_mut() {
if let Some((large, small)) = pad.service() {
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
+2
View File
@@ -52,6 +52,8 @@ pub mod keymap_sdl;
#[cfg(any(target_os = "linux", windows))]
pub mod overlay;
#[cfg(any(target_os = "linux", windows))]
mod present_pace;
#[cfg(any(target_os = "linux", windows))]
mod run;
#[cfg(any(target_os = "linux", windows))]
pub mod touch;
+5
View File
@@ -84,6 +84,11 @@ pub enum OverlayAction {
fp_hex: String,
launch: Option<String>,
title: String,
/// One-off settings-profile override for THIS launch (a profile id — a pinned
/// card's connect). `None` resolves the host's default binding as before; the
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
/// falls back to the defaults and never blocks the connect.
profile: Option<String>,
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
/// open a connect the host PARKS until the operator approves this device in its
/// console (a long connect budget), then persist it as paired. `false` = an
+751
View File
@@ -0,0 +1,751 @@
//! The presentation intent engine (design/desktop-presentation-rebuild.md WP2): the
//! store, clock, and gate the run loop composes into the two intents.
//!
//! * [`FrameStore`] — newest-wins slot (latency) or smoothing FIFO with preroll
//! (smoothness), ported from the Apple `FrameStore` / Android `presenter.rs` so all
//! three clients agree on what the intents mean.
//! * [`LatchClock`] — the panel latch grid, learned from `VK_KHR_present_wait` on-glass
//! stamps (measured, never queried — the Android refresh-rate lie and VRR both punish
//! trusting a reported rate). Without present-wait it degrades to a grid rooted at the
//! last submit on the mode's refresh period.
//! * [`PresentGate`] — the FIFO glass budget: one undisplayed present in flight, so the
//! swapchain's own queue can never become a standing queue (+1 refresh per slot,
//! forever — the law every bounded-FIFO pacing rediscovered on Apple). MAILBOX cannot
//! queue and never needs it.
//!
//! Everything here is pure state + arithmetic on `CLOCK_REALTIME` ns (the
//! `pf_client_core::session::now_ns` domain the on-glass stamps live in); the run loop
//! owns all clocks and Vulkan calls, which is what keeps this testable.
use std::collections::VecDeque;
/// Stale-present force-open: an undisplayed present older than this is presumed lost
/// (occluded window, wedged compositor) and the gate opens anyway, counted as `forced`
/// — reads 0 on healthy systems. The Apple/Android presenters use the same 100 ms.
const STALE_REOPEN_NS: u64 = 100_000_000;
/// The adaptive slot-pick margin's ceiling and step (Android's measured values: start
/// at 0 — a fixed lead was pure display tax on the reference device — and widen only
/// when measured misses demand it).
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
/// The decoded-frame store between the wake channel and the present call.
///
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
/// `capacity 1..=3` = smoothing FIFO: preroll-to-capacity, drop-oldest on overflow,
/// an underflow after preroll re-arms the preroll (the previous frame persists on
/// glass — a repeat by omission) while headroom rebuilds.
pub(crate) struct FrameStore<T> {
capacity: usize,
frames: VecDeque<T>,
prerolled: bool,
/// Newest-wins displacements (normal operation under latency, not a fault signal).
replaced: u32,
/// FIFO drop-oldest evictions — the Apple debug line's `qDrop`.
overflow_drops: u32,
/// FIFO dry-after-preroll events — `qDry`.
underflows: u32,
}
impl<T> FrameStore<T> {
pub(crate) fn new(capacity: usize) -> FrameStore<T> {
FrameStore {
capacity,
frames: VecDeque::with_capacity(capacity.max(1) + 1),
prerolled: false,
replaced: 0,
overflow_drops: 0,
underflows: 0,
}
}
pub(crate) fn is_smoothing(&self) -> bool {
self.capacity > 0
}
pub(crate) fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub(crate) fn submit(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.pop_front().is_some() {
self.replaced += 1;
}
self.frames.push_back(f);
} else {
self.frames.push_back(f);
// Drop the OLDEST past capacity: bounded added latency, the newest keeps
// flowing. Also trims a transient capacity+1 a put_back left behind.
while self.frames.len() > self.capacity {
self.frames.pop_front();
self.overflow_drops += 1;
}
}
}
pub(crate) fn take(&mut self) -> Option<T> {
if self.capacity == 0 {
return self.frames.pop_front();
}
if !self.prerolled {
// Preroll gate: without it a steady stream drains every frame on arrival
// and jitter headroom never builds (the Apple store's lesson).
if self.frames.len() < self.capacity {
return None;
}
self.prerolled = true;
}
match self.frames.pop_front() {
Some(f) => Some(f),
None => {
self.underflows += 1;
self.prerolled = false;
None
}
}
}
/// A frame taken but not presented (gate closed, present failed before consuming
/// it). Newest-wins reinserts only into an empty slot — a fresher decode wins;
/// FIFO puts it back at the front (it is the oldest).
pub(crate) fn put_back(&mut self, f: T) {
if self.capacity == 0 {
if self.frames.is_empty() {
self.frames.push_back(f);
}
} else {
self.frames.push_front(f);
}
}
/// Collapse to newest-wins for the rest of the stream (PyroWave: its plane-ring
/// retirement accounting assumes the depth-2 newest-wins hand-off, and its all-intra
/// frames make buffering pointless anyway).
///
/// Gated with its only caller: the power-user build (`--no-default-features`, which
/// the Windows ARM64 leg ships) has no PyroWave decode path, and an ungated helper
/// is dead code there.
#[cfg(feature = "pyrowave")]
pub(crate) fn force_latency(&mut self) {
if self.capacity == 0 {
return;
}
self.capacity = 0;
self.prerolled = false;
while self.frames.len() > 1 {
self.frames.pop_front();
}
}
/// Drain the window's counters: `(replaced, overflow_drops, underflows)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32, u32) {
let c = (self.replaced, self.overflow_drops, self.underflows);
self.replaced = 0;
self.overflow_drops = 0;
self.underflows = 0;
c
}
}
/// The panel latch grid: a recent on-glass instant + the latch period, extrapolated
/// forward for slot targeting.
///
/// The period learner is the SHARED [`punktfunk_core::phase::PanelGrid`], not a local
/// rule. An earlier version of this clock capped the learned period at the display
/// mode's refresh, on the reasoning that a stream running below panel rate spaces its
/// presents at k×period and the cap stops a 30 fps stream claiming a 30 Hz panel. That
/// cap is the same defect the Android presenter shipped in 0.23.0: the seed is only what
/// the *mode* claims, and when the real panel is slower (a refused mode switch, a
/// compositor running its own rate) a downward-only learner pins a grid that never
/// arrives, for the whole session, with no way back. `PanelGrid` moves both ways —
/// narrowing at once, widening only after eight consecutive agreeing observations and
/// then to the narrowest of them.
///
/// What is fed to it is still the window's MIN spacing: within one window that resists
/// the k×period inflation the old cap was aimed at, while the streak requirement means a
/// genuinely slower panel is still discovered. Same grid the host-facing `LatchGrid`
/// publish reads, so the phase-lock report and the local scheduler cannot disagree.
pub(crate) struct LatchClock {
anchor_ns: u64,
/// The previous stamp, kept ACROSS calls. The run loop drains present-wait samples
/// every pass, so a "batch" is very often a single stamp — computing spacings only
/// within a batch (`windows(2)`) observed nothing at all on glass, and the learner
/// silently ran on its seed forever.
last_ns: u64,
/// Narrowest spacing seen since the last handoff to the grid, and how many have
/// accumulated. The grid is fed the MIN of a run rather than every spacing: our
/// observations are the spacing of OUR presents, which is k×period whenever the
/// stream runs below panel rate, and the min over a run is the best available
/// estimate of the true grid step.
pending_min_ns: u64,
pending_count: u32,
grid: punktfunk_core::phase::PanelGrid,
fallback_period_ns: u64,
}
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
/// mode change is picked up in well under a second at any sane frame rate.
const GRID_OBSERVE_EVERY: u32 = 16;
impl LatchClock {
pub(crate) fn new(refresh_hz: u32) -> LatchClock {
LatchClock {
anchor_ns: 0,
last_ns: 0,
pending_min_ns: 0,
pending_count: 0,
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
}
}
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
/// the learner.
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
for &s in stamps {
if self.last_ns != 0 && s > self.last_ns {
let d = s - self.last_ns;
// < 1 ms apart = a queued pair, not a grid step.
if d > 1_000_000 {
self.pending_min_ns = if self.pending_min_ns == 0 {
d
} else {
self.pending_min_ns.min(d)
};
self.pending_count += 1;
if self.pending_count >= GRID_OBSERVE_EVERY {
self.grid.observe(self.pending_min_ns as i64);
self.pending_min_ns = 0;
self.pending_count = 0;
}
}
}
self.last_ns = s;
}
if let Some(&last) = stamps.last() {
self.anchor_ns = last;
}
}
pub(crate) fn period_ns(&self) -> u64 {
let learned = self.grid.period_ns();
if learned > 0 {
learned as u64
} else {
self.fallback_period_ns
}
}
pub(crate) fn anchor_ns(&self) -> u64 {
self.anchor_ns
}
/// The first predicted latch strictly after `after_ns` (`anchor + k·period`). With
/// no anchor yet: one period out — callers get a usable, if unanchored, deadline.
pub(crate) fn next_slot_after(&self, after_ns: u64) -> u64 {
let p = self.period_ns();
if self.anchor_ns == 0 || after_ns < self.anchor_ns {
return after_ns.saturating_add(p);
}
let k = (after_ns - self.anchor_ns) / p + 1;
self.anchor_ns + k * p
}
}
/// Whether the panel is refreshing on a fixed grid or following our cadence.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum Cadence {
/// Not enough evidence yet — say nothing rather than guess.
#[default]
Unknown,
/// On-glass instants land on multiples of the panel period: a fixed-refresh panel.
Fixed,
/// On-glass instants track our present spacing instead: variable refresh is live.
Variable,
}
impl Cadence {
pub(crate) fn label(self) -> &'static str {
match self {
Cadence::Unknown => "",
Cadence::Fixed => "no",
Cadence::Variable => "yes",
}
}
}
/// Is variable refresh actually live? **Measured, never queried** — no portable query
/// exists (SDL exposes none, Wayland does not report adaptive-sync state, and Windows
/// surfaces nothing through Vulkan), and the platforms that *do* answer have been caught
/// lying before (Android reports a game-uid's down-rated refresh as the panel's).
///
/// The discriminator is quantization. On a fixed-refresh panel every on-glass instant
/// lands on the vblank grid, so the spacing between consecutive presents is always
/// ~k×period for whole k — even when the stream runs slower than the panel, where it just
/// picks a larger k. Under real VRR the panel refreshes *when we present*, so the spacing
/// follows our own cadence and sits wherever it likes relative to the grid.
///
/// So: fold each delta to its distance from the nearest multiple of the period. Tight
/// against the grid ⇒ Fixed; consistently off it ⇒ Variable. A stream running exactly at
/// panel rate is indistinguishable either way (both give delta ≈ period), which is
/// harmless — at that rate VRR has nothing to do.
pub(crate) struct CadenceProbe {
/// Off-grid distances as a fraction of the period, in thousandths.
off_grid_milli: Vec<u32>,
/// Previous stamp, kept across calls for the same reason [`LatchClock`] does: the
/// live drain hands over one sample at a time.
last_ns: u64,
/// The last round's raw reading and how many rounds have agreed — a verdict is only
/// published once [`CADENCE_STABLE_ROUNDS`] agree.
candidate: Cadence,
agree_rounds: u8,
verdict: Cadence,
}
/// Enough deltas to distinguish jitter from a real off-grid cadence.
const CADENCE_MIN_SAMPLES: usize = 24;
/// Consecutive agreeing rounds before a verdict is published.
///
/// ⭐ On glass (GNOME/Wayland, .21, 2026-08-02) the raw per-round verdict FLAPPED between
/// runs with VRR provably disabled. The cause is structural, not a tuning miss: under a
/// compositor our on-glass stamp is the compositor's release, so anything that perturbs
/// delivery — an occluded or unfocused surface being throttled, a distressed pipeline
/// missing vblanks — smears the spacings exactly the way real VRR does. This probe can
/// therefore only ever say "presents are not landing on the grid", so it demands
/// agreement across rounds and refuses evidence from a distressed window (see
/// [`CadenceProbe::note`]'s `healthy` flag) before claiming anything.
const CADENCE_STABLE_ROUNDS: u8 = 2;
/// Median off-grid distance under this fraction of a period reads as grid-locked. Present
/// stamps carry real measurement jitter (the wait returns, then we read the clock), so
/// this is deliberately loose — the two regimes differ by far more than this in practice.
const CADENCE_FIXED_MILLI: u32 = 150;
impl CadenceProbe {
pub(crate) fn new() -> CadenceProbe {
CadenceProbe {
off_grid_milli: Vec::with_capacity(64),
last_ns: 0,
candidate: Cadence::Unknown,
agree_rounds: 0,
verdict: Cadence::Unknown,
}
}
/// Fold on-glass stamps against the learned panel period. Spacings are measured
/// against the previous stamp whatever the batching.
///
/// `healthy` is the caller's statement that this window's presents were flowing
/// normally (no stale force-opens). A distressed pipeline smears spacings for reasons
/// that have nothing to do with the panel, so its evidence is dropped — the timeline
/// continuity is still advanced, it simply does not count as a sample.
pub(crate) fn note(&mut self, stamps: &[u64], period_ns: u64, healthy: bool) {
if period_ns == 0 || !healthy {
self.last_ns = stamps.last().copied().unwrap_or(self.last_ns);
return;
}
for &s in stamps {
let prev = std::mem::replace(&mut self.last_ns, s);
if prev == 0 || s <= prev {
continue;
}
let delta = s - prev;
let rem = delta % period_ns;
// Distance to the NEAREST multiple, so a delta just under k×period reads as
// close to the grid rather than a whole period away from k-1.
let off = rem.min(period_ns - rem);
self.off_grid_milli
.push((off.saturating_mul(1000) / period_ns) as u32);
// A round closes on the SAMPLE count, inside the loop — not once per call.
// Evaluating per call would make the verdict depend on how the caller happens
// to batch its stamps (one big batch = one round, forever short of the
// agreement requirement), and the live drain and the tests batch differently.
self.close_round_if_ready();
}
}
/// Publish a verdict once a round's worth of spacings agree with the previous round.
fn close_round_if_ready(&mut self) {
if self.off_grid_milli.len() >= CADENCE_MIN_SAMPLES {
self.off_grid_milli.sort_unstable();
let median = self.off_grid_milli[self.off_grid_milli.len() / 2];
let round = if median <= CADENCE_FIXED_MILLI {
Cadence::Fixed
} else {
Cadence::Variable
};
if round == self.candidate {
self.agree_rounds = self.agree_rounds.saturating_add(1);
} else {
self.candidate = round;
self.agree_rounds = 1;
}
if self.agree_rounds >= CADENCE_STABLE_ROUNDS {
self.verdict = round;
}
self.off_grid_milli.clear();
}
}
pub(crate) fn verdict(&self) -> Cadence {
self.verdict
}
/// A mode switch / display change invalidates the evidence.
pub(crate) fn reset(&mut self) {
self.off_grid_milli.clear();
self.last_ns = 0;
self.candidate = Cadence::Unknown;
self.agree_rounds = 0;
self.verdict = Cadence::Unknown;
}
}
/// The FIFO glass budget: at most one undisplayed present in flight, measured by the
/// present-wait waiter's outstanding count. Never consulted under MAILBOX/IMMEDIATE
/// (they cannot queue) or without present-wait (nothing to count with — behavior is
/// then exactly the shipped arrival pacing).
#[derive(Default)]
pub(crate) struct PresentGate {
/// Submit stamp of the newest tracked present; 0 = none yet.
last_present_ns: u64,
gated: u32,
forced: u32,
}
impl PresentGate {
/// May a new present go out? Open when nothing undisplayed is in flight; a stale
/// in-flight present (occlusion, wedged compositor) force-opens after 100 ms so the
/// stream survives, counted as `forced`.
pub(crate) fn open(&mut self, outstanding: usize, now_ns: u64) -> bool {
if outstanding == 0 {
return true;
}
if self.last_present_ns != 0
&& now_ns.saturating_sub(self.last_present_ns) > STALE_REOPEN_NS
{
self.forced += 1;
return true;
}
self.gated += 1;
false
}
pub(crate) fn note_present(&mut self, now_ns: u64) {
self.last_present_ns = now_ns;
}
/// Drain the window's counters: `(gated, forced)`.
pub(crate) fn take_counters(&mut self) -> (u32, u32) {
let c = (self.gated, self.forced);
self.gated = 0;
self.forced = 0;
c
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Newest-wins: submit replaces, take clears, put_back only fills an empty slot.
#[test]
fn newest_wins_replaces_and_putback_never_clobbers() {
let mut s: FrameStore<u32> = FrameStore::new(0);
assert!(!s.is_smoothing());
assert_eq!(s.take(), None);
s.submit(1);
s.submit(2);
s.submit(3);
assert_eq!(s.take(), Some(3), "only the newest survives");
assert_eq!(s.take(), None);
// A taken-but-unpresented frame returns — unless a fresher one arrived.
s.submit(4);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(4));
let f = s.take();
assert_eq!(f, None);
s.submit(5);
let f = s.take().unwrap();
s.submit(6);
s.put_back(f); // 6 arrived while 5 was out — 6 wins
assert_eq!(s.take(), Some(6));
assert_eq!(
s.take_counters(),
(2, 0, 0),
"two displacements, no fifo counters"
);
}
/// FIFO: preroll to capacity, drop-oldest overflow, underflow re-arms the preroll.
#[test]
fn fifo_prerolls_overflows_oldest_and_rearms_on_dry() {
let mut s: FrameStore<u32> = FrameStore::new(2);
assert!(s.is_smoothing());
s.submit(1);
assert_eq!(s.take(), None, "prerolling: below capacity, nothing vends");
s.submit(2);
assert_eq!(s.take(), Some(1), "preroll reached — FIFO order");
assert_eq!(
s.take(),
Some(2),
"once prerolled the buffer drains normally"
);
// Dry after preroll = one underflow, preroll re-arms.
assert_eq!(s.take(), None);
s.submit(3);
assert_eq!(s.take(), None, "re-armed preroll holds again");
s.submit(4);
assert_eq!(s.take(), Some(3));
// Overflow drops the OLDEST: [4] → [4,5] → 6 evicts 4 → 7 evicts 5.
s.submit(5);
s.submit(6);
s.submit(7);
assert_eq!(s.take(), Some(6));
assert_eq!(s.take(), Some(7));
let (replaced, drops, dry) = s.take_counters();
assert_eq!(replaced, 0);
assert_eq!(drops, 2, "6 evicted 4, 7 evicted 5");
assert_eq!(dry, 1);
}
/// put_back under FIFO goes to the FRONT (it is the oldest), and the transient
/// capacity+1 is trimmed by the next submit.
#[test]
fn fifo_putback_restores_order() {
let mut s: FrameStore<u32> = FrameStore::new(2);
s.submit(1);
s.submit(2);
let f = s.take().unwrap();
s.put_back(f);
assert_eq!(s.take(), Some(1), "the put-back frame is still first");
}
/// force_latency collapses a smoothing store to a newest-wins slot mid-stream.
#[cfg(feature = "pyrowave")]
#[test]
fn force_latency_collapses_to_one_slot() {
let mut s: FrameStore<u32> = FrameStore::new(3);
s.submit(1);
s.submit(2);
s.submit(3);
s.force_latency();
assert!(!s.is_smoothing());
assert_eq!(s.take(), Some(3), "only the newest survives the collapse");
s.submit(4);
s.submit(5);
assert_eq!(s.take(), Some(5));
}
/// The clock learns the min positive spacing (capped at the mode refresh), anchors
/// on the newest stamp, and extrapolates the next slot; sub-ms pairs (a queued
/// double-present) never become the period.
#[test]
fn latch_clock_learns_and_extrapolates() {
const P: u64 = 16_666_666; // 60 Hz
let mut c = LatchClock::new(60);
assert_eq!(c.period_ns(), P, "fallback = the mode refresh");
// No anchor: a usable deadline one period out.
assert_eq!(c.next_slot_after(1_000), 1_000 + P);
c.note_batch(&[1_000_000_000, 1_000_000_000 + P, 1_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 1_000_000_000 + 2 * P);
let next = c.next_slot_after(c.anchor_ns());
assert_eq!(next, 1_000_000_000 + 3 * P);
// Mid-slot query lands on the same boundary; a later one steps whole periods.
assert_eq!(c.next_slot_after(next - 1), next);
assert_eq!(c.next_slot_after(next), next + P);
// A queued pair (< 1 ms apart) must not poison the period.
c.note_batch(&[2_000_000_000, 2_000_000_500]);
assert_eq!(c.period_ns(), P);
assert_eq!(c.anchor_ns(), 2_000_000_500, "the anchor still advances");
// A stream presenting every OTHER refresh spaces its glass stamps at 2×P. One
// such window must NOT move the grid — the shared learner needs a streak before
// it will widen, which is what keeps a briefly-slow stream from claiming a slow
// panel while still allowing a genuinely slower display to be discovered.
c.note_batch(&[3_000_000_000, 3_000_000_000 + 2 * P]);
assert_eq!(c.period_ns(), P, "one wide window is not a slower panel");
// A single stamp re-anchors without touching the period.
c.note_batch(&[5_000_000_000]);
assert_eq!(c.anchor_ns(), 5_000_000_000);
assert_eq!(c.period_ns(), P);
// A faster panel learns its own finer grid.
let mut fast = LatchClock::new(120);
fast.note_batch(&[1_000_000_000, 1_008_333_333]);
assert_eq!(fast.period_ns(), 8_333_333);
}
/// ⭐ The live loop drains present-wait samples EVERY pass, so stamps arrive one at a
/// time. Measuring spacings only within a batch meant the learner observed nothing on
/// glass and silently ran on its seed (found on .21, 2026-08-02: `period_us` read back
/// exactly the 60 Hz fallback while the panel really was 60 Hz — correct by luck, and
/// wrong the moment the mode lies).
#[test]
fn latch_clock_learns_from_one_sample_at_a_time() {
const REAL: u64 = 16_666_666;
let mut c = LatchClock::new(120); // seeded too fast, as a refused mode switch would
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + 8) {
t += REAL;
c.note_batch(&[t]); // ONE stamp per call — the live shape
}
assert_eq!(
c.period_ns(),
REAL,
"single-stamp batches must still feed the grid learner"
);
assert_eq!(c.anchor_ns(), t);
}
/// The mode's refresh is a CLAIM, not a measurement — a refused mode switch or a
/// compositor running its own rate leaves the seed too fast. The old downward-only
/// cap pinned that wrong grid for the session (the Android 0.23.0 defect); the
/// shared learner climbs back out once the evidence is consistent.
#[test]
fn latch_clock_recovers_from_a_seed_faster_than_the_real_panel() {
const REAL: u64 = 16_666_666; // the panel is really 60 Hz…
let mut c = LatchClock::new(120); // …but the mode claimed 120
assert_eq!(c.period_ns(), 8_333_333, "seeded from the claim");
// Consistent 60 Hz evidence. The grid is fed the MIN of every
// GRID_OBSERVE_EVERY spacings, and PanelGrid widens only after 8 agreeing
// observations, so a real widen needs 8 × GRID_OBSERVE_EVERY spacings — the
// deliberate cost of not letting one slow patch redefine the panel.
let mut t = 1_000_000_000u64;
for _ in 0..(GRID_OBSERVE_EVERY * 8 + GRID_OBSERVE_EVERY) {
t += REAL;
c.note_batch(&[t]);
}
assert_eq!(
c.period_ns(),
REAL,
"a sustained slower grid is adopted instead of aimed past forever"
);
}
/// The VRR discriminator: presents landing on the vblank grid read Fixed, presents
/// landing wherever our own cadence puts them read Variable — including the case that
/// matters most, a stream SLOWER than the panel, where a fixed panel still quantizes
/// to a larger whole multiple.
#[test]
fn cadence_probe_separates_grid_locked_from_variable() {
const P: u64 = 8_333_333; // 120 Hz
// Enough spacings for CADENCE_STABLE_ROUNDS full rounds: a verdict is published
// only once consecutive rounds agree (on glass a single round FLAPPED).
const ROUNDS: u64 = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
// Fixed panel, stream at panel rate: every delta is exactly one period.
let mut probe = CadenceProbe::new();
assert_eq!(probe.verdict(), Cadence::Unknown, "no evidence yet");
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * P).collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed);
// Fixed panel, stream at HALF panel rate: deltas are 2×P — still grid-locked.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS).map(|i| 1_000_000_000 + i * 2 * P).collect();
probe.note(&stamps, P, true);
assert_eq!(
probe.verdict(),
Cadence::Fixed,
"a slower stream on a fixed panel picks a larger k, it does not leave the grid"
);
// Fixed panel with realistic measurement jitter (±0.5 ms on an 8.3 ms period)
// must not read as variable.
let mut probe = CadenceProbe::new();
let jitter = [0i64, 300_000, -250_000, 120_000, -400_000, 80_000];
let stamps: Vec<u64> = (0..ROUNDS as usize)
.map(|i| (1_000_000_000 + i as i64 * P as i64 + jitter[i % jitter.len()]) as u64)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Fixed, "jitter is not VRR");
// VRR live: a 100 fps stream on a 120 Hz-max panel. 10 ms is not a multiple of
// 8.33 ms, so every present sits off the grid.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, P, true);
assert_eq!(probe.verdict(), Cadence::Variable);
// A display change throws the evidence away rather than carrying a stale verdict.
probe.reset();
assert_eq!(probe.verdict(), Cadence::Unknown);
// Below the sample floor nothing is claimed.
let mut probe = CadenceProbe::new();
probe.note(&[1_000_000_000, 1_010_000_000, 1_020_000_000], P, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
// ⭐ THE SHAPE THE LIVE LOOP ACTUALLY PRODUCES: the run loop drains present-wait
// samples every pass, so stamps arrive ONE AT A TIME. Measuring spacings only
// within a batch observed nothing at all on glass — `vrr` stayed Unknown and the
// latch clock ran on its seed forever. Found on .21, 2026-08-02.
let mut probe = CadenceProbe::new();
for i in 0..ROUNDS {
probe.note(&[1_000_000_000 + i * 10_000_000], P, true); // 100 fps, off a 120 Hz grid
}
assert_eq!(
probe.verdict(),
Cadence::Variable,
"one-sample batches must still yield spacings"
);
// A period we never learned can't discriminate anything.
let mut probe = CadenceProbe::new();
let stamps: Vec<u64> = (0..ROUNDS)
.map(|i| 1_000_000_000 + i * 10_000_000)
.collect();
probe.note(&stamps, 0, true);
assert_eq!(probe.verdict(), Cadence::Unknown);
}
/// ⭐ Batching must not change the verdict. The same spacings delivered as one big
/// batch, or one stamp at a time, must reach the same conclusion — the live loop
/// drains one at a time while tests hand over vectors, and an evaluation keyed to
/// call boundaries silently made the two disagree.
#[test]
fn cadence_verdict_is_independent_of_batching() {
const P: u64 = 8_333_333;
let n = (CADENCE_MIN_SAMPLES as u64) * (CADENCE_STABLE_ROUNDS as u64) + 4;
let stamps: Vec<u64> = (0..n).map(|i| 1_000_000_000 + i * P).collect();
let mut bulk = CadenceProbe::new();
bulk.note(&stamps, P, true);
let mut drip = CadenceProbe::new();
for s in &stamps {
drip.note(&[*s], P, true);
}
assert_eq!(bulk.verdict(), Cadence::Fixed);
assert_eq!(drip.verdict(), bulk.verdict(), "batching must not matter");
}
/// Gate: open at zero outstanding, closed at one, force-open past the stale bound.
#[test]
fn gate_budgets_one_undisplayed_present() {
let mut g = PresentGate::default();
let t0 = 1_000_000_000u64;
assert!(g.open(0, t0));
g.note_present(t0);
assert!(!g.open(1, t0 + 8_000_000), "one in flight — hold");
assert!(
g.open(1, t0 + STALE_REOPEN_NS + 1),
"stale in-flight present force-opens"
);
let (gated, forced) = g.take_counters();
assert_eq!((gated, forced), (1, 1));
assert_eq!(g.take_counters(), (0, 0), "counters drain");
}
}
+492 -45
View File
@@ -18,12 +18,15 @@
use crate::input::{Capture, FingerPhase};
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
use crate::present_pace::{
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
};
use crate::touch::Abs;
use crate::vk::{FrameInput, Presenter};
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode};
use pf_client_core::trust::{MouseMode, PresentPriority, StatsVerbosity, TouchMode};
use pf_client_core::video::VulkanDecodeDevice;
use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient;
@@ -63,6 +66,20 @@ pub struct SessionOpts {
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
pub inhibit_shortcuts: bool,
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
/// `Smooth { buffer }` runs the smoothing FIFO drained one frame per latch slot
/// (design/desktop-presentation-rebuild.md). `PUNKTFUNK_PRESENTER=arrival` overrides
/// the whole engine back to the legacy drain for field A/B without a rebuild.
pub present_priority: PresentPriority,
/// Tear-free presentation ([`Settings::vsync`], default on). Off asks for a tearing
/// present mode for the lowest possible latch — best-effort, and the mode that
/// actually took is named in the stats line.
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence ([`Settings::allow_vrr`],
/// default on) — prefers the present mode that drives VRR panels directly when the
/// session starts fullscreen.
pub allow_vrr: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
@@ -213,8 +230,47 @@ struct StreamState {
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
win_e2e_us: Vec<u64>,
win_disp_us: Vec<u64>,
/// The display stage's two halves (present-timing sessions only): decoded→submit and
/// submit→on-glass. See [`PresentedWindow::pace_ms`].
win_pace_us: Vec<u64>,
win_latch_us: Vec<u64>,
win_start: Instant,
presented: PresentedWindow,
/// The intent engine (design/desktop-presentation-rebuild.md WP2): the decoded-frame
/// store between the wake channel and the present call — a newest-wins slot under
/// the latency intent (behaviorally the shipped drain), the smoothing FIFO under
/// smoothness. NOTE: a smoothing store holds decoder-pool frames (Vulkan-Video
/// AVFrames) up to `buffer` deep on top of the depth-2 wake channels — within pool
/// headroom for 1..=3, but any deeper store must revisit pool sizing.
store: FrameStore<DecodedFrame>,
/// The panel latch grid (present-wait glass stamps; submit-anchored fallback) — the
/// smoothness slot clock, and the values published to the host-facing `latch_grid`.
clock: LatchClock,
/// The FIFO glass budget (one undisplayed present in flight) — inert off FIFO modes
/// or without present timing.
gate: PresentGate,
/// Is variable refresh actually live? Measured from the same on-glass stamps (no
/// portable query exists) — see [`CadenceProbe`].
cadence: CadenceProbe,
/// The DISPLAY MODE's refresh period — the vblank grid presents quantize to when
/// VRR is off, and so the cadence probe's reference. Deliberately not the learned
/// period (see the probe's call site).
mode_period_ns: u64,
/// The latch slot the last smoothness present served (one present per slot); 0 =
/// none yet.
last_target_ns: u64,
/// Smoothness slot-pick margin: starts 0 (a fixed lead is pure display tax —
/// measured on Android), widens +500 µs per >2-miss window toward 2.5 ms.
margin_ns: u64,
/// This window's latch misses (a present that reached glass > 1.5 latch periods
/// after submit) — the adaptive margin's error signal.
win_misses: u32,
/// This window's peak undisplayed-presents-in-flight (present timing only).
win_out_max: usize,
/// One-shot log latch: smoothness was requested but a PyroWave stream collapsed the
/// store to latency (its plane-ring retirement assumes the newest-wins hand-off).
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: bool,
// Hardware-path health: a failure streak (or a device with no import support at
// all) demotes the decoder to software via the shared flag — once per session.
dmabuf_demoted: bool,
@@ -279,6 +335,8 @@ impl StreamState {
params: SessionParams,
force_software: Arc<AtomicBool>,
wake: sdl3::event::EventSender,
priority: PresentPriority,
native_refresh_hz: u32,
) -> StreamState {
let profile = params.profile.clone();
// The presenter's half of phase-locked capture: it writes the latch grid the
@@ -316,8 +374,21 @@ impl StreamState {
hdr_untonemapped: false,
win_e2e_us: Vec::with_capacity(256),
win_disp_us: Vec::with_capacity(256),
win_pace_us: Vec::with_capacity(256),
win_latch_us: Vec::with_capacity(256),
win_start: Instant::now(),
presented: PresentedWindow::default(),
store: FrameStore::new(usize::from(priority.fifo_capacity())),
clock: LatchClock::new(native_refresh_hz),
gate: PresentGate::default(),
cadence: CadenceProbe::new(),
mode_period_ns: 1_000_000_000 / u64::from(native_refresh_hz.max(1)),
last_target_ns: 0,
margin_ns: 0,
win_misses: 0,
win_out_max: 0,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_latency_forced: false,
dmabuf_demoted: false,
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
pyro_present_warned: false,
@@ -356,6 +427,25 @@ impl StreamState {
}
self.handle.stop.store(true, Ordering::SeqCst);
}
/// The event-loop wait bound: a smoothness stream with buffered frames sleeps only
/// to its next latch-slot deadline; everything else keeps the 15 ms housekeeping
/// tick (frames, input, and present completions all wake the loop early anyway).
fn wake_timeout(&self) -> Duration {
const TICK: Duration = Duration::from_millis(15);
if !self.store.is_smoothing() || self.store.is_empty() {
return TICK;
}
let now = session::now_ns();
let mut target = self
.clock
.next_slot_after(now.saturating_add(self.margin_ns));
if target == self.last_target_ns {
// This slot is already served — the next boundary is the deadline.
target += self.clock.period_ns();
}
Duration::from_nanos(target.saturating_sub(now)).clamp(Duration::from_millis(1), TICK)
}
}
/// Whether a present error is `VK_ERROR_DEVICE_LOST` anywhere in its chain. A lost
@@ -438,9 +528,43 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
let mut presenter = Presenter::new(
&window,
&instance_exts,
crate::vk::PresentPref {
vsync: opts.vsync,
allow_vrr: opts.allow_vrr,
fullscreen: opts.fullscreen,
// `vrr_fifo_opt_in` (env) and `fifo_latest_ready` (device capability) are
// both resolved inside `Presenter::new` — the swapchain owns those, so every
// caller gets the same answer. `..Default` keeps this site from breaking each
// time the struct learns another one.
..Default::default()
},
)
.context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, FrameInput::Redraw, None)?;
// `PUNKTFUNK_PRESENTER=arrival` — the legacy drain, the intent engine's field-A/B
// kill switch (the Android sysprop pattern: no rebuild to bisect a pacing suspicion).
let arrival_override = std::env::var("PUNKTFUNK_PRESENTER").ok().as_deref() == Some("arrival");
let present_priority = if arrival_override {
tracing::info!("PUNKTFUNK_PRESENTER=arrival — presentation pacing disabled");
PresentPriority::Latency
} else {
opts.present_priority
};
let pacing_active = !arrival_override;
let present_debug = std::env::var_os("PUNKTFUNK_PRESENT_DEBUG").is_some();
// Present completions wake the loop exactly like decoded frames: a glass-gate
// reopen or a smoothness slot must not wait out the event timeout.
{
let sender = events.event_sender();
presenter.set_present_wake(Box::new(move || {
let _ = sender.push_custom_event(FrameWake);
}));
}
// Browse mode is "ready" the moment the library window presents — there may never be
// a stream. (Single mode announces on the first VIDEO frame instead, further down, so
// a shell only yields to a window that actually shows the stream.)
@@ -517,6 +641,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
))
}
ModeCtl::Browse(_) => None,
@@ -544,8 +670,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// forwarder's FrameWake) all land in this one queue, so the loop wakes exactly
// when there is work — a short-timeout poll here burned a full core (measured;
// the timeout only bounds stop-flag/pump-tick latency now). In browse-idle the
// per-iteration FIFO present vsync-throttles the loop anyway.
let timeout = Duration::from_millis(15);
// per-iteration FIFO present vsync-throttles the loop anyway. A smoothness
// stream tightens the bound to its next latch-slot deadline.
let timeout = stream
.as_ref()
.map_or(Duration::from_millis(15), |st| st.wake_timeout());
let first = event_pump.wait_event_timeout(timeout);
let mut queued: Vec<Event> = Vec::new();
if let Some(e) = first {
@@ -608,6 +737,29 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
}
// Dragged to another monitor (or the mode changed under us): the
// latch grid and the VRR verdict both belong to the OLD panel. The
// refresh rate used to be read once at startup and never revisited,
// so a 60 Hz-seeded clock would keep pacing a 144 Hz panel.
WindowEvent::DisplayChanged(..) => {
let hz = window
.get_display()
.and_then(|d| d.get_mode())
.map(|m| m.refresh_rate.round().max(0.0) as u32)
.unwrap_or(0);
if let Some(st) = stream.as_mut() {
if hz > 0 {
st.clock = LatchClock::new(hz);
st.mode_period_ns = 1_000_000_000 / u64::from(hz);
}
st.cadence.reset();
st.last_target_ns = 0;
tracing::info!(
refresh_hz = hz,
"display changed — relearning the latch grid"
);
}
}
WindowEvent::Exposed => {
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
@@ -1032,6 +1184,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
*params,
force_software,
events.event_sender(),
present_priority,
native.refresh_hz,
));
if let Some(o) = overlay.as_mut() {
o.session_phase(SessionPhase::Connecting);
@@ -1279,11 +1433,148 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
presenter.set_hdr_metadata(m);
}
}
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = st.frames.try_recv() {
newest = Some(f);
// Present-wait completions drive the latch clock, the glass gate, and the
// host-facing grid — drained every pass (a 1 Hz batch would starve all
// three; the waiter's SDL wake pairs with this so completions never wait
// out the event timeout).
if presenter.present_timing_active() {
let samples = presenter.take_presented_samples();
if !samples.is_empty() {
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let period = st.clock.period_ns();
let mut stamps = Vec::with_capacity(samples.len());
for s in &samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128
- s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
// The display split (WP4): our pipeline vs the vsync latch. Only
// meaningful with true glass stamps, which is exactly when this
// branch runs.
st.win_pace_us
.push(s.submitted_ns.saturating_sub(s.decoded_ns) / 1000);
st.win_latch_us
.push(s.displayed_ns.saturating_sub(s.submitted_ns) / 1000);
// Latch miss (the adaptive margin's error signal): glass later
// than one panel period past submit, PLUS the lead we already
// applied — i.e. the slot we aimed at was missed. Measuring the
// real latch rather than the store's own evictions is the
// Android 0.23.0 correction: policy drops happen whenever the
// stream out-runs the panel and say nothing about the latch, and
// widening on them walked the margin to its ceiling on healthy
// devices, re-imposing the very display latency it had removed.
if st.store.is_smoothing()
&& s.displayed_ns.saturating_sub(s.submitted_ns) > period + st.margin_ns
{
st.win_misses += 1;
}
stamps.push(s.displayed_ns);
}
st.clock.note_batch(&stamps);
// Same stamps answer "is VRR live" — the panel either quantizes them
// to its grid or follows our cadence. Evidence only counts from a
// window whose presents were flowing normally: a distressed pipeline
// (stale force-opens) smears spacings for reasons that have nothing
// to do with the panel, and on glass that flapped the verdict.
//
// ⚠ The reference is the DISPLAY MODE's period, NOT the learned one.
// The learned grid comes from our own present spacings, and a stream
// running below panel rate only ever produces multiples ≥ its frame
// interval — so the learner adopts our cadence as "the grid" and every
// delta then looks on-grid by construction. Measured on .21
// (2026-08-02): a 40-50 fps stream on a 60 Hz panel learned 18-22 ms
// and the probe reported VRR on a display with VRR provably disabled.
// The vblank grid is the mode's refresh; that is what presents
// quantize to when VRR is off.
//
// ⚠⚠ And it is only asked under a FIFO-family mode. The whole test
// rests on "with VRR off, a present waits for vblank" — MAILBOX and
// IMMEDIATE deliberately break that, so their stamps are never
// grid-quantized and the probe would call every mailbox session VRR.
// Measured on .21: same panel, same second — fifo read `no`
// (correct, period 16.56 ms), mailbox read `yes` (wrong). Outside
// FIFO the honest answer is "cannot tell", i.e. Unknown.
let healthy = st.presented.forced == 0;
if presenter.vblank_locked() {
st.cadence.note(&stamps, st.mode_period_ns, healthy);
}
// Phase-locked capture, the presenter's half: publish the grid the
// local clock just learned — a recent TRUE on-glass instant plus
// the latch period — for the pump's ~1 Hz PhaseReport. One learner
// feeds both, so the report and the scheduler cannot disagree.
if let Some(grid) = &st.latch_grid {
grid.period_ns
.store(st.clock.period_ns(), Ordering::Relaxed);
grid.anchor_ns
.store(st.clock.anchor_ns(), Ordering::Relaxed);
}
}
}
if let Some(f) = newest {
// Intake into the intent store: a newest-wins slot under latency (the
// shipped drain, now with displacement counters), the smoothing FIFO under
// smoothness. PyroWave collapses smoothness to latency for the stream: its
// plane-ring retirement accounting assumes the newest-wins hand-off
// (`video_pyrowave::RETIRE_HANDOVERS`), and all-intra frames make
// buffering moot anyway.
while let Ok(f) = st.frames.try_recv() {
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
if st.store.is_smoothing() && matches!(f.image, DecodedImage::PyroWave(_)) {
st.store.force_latency();
if !st.pyro_latency_forced {
st.pyro_latency_forced = true;
tracing::info!(
"PyroWave stream — smoothness buffering does not apply \
(latency pacing)"
);
}
}
st.store.submit(f);
}
// One frame out, by intent: latency takes the newest whenever the glass
// gate allows; smoothness serves at most one frame per latch slot (the
// preroll/underflow behavior lives in the store).
let now_ns = session::now_ns();
let mut slot_target = 0u64;
let mut to_present = if st.store.is_smoothing() {
let target = st
.clock
.next_slot_after(now_ns.saturating_add(st.margin_ns));
if target != st.last_target_ns {
slot_target = target;
st.store.take()
} else {
None
}
} else {
st.store.take()
};
// The FIFO glass budget: one undisplayed present in flight, so the
// swapchain's own FIFO can never become a standing queue (a measured
// 11-13 ms at 60 Hz on MAILBOX-less drivers). Only FIFO modes queue and
// only present timing can count, so everywhere else this stays inert and
// behavior is the shipped arrival pacing.
if pacing_active && presenter.needs_glass_gate() && presenter.present_timing_active() {
if let Some(f) = to_present.take() {
if st.gate.open(presenter.presents_outstanding(), now_ns) {
to_present = Some(f);
} else {
// Parked: a newest-wins store replaces it if a fresher frame
// lands; the waiter's wake (or the 100 ms stale force-open)
// retries.
st.store.put_back(f);
}
}
}
if let Some(f) = to_present {
// Resize END: a frame at the steered target size means the sharp new-mode
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
@@ -1472,6 +1763,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
};
if did_present {
presented_video = true;
// Smoothness: this latch slot is served — one present per slot.
// (Set only on success: a gated or failed present leaves the slot
// open for the retry.)
if slot_target != 0 {
st.last_target_ns = slot_target;
}
if opts.json_status && !st.ready_announced {
st.ready_announced = true;
println!("{{\"ready\":true}}");
@@ -1481,6 +1778,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// e2e/display samples arrive via `take_presented_samples` with a
// TRUE on-glass stamp instead of the submit-time one below.
presenter.note_presented(pts_ns, decoded_ns);
st.gate.note_present(now_ns);
st.win_out_max = st.win_out_max.max(presenter.presents_outstanding());
} else {
let displayed_ns = session::now_ns();
// The `displayed` stamp (same clamp rules as the pump's windows).
@@ -1495,59 +1794,81 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
st.win_disp_us
.push(displayed_ns.saturating_sub(decoded_ns) / 1000);
// No glass stamps on this stack: the submit instant anchors an
// approximate grid on the mode's refresh period, so smoothness
// still drains one frame per (approximate) slot.
st.clock.note_batch(&[displayed_ns]);
}
}
}
// Fold the presenter window into the shared stats line once per second.
// (The on-glass samples themselves are drained every pass above — they
// drive the latch clock and glass gate, not just this fold.)
if st.win_start.elapsed() >= Duration::from_secs(1) {
// On-glass samples the present-wait waiter completed this window (empty
// when timing is inactive — the legacy submit-time pushes fill in then).
let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let samples = presenter.take_presented_samples();
// Phase-locked capture, the presenter's half: publish this window's latch
// grid — a recent TRUE on-glass instant plus the panel period — for the
// pump's ~1 Hz PhaseReport. The period is the min positive spacing of
// consecutive on-glass stamps (Apple's method: honest under VRR), capped
// by the display mode's refresh — under arrival-paced MAILBOX a stream
// running below the panel rate spaces its presents at k×period, and the
// cap keeps a 30 fps stream from claiming a 30 Hz panel grid.
if let Some(grid) = &st.latch_grid {
if let Some(last) = samples.last() {
let refresh_period = 1_000_000_000u64 / u64::from(native.refresh_hz.max(1));
let min_delta = samples
.windows(2)
.map(|w| w[1].displayed_ns.saturating_sub(w[0].displayed_ns))
.filter(|&d| d > 1_000_000) // < 1 ms apart = queued pair, not a grid step
.min()
.unwrap_or(refresh_period);
grid.period_ns
.store(min_delta.min(refresh_period), Ordering::Relaxed);
grid.anchor_ns.store(last.displayed_ns, Ordering::Relaxed);
}
}
for s in samples {
let e2e = (s.displayed_ns as i128 + clock_offset_ns as i128 - s.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000);
}
st.win_disp_us
.push(s.displayed_ns.saturating_sub(s.decoded_ns) / 1000);
}
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut st.win_e2e_us);
let (disp_p50, _) = session::window_percentiles(&mut st.win_disp_us);
let (pace_p50, _) = session::window_percentiles(&mut st.win_pace_us);
let (latch_p50, _) = session::window_percentiles(&mut st.win_latch_us);
// Drained ONCE per window and shared by the HUD and the log line below —
// a second `take_counters` would read zeros.
let (replaced, q_drop, q_dry) = st.store.take_counters();
let (gated, forced) = st.gate.take_counters();
st.presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
display_ms: disp_p50 as f32 / 1000.0,
pace_ms: pace_p50 as f32 / 1000.0,
latch_ms: latch_p50 as f32 / 1000.0,
mode: presenter.present_mode_name(),
vrr: st.cadence.verdict(),
smoothing: st.store.is_smoothing(),
q_drop,
q_dry,
gated,
forced,
};
st.win_e2e_us.clear();
st.win_disp_us.clear();
st.win_pace_us.clear();
st.win_latch_us.clear();
st.win_start = Instant::now();
// Adaptive slot margin (the Android presenter's measured recipe):
// start at 0 — a fixed lead is pure display tax — and widen one step
// per window whose measured latch misses demand it. One-way per
// stream; the next stream restarts at 0.
if st.store.is_smoothing() && st.win_misses > 2 && st.margin_ns < MARGIN_MAX_NS {
st.margin_ns = (st.margin_ns + MARGIN_STEP_NS).min(MARGIN_MAX_NS);
tracing::info!(
margin_us = st.margin_ns / 1000,
misses = st.win_misses,
"smoothness slot margin widened (measured latch misses)"
);
}
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
// the field-triage instrument for the intent engine.
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
tracing::info!(
smoothing = st.presented.smoothing,
mode = st.presented.mode,
vrr = st.presented.vrr.label(),
replaced,
q_drop,
q_dry,
gated,
forced,
misses = st.win_misses,
out_max = st.win_out_max,
pace_ms = st.presented.pace_ms,
latch_ms = st.presented.latch_ms,
period_us = st.clock.period_ns() / 1000,
margin_us = st.margin_ns / 1000,
"presenter window"
);
}
st.win_misses = 0;
st.win_out_max = 0;
}
}
@@ -2007,6 +2328,32 @@ struct PresentedWindow {
e2e_p50_ms: f32,
e2e_p95_ms: f32,
display_ms: f32,
/// The display stage split (design/desktop-presentation-rebuild.md WP4):
/// `pace` = decoded → present-submit (our own pipeline), `latch` = submit → on-glass
/// (the presentation engine's queue + the vblank wait). Both `0` without
/// `VK_KHR_present_wait`, where the two are not separable — the HUD then shows the
/// unsplit figure rather than inventing a zero latch.
///
/// This split is what makes a high `display` self-diagnosing: latch dominating means
/// the vsync/queue floor (or a standing queue), pace dominating means us.
/// `pace` is also the honest cross-platform twin of the Apple client's shaved
/// number — Apple subtracts its measured OS present floor, and the latch IS our
/// floor, so `pace` is what remains on both sides of that comparison.
pace_ms: f32,
latch_ms: f32,
/// The live swapchain present mode (`mailbox`/`fifo`/…). Shown because a mode is
/// chosen from what the surface offers, so "why is my latch a refresh long" is
/// usually answered by a MAILBOX request having landed on FIFO.
mode: &'static str,
/// Whether variable refresh is measurably live (never claimed without evidence).
vrr: Cadence,
/// Presenter-engine counters for the window: the smoothing FIFO's overflow drops and
/// post-preroll underflows, and the FIFO glass gate's holds/stale force-opens.
smoothing: bool,
q_drop: u32,
q_dry: u32,
gated: u32,
forced: u32,
}
/// The capture hints (`ui_stream` parity — the words the user reads while released).
@@ -2112,6 +2459,15 @@ fn stats_text(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
// The display split (WP4). Only with true on-glass stamps — without them the
// two halves are not separable and the unsplit figure stands alone rather than
// implying a zero latch.
if p.latch_ms > 0.0 || p.pace_ms > 0.0 {
text.push_str(&format!(
" (pace {:.1} + latch {:.1})",
p.pace_ms, p.latch_ms
));
}
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
if s.staged {
@@ -2120,6 +2476,32 @@ fn stats_text(
s.host_queue_ms, s.host_encode_ms, s.host_xfer_ms, s.host_pace_ms
));
}
// The presenter line: the swapchain mode that is actually live, the chosen
// intent, and the engine's own counters. Present-mode alone answers most
// "why is my latch a whole refresh" questions; the counters only render when
// they are non-zero, so a healthy latency session shows just the mode.
if !p.mode.is_empty() {
text.push_str(&format!("\npresent: {}", p.mode));
// Only once measured — an unproven "vrr no" would be a claim, not a reading.
if p.vrr != Cadence::Unknown {
text.push_str(&format!(" · vrr {}", p.vrr.label()));
}
if p.smoothing {
text.push_str(" · smoothing");
}
if p.q_drop > 0 {
text.push_str(&format!(" · qdrop {}", p.q_drop));
}
if p.q_dry > 0 {
text.push_str(&format!(" · qdry {}", p.q_dry));
}
if p.gated > 0 {
text.push_str(&format!(" · gated {}", p.gated));
}
if p.forced > 0 {
text.push_str(&format!(" · forced {}", p.forced));
}
}
}
if s.lost > 0 {
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
@@ -2393,6 +2775,7 @@ mod tests {
e2e_p50_ms: 6.4,
e2e_p95_ms: 9.1,
display_ms: 1.1,
..Default::default()
},
)
}
@@ -2430,6 +2813,70 @@ mod tests {
!normal.contains("queue"),
"host-stage split is Detailed-only"
);
assert!(
!detailed.contains("pace 1.1"),
"no glass stamps in this sample — the display stage stays unsplit"
);
}
/// WP4: with true on-glass stamps the display stage reads as its two halves, the
/// live present mode is named, and the engine counters render only when non-zero —
/// so a healthy latency session shows the mode and nothing else. Without glass
/// stamps (no `VK_KHR_present_wait`) the split is absent rather than a zero latch.
#[test]
fn detailed_splits_display_into_pace_and_latch() {
let (s, mut p) = sample();
p.display_ms = 12.4;
p.pace_ms = 1.1;
p.latch_ms = 11.3;
p.mode = "fifo";
let split = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(split.contains("display 12.4 ms (pace 1.1 + latch 11.3)"));
assert!(split.contains("\npresent: fifo"));
assert!(
!split.contains("qdrop") && !split.contains("gated") && !split.contains("smoothing"),
"quiet counters stay off the HUD: {split}"
);
// The smoothing FIFO and the glass gate surface once they actually do something.
p.smoothing = true;
p.q_drop = 2;
p.q_dry = 1;
p.gated = 7;
p.forced = 1;
let busy = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(busy.contains("present: fifo · smoothing · qdrop 2 · qdry 1 · gated 7 · forced 1"));
// A tier below Detailed never carries any of it.
let normal = stats_text(
StatsVerbosity::Normal,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(!normal.contains("present:") && !normal.contains("pace"));
}
/// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT
+67 -2
View File
@@ -33,7 +33,7 @@ mod reconfig;
mod resources;
mod setup;
pub use setup::list_adapters;
pub use setup::{list_adapters, PresentPref};
/// One presenter iteration's video input.
pub enum FrameInput<'a> {
@@ -247,10 +247,75 @@ impl Presenter {
/// (the presenter itself never sees them). No-op when timing is inactive.
pub(crate) fn note_presented(&mut self, pts_ns: u64, decoded_ns: u64) {
if let (Some(t), Some((sc, id))) = (&self.present_timer, self.last_presented.take()) {
t.enqueue(sc, id, pts_ns, decoded_ns);
// The submit stamp: `present()` already returned, so "now" is within the
// present-call tail — the pace/latch split point.
t.enqueue(
sc,
id,
pts_ns,
decoded_ns,
pf_client_core::session::now_ns(),
);
}
}
/// Undisplayed id-carrying presents in flight (0 when timing is inactive) — the
/// FIFO glass gate's budget count.
pub(crate) fn presents_outstanding(&self) -> usize {
self.present_timer.as_ref().map_or(0, |t| t.outstanding())
}
/// Install the run loop's wake for present completions (an SDL event push). No-op
/// without present timing — there is nothing to wake on then.
pub(crate) fn set_present_wake(&self, cb: Box<dyn Fn() + Send>) {
if let Some(t) = &self.present_timer {
t.set_wake(cb);
}
}
/// The live swapchain present mode, for the stats overlay: a mode is picked from
/// what the surface actually offers, so the requested one and this can differ (a
/// MAILBOX request lands on FIFO wherever the driver has no mailbox — AMD's Windows
/// driver, notably). Showing it is what makes that visible instead of puzzling.
pub(crate) fn present_mode_name(&self) -> &'static str {
match self.present_mode {
vk::PresentModeKHR::MAILBOX => "mailbox",
vk::PresentModeKHR::FIFO => "fifo",
vk::PresentModeKHR::FIFO_RELAXED => "fifo-relaxed",
vk::PresentModeKHR::IMMEDIATE => "immediate",
setup::fifo_latest_ready::MODE => "fifo-latest-ready",
_ => "other",
}
}
/// The active present mode QUEUES presents — the only modes where the swapchain
/// itself can become a standing queue, and so the only ones the glass gate governs.
///
/// MAILBOX and IMMEDIATE replace/flip and never queue. Nor does
/// `FIFO_LATEST_READY`, which retires stale images in the driver: gating on top of it
/// would hold frames back to emulate something the presentation engine is already
/// doing, paying the serialisation twice.
pub(crate) fn needs_glass_gate(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO | vk::PresentModeKHR::FIFO_RELAXED
)
}
/// The active present mode shows images ON THE VBLANK GRID — the premise the VRR
/// cadence probe rests on ("with VRR off, a present waits for vblank"). The whole
/// FIFO family qualifies, `FIFO_LATEST_READY` included: it drops stale images but
/// still presents on the refresh boundary. MAILBOX/IMMEDIATE do not, and under them
/// the probe reports Unknown rather than calling every session VRR.
pub(crate) fn vblank_locked(&self) -> bool {
matches!(
self.present_mode,
vk::PresentModeKHR::FIFO
| vk::PresentModeKHR::FIFO_RELAXED
| setup::fifo_latest_ready::MODE
)
}
/// Take the window's completed on-glass samples (empty when timing is inactive).
pub(crate) fn take_presented_samples(&self) -> Vec<present_timing::PresentedSample> {
self.present_timer
+38 -2
View File
@@ -26,6 +26,9 @@ pub(crate) struct PresentedSample {
pub pts_ns: u64,
/// Decode-complete stamp (client clock) — the display-stage anchor.
pub decoded_ns: u64,
/// `vkQueuePresentKHR`-return stamp (client clock) — the pace/latch split point:
/// `submitted decoded` is our pipeline, `displayed submitted` the vsync latch.
pub submitted_ns: u64,
/// `vkWaitForPresentKHR` completion = the image is visible (client clock).
pub displayed_ns: u64,
}
@@ -35,15 +38,24 @@ struct Job {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
}
/// The run loop's wake callback (an SDL event push), shared with the waiter thread.
type WakeSlot = Arc<Mutex<Option<Box<dyn Fn() + Send>>>>;
/// The waiter: a channel-fed thread turning (swapchain, present-id) pairs into
/// [`PresentedSample`]s. One frame in flight upstream keeps the queue depth ~1.
pub(crate) struct PresentTimer {
tx: Option<mpsc::Sender<Job>>,
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown.
/// Jobs enqueued but not yet finished — the drain barrier for swapchain teardown,
/// and the glass gate's "undisplayed presents in flight" count.
pending: Arc<AtomicUsize>,
results: Arc<Mutex<Vec<PresentedSample>>>,
/// Called by the waiter after each completed wait (sample or not) — the run loop
/// installs an SDL wake here so a gate reopen / smoothness slot never waits out the
/// event-loop timeout.
wake: WakeSlot,
join: Option<std::thread::JoinHandle<()>>,
}
@@ -52,7 +64,8 @@ impl PresentTimer {
let (tx, rx) = mpsc::channel::<Job>();
let pending = Arc::new(AtomicUsize::new(0));
let results = Arc::new(Mutex::new(Vec::with_capacity(256)));
let (pending_t, results_t) = (pending.clone(), results.clone());
let wake: WakeSlot = Arc::new(Mutex::new(None));
let (pending_t, results_t, wake_t) = (pending.clone(), results.clone(), wake.clone());
let join = std::thread::Builder::new()
.name("pf-present-wait".into())
.spawn(move || {
@@ -69,12 +82,20 @@ impl PresentTimer {
results_t.lock().unwrap().push(PresentedSample {
pts_ns: job.pts_ns,
decoded_ns: job.decoded_ns,
submitted_ns: job.submitted_ns,
displayed_ns,
});
}
// SUBOPTIMAL/TIMEOUT/DEVICE_LOST: no sample; the frame still showed
// (or the loop is about to find out) — never poison the window.
pending_t.fetch_sub(1, Ordering::AcqRel);
// Wake the run loop AFTER the count dropped: what it observes on
// wake is the post-completion state (the gate may now be open).
// Called under the slot lock — the callback is a bare SDL event
// push and never reenters this type.
if let Some(cb) = wake_t.lock().unwrap().as_ref() {
cb();
}
}
})
.expect("spawn pf-present-wait");
@@ -82,10 +103,23 @@ impl PresentTimer {
tx: Some(tx),
pending,
results,
wake,
join: Some(join),
}
}
/// Install the run loop's wake callback (an SDL event push — thread-safe by design).
pub(crate) fn set_wake(&self, cb: Box<dyn Fn() + Send>) {
*self.wake.lock().unwrap() = Some(cb);
}
/// Presents handed to the waiter and not yet resolved to glass — the glass gate's
/// budget count. (Also counts a wait that will end SUBOPTIMAL/TIMEOUT; those resolve
/// within the 250 ms cap, far past the gate's own 100 ms stale force-open.)
pub(crate) fn outstanding(&self) -> usize {
self.pending.load(Ordering::Acquire)
}
/// Hand a successfully submitted present to the waiter.
pub(crate) fn enqueue(
&self,
@@ -93,6 +127,7 @@ impl PresentTimer {
present_id: u64,
pts_ns: u64,
decoded_ns: u64,
submitted_ns: u64,
) {
if let Some(tx) = &self.tx {
self.pending.fetch_add(1, Ordering::AcqRel);
@@ -102,6 +137,7 @@ impl PresentTimer {
present_id,
pts_ns,
decoded_ns,
submitted_ns,
})
.is_err()
{
+335 -24
View File
@@ -13,10 +13,55 @@ use ash::vk;
use ash::vk::Handle as _;
use std::ffi::{c_char, CString};
/// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers
/// ash 0.38 is generated from (1.3.281), so there is no binding for it — which is also why
/// an unenabled driver reports the mode back as the bare number `1000361000`.
///
/// The mode is FIFO's tear-free vblank pacing that presents the **latest ready** image at
/// each refresh and retires the older ones, instead of draining a queue. That is precisely
/// what [`super::super::present_pace::PresentGate`] emulates in software, done by the
/// driver — and it matters most exactly where the gate does: on a surface that offers no
/// MAILBOX, this restores newest-wins behaviour without the app holding frames back.
pub(crate) mod fifo_latest_ready {
use ash::vk;
/// `VK_EXT_present_mode_fifo_latest_ready` (extension 361).
pub(super) const NAME: &std::ffi::CStr = c"VK_EXT_present_mode_fifo_latest_ready";
/// `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.
pub(crate) const MODE: vk::PresentModeKHR = vk::PresentModeKHR::from_raw(1000361000);
/// `VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_MODE_FIFO_LATEST_READY_FEATURES_EXT`.
const S_TYPE: vk::StructureType = vk::StructureType::from_raw(1000361000);
/// `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT`. The mode is usable only
/// when this feature is enabled at device creation, so the surface advertising the
/// mode is NOT on its own permission to request it.
#[repr(C)]
#[derive(Clone, Copy)]
pub(super) struct Features {
pub s_type: vk::StructureType,
pub p_next: *mut std::ffi::c_void,
pub present_mode_fifo_latest_ready: vk::Bool32,
}
impl Default for Features {
fn default() -> Features {
Features {
s_type: S_TYPE,
p_next: std::ptr::null_mut(),
present_mode_fifo_latest_ready: vk::FALSE,
}
}
}
}
impl Presenter {
/// Bring up instance → surface → device → swapchain over an SDL window.
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
pub fn new(
window: &sdl3::video::Window,
instance_extensions: &[String],
pref: PresentPref,
) -> Result<Presenter> {
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by
// the value being built here.
@@ -176,6 +221,21 @@ impl Presenter {
// structs through its pNext chain, so any later use of it would pin those borrows —
// every read of a chained struct below must come after this, have_f2's last use.
let have_shader_int16 = have_f2.features.shader_int16;
// FIFO_LATEST_READY: the surface may list the mode even with the extension
// disabled, so the device feature is the real gate on using it.
let flr_ok = if has(fifo_latest_ready::NAME) {
let mut feat = fifo_latest_ready::Features::default();
let mut probe = vk::PhysicalDeviceFeatures2 {
p_next: (&mut feat) as *mut _ as *mut std::ffi::c_void,
..Default::default()
};
// SAFETY: per the Vulkan contract above - a read-only query on the live
// instance/device, filling locals returned by value; `feat` outlives the call.
unsafe { instance.get_physical_device_features2(pdev, &mut probe) };
feat.present_mode_fifo_latest_ready == vk::TRUE
} else {
false
};
let present_wait_ok = present_wait_exts
&& have_pid.present_id == vk::TRUE
&& have_pwait.present_wait == vk::TRUE;
@@ -273,6 +333,13 @@ impl Presenter {
dev_exts.push(ash::khr::present_id::NAME.as_ptr());
dev_exts.push(ash::khr::present_wait::NAME.as_ptr());
}
if flr_ok {
dev_exts.push(fifo_latest_ready::NAME.as_ptr());
}
let mut en_flr = fifo_latest_ready::Features {
present_mode_fifo_latest_ready: vk::TRUE,
..Default::default()
};
let mut en_pid = vk::PhysicalDevicePresentIdFeaturesKHR::default().present_id(true);
let mut en_pwait = vk::PhysicalDevicePresentWaitFeaturesKHR::default().present_wait(true);
@@ -295,6 +362,11 @@ impl Presenter {
if present_wait_ok {
en_f2 = en_f2.push_next(&mut en_pid).push_next(&mut en_pwait);
}
if flr_ok {
// Hand-rolled struct, so chain it by hand: splice into the pNext list head.
en_flr.p_next = en_f2.p_next;
en_f2.p_next = (&mut en_flr) as *mut _ as *mut std::ffi::c_void;
}
en_f2.features.shader_int16 = if pyrowave_ok { vk::TRUE } else { vk::FALSE };
let priorities = [1.0f32];
@@ -450,11 +522,17 @@ impl Presenter {
if let Some(v) = video_export.as_mut() {
v.d3d11_hdr10 = win_capable && import_rgb10 && hdr10_format.is_some();
}
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
let mut pref = pref;
pref.vrr_fifo_opt_in = vrr_fifo_opt_in();
pref.fifo_latest_ready = flr_ok;
let present_mode = pick_present_mode(&surface_i, pdev, surface, pref)?;
tracing::info!(
?format,
?hdr10_format,
?present_mode,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
fifo_latest_ready = flr_ok,
hdr_metadata = has_hdr_metadata,
"swapchain config"
);
@@ -730,42 +808,275 @@ pub(super) fn pick_formats(
Ok((sdr, hdr10))
}
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
/// fifo|mailbox|immediate|fifo_relaxed` overrides). Both defaults are tear-free, but an
/// arrival-paced presenter must not block in FIFO's present queue: when the compositor
/// holds images for a vblank pass (gamescope's composite path) or arrival cadence drifts
/// against refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms
/// added to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
/// What the user asked the presentation to be, resolved into a swapchain present mode by
/// [`present_mode_chain`] (design/desktop-presentation-rebuild.md WP3).
#[derive(Clone, Copy, Debug, Default)]
pub struct PresentPref {
/// Tear-free presentation (the `vsync` setting, default on).
pub vsync: bool,
/// Let a variable-refresh display follow the stream cadence (`allow_vrr`, default on).
pub allow_vrr: bool,
/// Opt-in for the VRR FIFO-first ladder (`PUNKTFUNK_VRR_FIFO=1`). Off by default on
/// measured evidence — see [`present_mode_chain`].
pub vrr_fifo_opt_in: bool,
/// `VK_EXT_present_mode_fifo_latest_ready` is enabled on the device, so the mode may
/// be requested. Resolved during device creation; never set by callers.
pub fifo_latest_ready: bool,
/// The session STARTED fullscreen. The mode is chosen once, at swapchain creation, so
/// this is the starting state and an F11 mid-session does not re-pick — consistent
/// with the shells' "Display changes apply from the next session" footer, and why
/// live present-mode switching is an explicit non-goal.
pub fullscreen: bool,
}
/// The preference ladder, most to least wanted. The caller takes the first entry the
/// surface actually offers; FIFO ends every chain because the spec guarantees it.
///
/// * **V-Sync off** — IMMEDIATE (tears, no wait at all), then FIFO_RELAXED (tears only on
/// a late frame), then the tear-free modes. Asking for tearing and silently getting
/// vsync is a lie the stats line now exposes, but the ladder still degrades safely.
/// * **V-Sync on + VRR allowed + fullscreen + `PUNKTFUNK_VRR_FIFO=1`** — FIFO first. On a
/// variable-refresh panel with direct scanout the FIFO present IS the flip, so the panel
/// follows the stream's cadence; MAILBOX would decouple presents from scanout and
/// re-quantize to the compositor's clock.
///
/// **Automatic where a queue-free vblank mode exists, opt-in otherwise.** The history is
/// worth keeping: this was default-on, then measured on glass (.21, GNOME/Wayland,
/// NVIDIA, *non*-VRR 60 Hz panel, 2026-08-02) to cost ~27 ms of display stage against
/// MAILBOX — `28.4 ms (pace 11.8 + latch 16.6)` versus `1.4 ms (0.2 + 1.2)` — because a
/// plain-FIFO present's on-glass confirmation lands a whole refresh later and the
/// presenter serialises behind it. It became opt-in on that evidence.
///
/// `FIFO_LATEST_READY` removes the cause rather than working around it: the driver
/// retires stale images, so the vblank-locked path measured **2.6 ms** on the same box —
/// 0.6 ms over MAILBOX instead of 27. So where the device offers it, following the panel
/// is cheap enough to be the default again; where it does not, the ladder would fall
/// back to plain FIFO and the regression returns, so it stays behind
/// `PUNKTFUNK_VRR_FIFO=1` there. The win on a genuine VRR panel is still UNMEASURED —
/// no VRR display was available — but the cost of trying is now small and bounded.
/// * **Otherwise** — MAILBOX, then FIFO: the shipped default. MAILBOX never queues more
/// than the newest frame, so an arrival-paced presenter doesn't block in the present
/// queue (a measured 11-13 ms standing wait at 60 Hz when the compositor holds images
/// for a vblank pass, or when arrival cadence drifts against refresh).
///
/// AMD's Windows driver offers no MAILBOX (NVIDIA does), so those clients land on FIFO —
/// expected, not a client misconfiguration. FIFO_RELAXED is opt-in only: it tears exactly
/// when a stream frame misses the vblank it was pacing for, which on a drifting arrival
/// cadence is often — a trade the user must choose, never a silent fallback.
/// expected, not a misconfiguration, and now visible in the `present:` stats line.
fn present_mode_chain(pref: PresentPref) -> Vec<vk::PresentModeKHR> {
use vk::PresentModeKHR as M;
let flr = pref.fifo_latest_ready.then_some(fifo_latest_ready::MODE);
let mut chain: Vec<M> = if !pref.vsync {
vec![M::IMMEDIATE, M::FIFO_RELAXED, M::MAILBOX]
} else if pref.allow_vrr && pref.fullscreen && (pref.fifo_latest_ready || pref.vrr_fifo_opt_in)
{
// The VRR ladder wants the vblank-locked family; LATEST_READY is that with the
// queue removed, so it outranks plain FIFO here too.
vec![]
.into_iter()
.chain(flr)
.chain([M::FIFO, M::MAILBOX, M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
} else {
// MAILBOX first (measured good), then LATEST_READY — which is what gives a
// MAILBOX-less surface the same newest-wins behaviour, in the driver instead of
// in our glass gate.
vec![M::MAILBOX]
.into_iter()
.chain(flr)
.chain([M::FIFO_RELAXED, M::IMMEDIATE])
.collect()
};
if !pref.vsync {
chain.extend(flr);
}
// FIFO ends every chain: the spec guarantees it exists, so there is always a landing.
chain.push(M::FIFO);
chain
}
/// `PUNKTFUNK_VRR_FIFO=1` — opt into the FIFO-first ladder for variable-refresh panels.
/// See [`present_mode_chain`] for the measurement that made this opt-in rather than
/// default.
fn vrr_fifo_opt_in() -> bool {
std::env::var("PUNKTFUNK_VRR_FIFO").is_ok_and(|v| v != "0")
}
/// Resolve the present mode: `PUNKTFUNK_PRESENT_MODE` pins one outright (the debug lever,
/// unchanged), otherwise the first entry of [`present_mode_chain`] the surface offers.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
pref: PresentPref,
) -> Result<vk::PresentModeKHR> {
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
// filling locals returned by value.
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => vk::PresentModeKHR::FIFO,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
Some("fifo_relaxed") => vk::PresentModeKHR::FIFO_RELAXED,
Some("mailbox") | None => vk::PresentModeKHR::MAILBOX,
let pinned = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("fifo") => Some(vk::PresentModeKHR::FIFO),
Some("immediate") => Some(vk::PresentModeKHR::IMMEDIATE),
Some("fifo_relaxed") => Some(vk::PresentModeKHR::FIFO_RELAXED),
Some("mailbox") => Some(vk::PresentModeKHR::MAILBOX),
None => None,
Some(other) => {
tracing::warn!(
value = other,
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — using mailbox"
"unknown PUNKTFUNK_PRESENT_MODE (expected fifo|mailbox|immediate|fifo_relaxed) — following the settings"
);
vk::PresentModeKHR::MAILBOX
None
}
};
Ok(if modes.contains(&want) {
want
} else {
vk::PresentModeKHR::FIFO // always available per spec
})
if let Some(want) = pinned {
if modes.contains(&want) {
return Ok(want);
}
tracing::warn!(
?want,
"PUNKTFUNK_PRESENT_MODE not offered by this surface — falling back"
);
}
// What the surface ACTUALLY offers, logged unconditionally. "AMD's Windows driver
// has no MAILBOX" is the premise the FIFO glass gate is built on, and it has been
// carried in comments rather than measured — present modes are a property of the
// (surface, device) pair, so they vary by platform surface, driver version and
// fullscreen state, and the only way to settle it is to read it back from real
// machines. One line here makes every field log answer the question.
tracing::info!(
available = ?modes,
"surface present modes"
);
let chain = present_mode_chain(pref);
let chosen = chain
.iter()
.copied()
.find(|m| modes.contains(m))
.unwrap_or(vk::PresentModeKHR::FIFO); // always available per spec
// The one line that answers "did V-Sync off actually take?" — a request the surface
// can't serve is a fact about the driver, and it must not look like our choice.
if chosen != chain[0] {
tracing::info!(
requested = ?chain[0],
active = ?chosen,
vsync = pref.vsync,
allow_vrr = pref.allow_vrr,
"the surface does not offer the preferred present mode"
);
}
Ok(chosen)
}
#[cfg(test)]
mod tests {
use super::*;
use vk::PresentModeKHR as M;
/// The preference ladders (WP3). Every chain must end at FIFO, which the spec
/// guarantees exists — a chain whose entries a surface all refuses would otherwise
/// have no landing.
#[test]
fn present_mode_chains_rank_by_intent() {
let pref = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true, // the ladder under test; the DEFAULT is off (see below)
fifo_latest_ready: false,
};
let flr = fifo_latest_ready::MODE;
// V-Sync off asks to tear, hardest first, and outranks the VRR rule (tearing
// already gives a VRR-like latch, so the two never fight).
assert_eq!(present_mode_chain(pref(false, true, true))[0], M::IMMEDIATE);
assert_eq!(
present_mode_chain(pref(false, false, false))[0],
M::IMMEDIATE
);
assert_eq!(
present_mode_chain(pref(false, true, true))[1],
M::FIFO_RELAXED,
"tears only on a late frame — the gentler tearing rung"
);
// Tear-free + VRR allowed + fullscreen prefers the vblank-locked family — but
// ONLY when opted in.
assert_eq!(present_mode_chain(pref(true, true, true))[0], M::FIFO);
// Without the opt-in the shipped MAILBOX-first default stands: measured on glass
// to be ~27 ms of display stage better on a non-VRR panel.
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: false,
})[0],
M::MAILBOX,
"without a queue-free vblank mode the VRR ladder would lead with plain FIFO, \
which measured ~27 ms worse so it stays opt-in there"
);
assert_eq!(
present_mode_chain(PresentPref {
vsync: true,
allow_vrr: true,
fullscreen: true,
vrr_fifo_opt_in: false,
fifo_latest_ready: true,
})[0],
fifo_latest_ready::MODE,
"with LATEST_READY available, following the panel costs 0.6 ms over MAILBOX \
instead of 27 cheap enough to be automatic"
);
// FIFO_LATEST_READY only appears where the device enabled it, and it outranks
// plain FIFO everywhere: it is FIFO's vblank pacing WITHOUT the queue, which is
// what a MAILBOX-less surface otherwise needs the software glass gate for.
let with_flr = |vsync, allow_vrr, fullscreen| PresentPref {
vsync,
allow_vrr,
fullscreen,
vrr_fifo_opt_in: true,
fifo_latest_ready: true,
};
for p in [
pref(true, false, false),
pref(true, true, true),
pref(false, true, true),
] {
assert!(
!present_mode_chain(p).contains(&flr),
"never requested unless the device enabled the extension"
);
}
let default_flr = present_mode_chain(with_flr(true, false, false));
assert_eq!(
default_flr[0],
M::MAILBOX,
"MAILBOX still leads by measurement"
);
assert_eq!(default_flr[1], flr, "then the driver-native newest-wins");
assert!(
default_flr.iter().position(|m| *m == flr)
< default_flr.iter().position(|m| *m == M::FIFO),
"LATEST_READY must outrank plain FIFO — it is FIFO minus the standing queue"
);
assert_eq!(
present_mode_chain(with_flr(true, true, true))[0],
flr,
"the VRR ladder takes the queue-free vblank mode first"
);
// Every ladder can land: FIFO appears in all of them.
for p in [
pref(true, true, true),
pref(true, true, false),
pref(true, false, true),
pref(false, true, true),
pref(false, false, false),
with_flr(true, false, false),
] {
assert!(
present_mode_chain(p).contains(&M::FIFO),
"FIFO is the guaranteed landing"
);
}
}
}
+7
View File
@@ -48,6 +48,13 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"]
"AXIS_RT" = "PUNKTFUNK_AXIS_RT"
"AUDIO_MAGIC" = "PUNKTFUNK_AUDIO_MAGIC"
"RUMBLE_MAGIC" = "PUNKTFUNK_RUMBLE_MAGIC"
"AUDIO_RED_MAGIC" = "PUNKTFUNK_AUDIO_RED_MAGIC"
"AUDIO_RED_HEADER" = "PUNKTFUNK_AUDIO_RED_HEADER"
# Same hazard as the BTN_* block above, one step worse: `FRAME_MS` and `SAMPLE_RATE_HZ` are
# generic enough that an embedder is likely to have its own, and a clashing #define silently
# takes the last definition rather than failing to compile.
"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS"
"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ"
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
# (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -490,7 +490,13 @@ impl NativeClient {
video_codecs,
preferred_codec,
display_hdr,
client_caps,
// Redundant audio (`0xD2`) is advertised by CORE, not by the embedder: the
// recovery happens on the demux side (`AudioRedRecovery` in the datagram
// task) and re-inserts the rebuilt frame into the same queue, so every
// embedder benefits without knowing the plane exists — and none of them can
// forget to opt in. The bit is a pure "I can decode it"; the host still
// decides whether to spend the extra ~1 %.
client_caps: client_caps | crate::quic::CLIENT_CAP_AUDIO_RED,
frame_parts,
launch,
name,
@@ -243,6 +243,38 @@ impl ControlTask {
seq: offer.seq,
kinds: offer.kinds,
});
} else if let Ok(chg) = crate::quic::ShardPayloadChanged::decode(&msg) {
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the
// host re-keys the sealed video geometry. Per-frame pinning means there
// is nothing to re-key on the receive path — the reassembler follows
// each frame's own header and every buffer is statically sized for the
// ceiling — so the dispatch is validate + ack. The ack is telemetry for
// a shrink and the GATE for a grow (the host emits nothing above the
// old size until it lands). Validate against our own receive bounds —
// the same ceiling we advertised in `Hello::max_shard_payload` — and
// answer an out-of-bounds request with SILENCE, not an ack: a buggy
// host must never read a granted grow out of garbage.
let n = chg.shard_payload as usize;
if (crate::config::MIN_SHARD_PAYLOAD..=crate::config::max_shard_payload())
.contains(&n)
&& n % 2 == 0
{
tracing::info!(
shard_payload = n,
"host re-keyed the wire shard payload — acking"
);
let ack = crate::quic::ShardPayloadAck {
shard_payload: chg.shard_payload,
};
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
break;
}
} else {
tracing::warn!(
shard_payload = n,
"out-of-bounds shard-payload change — ignoring (no ack)"
);
}
} else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) {
// Pointer bitmap changed (cursor channel, only when negotiated). try_send:
// an overflowing ring drops the newest shape — the next change resends.
+27 -12
View File
@@ -232,7 +232,7 @@ impl DataPump {
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_bytes = st.media_bytes_received;
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
@@ -317,11 +317,12 @@ impl DataPump {
"adaptive bitrate: capacity probe declined — keeping negotiated ceiling"
);
}
// The probe's FLAG_PROBE filler landed in `bytes_received` but never reached
// the decoder — rebase the ABR window's byte counter past it, or the next
// window's "actual throughput" reads as the burst rate and poisons the
// controller's proven-throughput high-water mark with the LINK rate.
last_bytes = st.bytes_received;
// Rebase the ABR window's byte anchor past the burst. (Probe filler is
// routed out of `media_bytes_received` at the reassembler, so it can no
// longer read as the burst rate on its own — but the anchor still has to
// skip the video that landed around the burst under a suppressed report
// tick, which would otherwise divide a long span's bytes by one window.)
last_bytes = st.media_bytes_received;
} else if Instant::now() >= deadline {
// The host never answered (a build that ignores ProbeRequest): clear the
// stuck-active state so LossReports resume, keep the negotiated ceiling.
@@ -454,11 +455,17 @@ impl DataPump {
// the next one.
let recovery_kf_reqs = pump_recovery_kf.swap(0, Ordering::Relaxed);
// The window's ACTUAL delivered throughput — what the pipeline really carried, vs
// the target it was allowed. Wire bytes (headers + FEC) slightly overstate the
// media rate the decoder ingests; acceptable for the climb gate / proven-mark
// semantics (both compare against targets with their own headroom).
// the target it was allowed. MEDIA bytes (data-shard payload: no headers, no FEC
// parity, no probe filler, no audio), because both consumers compare it against
// the ENCODER's target: the utilization gate asks "was the target genuinely
// tested?" and the proven mark bounds every later climb. Wire bytes answered a
// different question — they rise with the redundancy the host adds in answer to
// loss, so the gate read ~25 % high precisely on the links it exists for.
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
let actual_kbps = (st
.media_bytes_received
.wrapping_sub(last_bytes)
.saturating_mul(8)
/ window_ms) as u32;
// A discard window feeds the controller NOTHING — its signals are probe-tail
// residue, and one "congestion" verdict here ends slow start for good.
@@ -492,7 +499,15 @@ impl DataPump {
recovery_kf = recovery_kf_reqs,
"adaptive bitrate: requesting encoder re-target"
);
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
if ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps)).is_err() {
// Never reached the control task — tell the controller, or three of
// these retire it for the session as "the host never acked".
abr.on_request_dropped();
tracing::warn!(
kbps,
"adaptive bitrate: control queue full — re-target dropped"
);
}
}
flush_in_window = false;
last_report = Instant::now();
@@ -500,7 +515,7 @@ impl DataPump {
last_late = st.fec_late_shards;
last_received = st.packets_received;
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_bytes = st.media_bytes_received;
if pump_perf_on {
if let Some(p) = session.take_pump_perf() {
let per_pkt_ns = |ns: u64| ns.checked_div(p.packets).unwrap_or(0);
@@ -23,6 +23,10 @@ pub(super) async fn run(
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
// Redundant-audio-plane rebuild (`0xD2`). Recovery happens HERE rather than in the four
// client decoders: the recovered frame is re-inserted into this queue in order, so every
// embedder gets a complete stream without knowing the plane exists.
let mut audio_red = crate::audio::AudioRedRecovery::new();
while let Ok(d) = conn.read_datagram().await {
match d.first() {
Some(&crate::quic::AUDIO_MAGIC) => {
@@ -34,6 +38,26 @@ pub(super) async fn run(
});
}
}
Some(&crate::quic::AUDIO_RED_MAGIC) => {
if let Some((seq, pts_ns, opus, prev)) = crate::quic::decode_audio_red_datagram(&d)
{
if audio_red.recover_before(seq, prev.is_some()) {
// The copy is the frame BEFORE this one, so it carries the previous
// sequence and presentation time — one protocol frame earlier.
let _ = audio_tx.try_send(AudioPacket {
seq: seq.wrapping_sub(1),
pts_ns: pts_ns
.saturating_sub(crate::audio::FRAME_MS as u64 * 1_000_000),
data: prev.unwrap_or_default().to_vec(),
});
}
let _ = audio_tx.try_send(AudioPacket {
seq,
pts_ns,
data: opus.to_vec(),
});
}
}
Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
// Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
@@ -156,6 +156,12 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result<Handshake
// stop compositing the pointer, so only an embedder that actually renders the
// cursor locally may set it (the embedder decides, we pass through).
client_caps: args.client_caps,
// Unconditional like STREAMED_AU: the shared reassembler pins geometry
// per-frame and every receive buffer is sized from MAX_DATAGRAM_BYTES, so
// every embedder accepts a mid-session shard change up to this ceiling
// (design/shard-payload-reneg.md W0.3 — the host only renegotiates, and only
// grows to jumbo, when this advertises it).
max_shard_payload: crate::config::max_shard_payload() as u16,
}
.encode(),
)
+173
View File
@@ -341,6 +341,88 @@ pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize {
}
}
/// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
/// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
/// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
/// bottom out here instead of producing degenerate confetti-sized shards.
pub const MIN_SHARD_PAYLOAD: usize = 512;
/// The sealed wire size of a video datagram carrying `shard_payload` bytes of shard — what
/// actually leaves the socket as UDP payload (punktfunk header + shard + crypto overhead).
pub const fn sealed_datagram_bytes(shard_payload: usize) -> usize {
HEADER_LEN + shard_payload + CRYPTO_OVERHEAD
}
/// The UDP-payload size a path must carry for full-size IPv4 video datagrams: the sealed size
/// of the [`mtu1500_shard_payload`] default (= 1472, the exact 1500-MTU IPv4 ceiling). Doubles
/// as the QUIC MTU-discovery probe ceiling (`quic/endpoint.rs`): with the ceiling set to
/// exactly this value, a control connection whose discovery settles AT the ceiling has proven
/// the path carries full-size video datagrams, and one that settles BELOW it has proven the
/// path cannot — a discrimination quinn's stock 1452 ceiling can't make in either direction.
pub const fn video_datagram_udp_ceiling() -> usize {
sealed_datagram_bytes(mtu1500_shard_payload())
}
/// Largest even shard payload whose sealed datagram fits in `udp_budget` bytes of UDP payload
/// (the quantity QUIC MTU discovery measures — [`video_datagram_udp_ceiling`] is its probe
/// ceiling). Clamped to the peer's family default ([`mtu1500_shard_payload_for`]) so a generous
/// budget never grows packets past today's wire, and floored at [`MIN_SHARD_PAYLOAD`].
pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) -> usize {
let p = udp_budget.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
let p = p - p % 2; // FEC requires even shards
p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer))
}
/// The family's IP+UDP header bytes between an on-wire IP MTU and its UDP payload budget —
/// 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
fn ip_udp_overhead(peer: core::net::IpAddr) -> usize {
match peer {
core::net::IpAddr::V4(_) => 28,
core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28,
core::net::IpAddr::V6(_) => 48,
}
}
/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number
/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP
/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6.
pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp_overhead(peer)), peer)
}
/// The operator's jumbo-frames opt-in (design/shard-payload-reneg.md Phase 2): the target
/// on-wire IP MTU, or `None` = no opt-in (nothing above the 1500-default wire is ever probed
/// or grown to). One knob, one code path: a `PUNKTFUNK_WIRE_MTU` above the standard 1500
/// derives the target from the operator's number; `PUNKTFUNK_JUMBO=1` is the fixed 9000
/// profile for operators who don't want to think in MTUs. Raising the wire above 1500 is
/// only ever an ACK-GATED mid-session grow toward a client that advertised
/// [`max_shard_payload`] headroom — sessions still START at the family default.
pub fn jumbo_wire_mtu() -> Option<usize> {
if let Ok(v) = std::env::var("PUNKTFUNK_WIRE_MTU") {
if let Ok(mtu) = v.trim().parse::<usize>() {
if mtu > 1500 {
return Some(mtu);
}
}
}
match std::env::var("PUNKTFUNK_JUMBO") {
Ok(v) if v.trim() == "1" => Some(9000),
_ => None,
}
}
/// The jumbo sibling of [`shard_payload_for_wire_mtu`]: the largest even shard payload whose
/// sealed datagram fits `wire_mtu`, clamped to the RECEIVE ceiling ([`max_shard_payload`])
/// instead of the family 1500-default — the up-leg's grow target. Still floored at
/// [`MIN_SHARD_PAYLOAD`].
pub fn jumbo_shard_payload_for(wire_mtu: usize, peer: core::net::IpAddr) -> usize {
let p = wire_mtu
.saturating_sub(ip_udp_overhead(peer))
.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD);
let p = p - p % 2; // FEC requires even shards
p.clamp(MIN_SHARD_PAYLOAD, max_shard_payload())
}
/// Everything needed to construct a [`Session`](crate::session::Session).
///
/// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized
@@ -514,6 +596,97 @@ mod tests {
assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal");
}
/// The video-datagram ceiling IS the exact v4 sealed size — the QUIC MTU-discovery probe
/// ceiling (endpoint.rs) relies on this equality for its settled-at-vs-below verdict.
#[test]
fn video_datagram_ceiling_is_the_sealed_default() {
assert_eq!(
video_datagram_udp_ceiling(),
HEADER_LEN + mtu1500_shard_payload() + CRYPTO_OVERHEAD
);
assert_eq!(video_datagram_udp_ceiling(), 1472);
}
/// Budget-derived sizing: even, sealed-fits-the-budget, clamped to the family default
/// above and [`MIN_SHARD_PAYLOAD`] below.
#[test]
fn shard_payload_for_udp_budget_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
// The full ceiling reproduces the default exactly.
assert_eq!(
shard_payload_for_udp_budget(video_datagram_udp_ceiling(), v4),
mtu1500_shard_payload()
);
// A WARP/Tailscale-shaped 1280 budget: sealed result must fit the budget, stay even.
let p = shard_payload_for_udp_budget(1280, v4);
assert_eq!(p % 2, 0);
assert!(sealed_datagram_bytes(p) <= 1280);
assert!(sealed_datagram_bytes(p + 2) > 1280, "not maximal");
// Odd budgets round down to even shards.
assert_eq!(shard_payload_for_udp_budget(1281, v4) % 2, 0);
// A generous budget never grows past the family default (either family).
assert_eq!(
shard_payload_for_udp_budget(9000, v4),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_udp_budget(9000, v6),
mtu1500_shard_payload_v6()
);
// Degenerate budgets bottom out at the floor instead of confetti.
assert_eq!(shard_payload_for_udp_budget(100, v4), MIN_SHARD_PAYLOAD);
}
/// Operator-facing wire-MTU sizing subtracts the right IP+UDP header per family, and 1500
/// reproduces today's defaults exactly.
#[test]
fn shard_payload_for_wire_mtu_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap();
assert_eq!(
shard_payload_for_wire_mtu(1500, v4),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_wire_mtu(1500, mapped),
mtu1500_shard_payload()
);
assert_eq!(
shard_payload_for_wire_mtu(1500, v6),
mtu1500_shard_payload_v6()
);
// 1280 wire 28 64 = 1188 (v4); 48 64 = 1168 (v6).
assert_eq!(shard_payload_for_wire_mtu(1280, v4), 1188);
assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168);
}
/// Jumbo grow-target sizing (the up-leg, design/shard-payload-reneg.md): even, sealed
/// fits the wire, clamped to the RECEIVE ceiling instead of the family 1500-default —
/// and the standard 9000 profile lands on the exact documented value.
#[test]
fn jumbo_shard_payload_math() {
use core::net::IpAddr;
let v4: IpAddr = "192.168.1.50".parse().unwrap();
let v6: IpAddr = "fd00::50".parse().unwrap();
// 9000 28 (IPv4+UDP) 64 (header+crypto) = 8908 even; sealed 8972 ≤ the 9216
// datagram ceiling. The v6 sibling: 9000 48 64 = 8888.
assert_eq!(jumbo_shard_payload_for(9000, v4), 8908);
assert_eq!(sealed_datagram_bytes(8908), 8972);
assert!(sealed_datagram_bytes(8908) <= MAX_DATAGRAM_BYTES);
assert_eq!(jumbo_shard_payload_for(9000, v6), 8888);
// An operator MTU larger than the receive path clamps to the ceiling, smaller ones
// track the wire, and degenerate ones floor at MIN_SHARD_PAYLOAD.
assert_eq!(jumbo_shard_payload_for(64_000, v4), max_shard_payload());
let p = jumbo_shard_payload_for(4000, v4);
assert_eq!(p % 2, 0);
assert!(sealed_datagram_bytes(p) <= 4000 - 28);
assert_eq!(jumbo_shard_payload_for(100, v4), MIN_SHARD_PAYLOAD);
}
/// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6
/// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size.
#[test]
+11 -1
View File
@@ -70,7 +70,17 @@ pub const CRYPTO_OVERHEAD: usize = 8 + crate::crypto::TAG_LEN;
/// Largest UDP datagram the core will send or accept. `Config::validate` bounds
/// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
pub const MAX_DATAGRAM_BYTES: usize = 2048;
///
/// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries
/// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport
/// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed
/// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is
/// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never
/// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price
/// for never having to resize buffers on a mid-session grow. Senders still derive their
/// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps);
/// this is the acceptance ceiling, not a transmit size.
pub const MAX_DATAGRAM_BYTES: usize = 9216;
/// Fixed per-packet header. `#[repr(C)]`, no padding, zero-copy (de)serializable.
#[repr(C)]
+58 -19
View File
@@ -25,6 +25,10 @@ pub struct Packetizer {
next_probe_index: u32,
next_seq: u32,
shard_payload: usize,
/// The negotiated frame-size cap — kept so a live shard-payload swap
/// ([`set_shard_payload`](Self::set_shard_payload)) can re-derive the per-frame block
/// ceilings from the same formulas construction used.
max_frame_bytes: usize,
fec: crate::config::FecConfig,
version: u8,
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
@@ -47,10 +51,12 @@ pub struct Packetizer {
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
/// The peer's per-frame block ceiling, mirroring [`ReassemblerLimits::from_config`]'s
/// `max_blocks` — the streamed path's bound on how many sentinel blocks it may emit (a
/// streamed AU's size isn't known up front, so this is the only pre-emission guard against
/// producing a frame the receiver must reject).
/// The peer's per-frame block ceiling — the streamed path's bound on how many sentinel
/// blocks it may emit (a streamed AU's size isn't known up front, so this is the only
/// pre-emission guard against producing a frame the receiver must reject). The receiver
/// derives the same ceiling per packet from the packet's own `shard_bytes`
/// (`Reassembler::push` — geometry is per-frame), so this stays in step as long as it is
/// computed from the shard size this packetizer actually stamps.
max_blocks: usize,
/// The streamed path's block-count ceiling in SLICE mode ([`USER_FLAG_SLICE_STREAM`]) —
/// variable-K blocks, floored at `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` shards.
@@ -73,9 +79,9 @@ pub struct StreamedAu {
pts_ns: u64,
user_flags: u32,
/// Bytes not yet sealed into a block: the sub-shard remainder plus anything below the
/// slice-flush threshold. The final block always has ≥ 1 byte (flushes emit only whole
/// shards and never drain to empty on a slice that ends the AU — `finish_streamed` seals
/// whatever remains).
/// slice-flush threshold. The final block always has ≥ 1 byte flushes emit only whole
/// shards, and a flush that WOULD empty this keeps one shard back (see `push_streamed`),
/// so `finish_streamed` always has something real to seal.
pending: Vec<u8>,
/// Sentinel blocks already emitted.
blocks_out: u16,
@@ -105,15 +111,12 @@ impl StreamedAu {
impl Packetizer {
pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
let total_data_max = config
.max_frame_bytes
.div_ceil(config.shard_payload.max(1))
.max(1);
Packetizer {
let mut p = Packetizer {
next_frame_index: 0,
next_probe_index: 0,
next_seq: 0,
shard_payload: config.shard_payload,
max_frame_bytes: config.max_frame_bytes,
fec: config.fec,
version: config.phase as u8,
tail: Vec::new(),
@@ -121,12 +124,37 @@ impl Packetizer {
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
max_blocks: total_data_max.div_ceil(max_data).max(1),
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
// data shards (the flush floor, clamped by the block size), so a max-size frame
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
slice_block_cap: total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2,
}
// Derived from the shard size below (single source of truth for the formulas).
max_blocks: 0,
slice_block_cap: 0,
};
p.set_shard_payload(config.shard_payload);
p
}
/// Live-swap the wire shard payload (mid-session shard renegotiation,
/// design/shard-payload-reneg.md Phase 1). Takes effect on the next packetized AU — call
/// ONLY between AUs, never with a [`StreamedAu`] in flight: an open streamed AU's
/// shard-aligned tiling derives from the size it began with, and re-keying under it would
/// corrupt the frame's layout. The per-frame block ceilings follow the new size here; the
/// receiver re-derives its side per packet from the header's own `shard_bytes` (geometry
/// is per-frame there), so the two stay in step by construction. Bounds are the caller's
/// contract — go through [`Session::set_shard_payload`](crate::session::Session::set_shard_payload),
/// which enforces the `Config::validate` rules.
pub fn set_shard_payload(&mut self, shard_payload: usize) {
let max_data = self.fec.max_data_per_block as usize;
let total_data_max = self.max_frame_bytes.div_ceil(shard_payload.max(1)).max(1);
self.shard_payload = shard_payload;
self.max_blocks = total_data_max.div_ceil(max_data).max(1);
// Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)`
// data shards (the flush floor, clamped by the block size), so a max-size frame
// bounds the block count. Mirrors the receiver's slice firewall — keep in step.
self.slice_block_cap = total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2;
}
/// The wire shard payload AUs are currently packetized at.
pub fn shard_payload(&self) -> usize {
self.shard_payload
}
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
@@ -418,7 +446,18 @@ impl Packetizer {
"streamed AU exceeds the negotiated max_frame_bytes",
));
}
let k = whole.min(self.fec.max_data_per_block as usize);
// Never drain `pending` to EMPTY. [`finish_streamed`] must have bytes left to seal,
// or the final block degenerates to a single zero-padded filler shard whose derived
// base (`total_data 1`) overlaps the block flushed just now — which the receiver's
// retro-validation correctly reads as a lying header and kills the whole AU. It bites
// exactly when the AU's length is a multiple of `shard_payload` (~1 in 1408 frames on
// a 1500-MTU link), and only on the slice arm: the legacy `must_flush` is a strict
// `>`, so its remainder is never empty. Keeping one whole shard back costs nothing —
// it rides out in the final block, which has to exist regardless.
let mut k = whole.min(self.fec.max_data_per_block as usize);
if k > 1 && k == whole && au.pending.len() == whole * payload {
k -= 1;
}
let sof = !au.opened;
let (bi, pts, uf) = (au.blocks_out, au.pts_ns, au.user_flags);
let fi = au.frame_index;
+112 -21
View File
@@ -76,6 +76,12 @@ struct BlockState {
}
struct FrameBuf {
/// The frame's PINNED shard payload — set by its first-arriving packet (bounds-checked by
/// the firewall), matched by every later packet of the frame. Geometry is per-frame so a
/// mid-session `shard_payload` change (design/shard-payload-reneg.md) is safe on an
/// unordered wire: frames in flight complete under their own pin while new frames arrive
/// under the new one, and no cross-geometry splice can land in one buffer.
shard_bytes: usize,
/// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet
/// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't
/// arrived yet — the frame can't complete before they do (and retro-validate).
@@ -105,16 +111,28 @@ struct FrameBuf {
/// Per-session bounds the reassembler enforces on every packet header *before*
/// allocating, so a hostile or corrupt header cannot drive unbounded memory use. All
/// derived from the negotiated [`Config`].
///
/// Shard geometry is PER-FRAME, not per-session (mid-session shard-payload renegotiation,
/// design/shard-payload-reneg.md W0.1): a frame's first-arriving packet pins the frame's
/// `shard_bytes` within `[min_shard_bytes, max_shard_bytes]`, later packets must match the
/// pin, and the per-frame block ceiling derives from the pinned size (a shrunk shard needs
/// more blocks for the same bytes). The reorder race between an ordered control-stream
/// geometry change and the unordered video datagrams is thereby killed structurally — every
/// frame is wholly one geometry, whichever order its packets and the change arrive in.
#[derive(Clone, Copy, Debug)]
pub struct ReassemblerLimits {
/// Expected shard payload length; every shard in the stream must match exactly.
pub shard_bytes: usize,
/// Floor for a frame's pinned shard payload — [`crate::config::MIN_SHARD_PAYLOAD`] in
/// production (or the negotiated value when a session legitimately starts below it).
pub min_shard_bytes: usize,
/// Ceiling for a frame's pinned shard payload — what this receive path accepts and what
/// the client advertises in `Hello::max_shard_payload`
/// ([`crate::config::max_shard_payload`]): the transport recv buffers are sized for a
/// sealed datagram of exactly this shard size.
pub max_shard_bytes: usize,
/// Max data shards per block (the negotiated `max_data_per_block`).
pub max_data_shards: usize,
/// Max total shards per block (data + recovery), capped by the FEC scheme ceiling.
pub max_total_shards: usize,
/// Max FEC blocks per frame.
pub max_blocks: usize,
/// Max accepted access-unit size.
pub max_frame_bytes: usize,
}
@@ -135,12 +153,13 @@ impl ReassemblerLimits {
// snapshot of it.
let max_total =
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
ReassemblerLimits {
shard_bytes: c.shard_payload,
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
// hand-configured session below the production floor still reassembles itself.
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
max_shard_bytes: crate::config::max_shard_payload(),
max_data_shards: max_data,
max_total_shards: max_total,
max_blocks: total_data.div_ceil(max_data).max(1),
max_frame_bytes: c.max_frame_bytes,
}
}
@@ -179,6 +198,9 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4;
/// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery
/// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst.
/// Entries size themselves to the largest shard they ever held, so a jumbo session (opt-in,
/// desktop-LAN — shards up to [`ReassemblerLimits::max_shard_bytes`]) retains proportionally
/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there.
const RECOVERY_POOL_MAX: usize = 512;
/// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units.
@@ -295,11 +317,16 @@ impl Reassembler {
// Bound every attacker-controllable header field against the negotiated limits
// BEFORE allocating anything keyed on it — this is the firewall against a tiny
// datagram triggering a huge `vec![None; total]` / `Vec::with_capacity`.
// `shard_bytes` is bounds-checked (not equality-checked) because geometry is
// per-frame — the frame-pin check below is what rejects a size CHANGE mid-frame;
// the even requirement mirrors `Config::validate` (FEC requires even shards).
let drop = |stats: &StatsCounters| {
StatsCounters::add(&stats.packets_dropped, 1);
};
if hdr.magic != PUNKTFUNK_MAGIC
|| shard_bytes != lim.shard_bytes
|| shard_bytes < lim.min_shard_bytes
|| shard_bytes > lim.max_shard_bytes
|| shard_bytes % 2 != 0
|| pkt.len() < HEADER_LEN + shard_bytes
|| data_shards == 0
|| data_shards > lim.max_data_shards
@@ -330,6 +357,11 @@ impl Reassembler {
// later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
// cap from the negotiated size would reject legitimate post-shrink frames). Mirrors
// the sender's `Packetizer::new` for whatever size it currently packetizes at.
let max_blocks = total_data_max.div_ceil(lim.max_data_shards).max(1);
// The slice pipeline's per-frame block ceiling: every non-final slice block carries at
// least `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` data shards (the sender's
// flush floor, clamped by the block size), so a max-size frame bounds the block count
@@ -350,9 +382,7 @@ impl Reassembler {
return Ok(None);
}
} else if sentinel {
if frame_bytes != 0
|| data_shards != lim.max_data_shards
|| block_idx + 1 >= lim.max_blocks
if frame_bytes != 0 || data_shards != lim.max_data_shards || block_idx + 1 >= max_blocks
{
drop(stats);
return Ok(None);
@@ -361,7 +391,7 @@ impl Reassembler {
let block_cap = if slice_stream {
slice_block_cap
} else {
lim.max_blocks
max_blocks
};
if block_count > block_cap || block_idx >= block_count {
drop(stats);
@@ -429,6 +459,13 @@ impl Reassembler {
stats
.probe_last_arrival_ns
.store(now_ns, std::sync::atomic::Ordering::Relaxed);
} else if hdr.shard_index < hdr.data_shards {
// Media accounting (see `Stats::media_bytes_received`): DATA shards only, payload
// only. Stamped at the same routing decision as the probe counters and for the same
// reason — the adaptive-bitrate utilization gate compares delivered throughput
// against an ENCODER target, so parity, headers and probe filler have no business
// in the numerator.
StatsCounters::add(&stats.media_bytes_received, shard_bytes as u64);
}
let win = if is_probe { probe } else { video };
win.advance_window(
@@ -467,14 +504,33 @@ impl Reassembler {
return Ok(None);
}
// First packet of a frame allocates its whole (zeroed) buffer, budget-gated; later
// packets must agree with its geometry. A sentinel-opened (streamed) frame allocates at
// the limits' maximum — its real size doesn't exist yet.
let buf_len = if sentinel {
total_data_max * shard_bytes
// How many shards of frame buffer THIS packet proves the frame needs. A sentinel carries
// no total, but it does pin its own block's extent — a slice sentinel by its wire base,
// a legacy one by its full-K position — and that is what the buffer must cover to place
// the shard. The frame grows as later blocks reveal more, and the final (non-sentinel)
// block's totals settle it.
//
// ⚠ NOT `total_data_max` (= the negotiated `max_frame_bytes`, 8-64 MiB): that shape
// shipped in 0.23.0 and was survivable only while sentinels were rare — the streamed
// path emitted one solely for an AU exceeding a whole FEC block (~281 KB). The slice
// wire flushes at `MIN_STREAM_BLOCK_SHARDS`, so EVERY ordinary AU became sentinel-opened
// and every one of them committed the full ceiling: a multi-megabyte zeroed allocation
// per access unit, and an in-flight budget (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`)
// exhausted after ~3 concurrent frames — beyond which every packet of every further
// frame was dropped outright. On a jittery link that is a permanent loss storm.
let need_shards = if sentinel && slice_stream {
frame_bytes / shard_bytes + data_shards
} else if sentinel {
// Legacy sentinels are full-K uniform blocks (firewall-enforced), so the block's
// index alone gives its end.
(block_idx + 1).saturating_mul(lim.max_data_shards)
} else {
total_data * shard_bytes
};
total_data
}
.min(total_data_max);
// First packet of a frame allocates its (zeroed) buffer, budget-gated; later packets must
// agree with its geometry.
let buf_len = need_shards * shard_bytes;
let frame = match win.frames.entry(hdr.frame_index) {
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
std::collections::hash_map::Entry::Vacant(e) => {
@@ -487,6 +543,7 @@ impl Reassembler {
}
*in_flight_bytes += buf_len;
e.insert(FrameBuf {
shard_bytes,
// A slice-stream sentinel's `frame_bytes` is its block's BASE offset, not a
// frame size — the unpinned marker stays 0 until the final block's totals.
frame_bytes: if sentinel { 0 } else { frame_bytes },
@@ -501,6 +558,15 @@ impl Reassembler {
})
}
};
// Per-frame geometry pin: the frame's first packet pinned its shard size; a later
// packet claiming a different (even in-bounds) size is dropped — otherwise two
// geometries would compute different offsets into one buffer (a splice). This is
// also what makes a mid-session `shard_payload` change safe against reorder: a
// straggler of the old geometry can only ever land in ITS OWN frame's buffer.
if frame.shard_bytes != shard_bytes {
drop(stats);
return Ok(None);
}
// The slice marker must be frame-consistent: a mixed frame would firewall under one
// placement rule and place under the other. The per-packet checks above and the
// placement bounds guard below stay memory-safe without this — it's the tighter drop.
@@ -602,6 +668,21 @@ impl Reassembler {
drop(stats);
return Ok(None);
}
// Grow to this packet's proven extent. A streamed frame opens at whichever block arrived
// first and learns its real size from the final block's totals (or a later, higher
// sentinel base) — reorder means either can come first, so the buffer is sized by
// whatever the frame has proven so far. Never shrinks: the totals only settle the frame's
// END, and completion truncates to `frame_bytes` anyway. The budget is re-checked here
// for exactly the reason it is checked at open — growth commits memory too.
if buf_len > frame.buf.len() {
let delta = buf_len - frame.buf.len();
if *in_flight_bytes + delta > IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes {
drop(stats);
return Ok(None);
}
*in_flight_bytes += delta;
frame.buf.resize(buf_len, 0);
}
let FrameBuf {
buf,
blocks,
@@ -842,6 +923,16 @@ impl Reassembler {
// jump-to-live, exactly the stale content the flush existed to discard.
self.pending_partial = None;
}
/// Test-only: the current in-flight frame-buffer byte commitment (see
/// [`IN_FLIGHT_BUF_FACTOR`]). The mixed-geometry budget tests assert it returns to
/// exactly zero once every frame has terminated — the 0.23.0 lesson: geometry changes
/// breed sizing bugs, and accounting drift here surfaces in the field as a permanent
/// loss storm once the budget wedges.
#[cfg(test)]
pub(crate) fn in_flight(&self) -> usize {
self.in_flight_bytes
}
}
/// The data shards of a terminating frame that only exist because parity restored them
@@ -983,10 +1074,10 @@ mod reset_tests {
#[test]
fn reset_drops_a_parked_partial() {
let mut r = Reassembler::new(ReassemblerLimits {
shard_bytes: 64,
min_shard_bytes: 64,
max_shard_bytes: 64,
max_data_shards: 8,
max_total_shards: 16,
max_blocks: 4,
max_frame_bytes: 4096,
});
r.pending_partial = Some(Frame {
+578 -13
View File
@@ -7,11 +7,14 @@ use crate::stats::StatsCounters;
use zerocopy::{FromBytes, IntoBytes};
fn limits() -> ReassemblerLimits {
// `min == max` pins the whole stream to 16-byte shards — the strictest geometry, so the
// firewall tests below exercise the bounds checks; per-frame-pinning tests build their own
// limits with a real range. Derived per-frame block ceiling: 4096/16 = 256 shards → 32.
ReassemblerLimits {
shard_bytes: 16,
min_shard_bytes: 16,
max_shard_bytes: 16,
max_data_shards: 8,
max_total_shards: 12,
max_blocks: 4,
max_frame_bytes: 4096,
}
}
@@ -840,7 +843,7 @@ fn streamed_sentinel_firewall_bounds() {
.unwrap()
.is_none());
// Sits on the last block the limits allow (no room for the final block after it).
let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4
let h = sentinel(|h| h.block_index = 31); // derived max_blocks == 32 (see `limits()`)
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
@@ -941,8 +944,9 @@ fn slice_config() -> Config {
/// Slice chunks chosen to exercise every packetizer path: an exact-shard slice, a slice with
/// a sub-shard remainder, a slice below [`MIN_STREAM_BLOCK_SHARDS`] that must accumulate,
/// and a finish tail. 1023 B total → blocks (K, base-shard): (20, 0), (25, 20), (18, 45),
/// final (1, 63) with block_count 4.
/// and a finish tail. 1023 B total → blocks (K, base-shard): (19, 0), (26, 19), (18, 45),
/// final (1, 63) with block_count 4. Chunk 0 is an exact 20-shard multiple and flushes 19:
/// a flush never drains `pending` to empty, so `finish_streamed` always seals real bytes.
fn slice_chunks() -> Vec<Vec<u8>> {
[320usize, 403, 100, 200]
.iter()
@@ -1007,7 +1011,8 @@ fn slice_streamed_wire_shape_and_roundtrip() {
assert_eq!(src.len(), 1023);
// (block_index, K, base bytes) — chunk 2 (100 B) accumulated instead of flushing (6
// whole shards < MIN_STREAM_BLOCK_SHARDS) and rode into block 2 with chunk 3's bytes.
let expect = [(0u16, 20u16, 0u32), (1, 25, 320), (2, 18, 720)];
// Block 0 keeps one shard back (chunk 0 is an exact multiple), which rides into block 1.
let expect = [(0u16, 19u16, 0u32), (1, 26, 304), (2, 18, 720)];
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_ne!(
@@ -1478,15 +1483,24 @@ fn parts_flow_for_legacy_streamed_frames() {
assert!(got.last().unwrap().complete);
}
/// A sentinel first-packet commits a MAX-sized frame buffer, so the in-flight budget must
/// bite after IN_FLIGHT_BUF_FACTOR frames — the amplification bound for one-datagram opens.
/// A one-datagram open commits only the buffer its OWN header proves it needs, and the
/// in-flight budget still bounds the ones that claim a lot.
///
/// Both halves matter. A sentinel that claims little must cost little: sizing every
/// sentinel-opened frame at `max_frame_bytes` (the 0.23.0 shape) was survivable only while
/// sentinels were rare, and the slice wire made every ordinary AU one — after which the budget
/// was spent on ~3 frames and everything else on the link was dropped. A sentinel that claims a
/// lot must still be bounded: its wire base can point near the frame ceiling, which is the
/// amplification this budget exists for.
#[test]
fn streamed_open_amplification_is_budget_bounded() {
let mut r = Reassembler::new(limits());
fn streamed_open_commits_its_own_extent_and_stays_bounded() {
let coder = coder_for(FecScheme::Gf8);
// limits(): shard 16 B, max_data_shards 8, max_frame_bytes 4096 → budget = 4 × 4096.
// Modest legacy sentinels (block 0, full K = 8 → 128 B each): far more than
// IN_FLIGHT_BUF_FACTOR of them must fit, because none of them claims the ceiling.
let mut r = Reassembler::new(limits());
let stats = StatsCounters::default();
// limits(): max_frame_bytes 4096 → each sentinel open commits 4096 B; budget = 4×4096.
for fi in 0..5u32 {
for fi in 0..32u32 {
let mut h = base_header();
h.block_count = 0;
h.frame_bytes = 0;
@@ -1498,10 +1512,35 @@ fn streamed_open_amplification_is_budget_bounded() {
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
0,
"ordinary one-datagram opens must not exhaust the in-flight budget"
);
// A SLICE sentinel whose wire base sits just under the ceiling really does commit a
// max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the
// fifth must be refused.
let mut r = Reassembler::new(limits());
let stats = StatsCounters::default();
for fi in 0..5u32 {
let mut h = base_header();
h.user_flags = USER_FLAG_SLICE_STREAM;
h.block_count = 0;
h.frame_bytes = 4096 - 8 * 16;
h.block_index = 1;
h.data_shards = 8;
h.recovery_shards = 0;
h.frame_index = fi;
assert!(r
.push(&packet(h), coder.as_ref(), &stats)
.unwrap()
.is_none());
}
assert_eq!(
stats.snapshot().packets_dropped,
1,
"the fifth max-sized open must be refused by the in-flight budget"
"the fifth ceiling-claiming open must be refused by the in-flight budget"
);
}
@@ -1612,3 +1651,529 @@ fn streamed_second_final_with_different_totals_is_rejected() {
.expect("frame completes under the first pinned totals");
assert_eq!(got.data.len(), 160);
}
/// Production-shaped slice geometry: a 1500-MTU shard payload and the smallest frame ceiling
/// the QUIC handshake ever negotiates (`max_frame_bytes` is clamped to ≥ 8 MiB there).
fn prod_slice_config() -> Config {
use crate::config::{FecConfig, ProtocolPhase, Role};
Config {
role: Role::Host,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 200,
},
shard_payload: crate::config::mtu1500_shard_payload(),
max_frame_bytes: 8 << 20,
encrypt: false,
key: SessionKey::Aes128Gcm([0u8; 16]),
salt: [0u8; 4],
loopback_drop_period: 0,
}
}
/// Packetize one streamed AU of `chunks`, each chunk an encoder slice boundary.
fn streamed_packets_with(
cfg: &Config,
frame_index: u32,
pts_ns: u64,
slice: bool,
chunks: &[usize],
) -> (Vec<Vec<u8>>, Vec<u8>) {
let coder = coder_for(cfg.fec.scheme);
let mut pk = Packetizer::new(cfg);
let uf = if slice { USER_FLAG_SLICE_STREAM } else { 0 };
let mut au = pk.begin_streamed(pts_ns, uf, Some(frame_index));
let (mut pkts, mut src) = (Vec::new(), Vec::new());
let sink = |pkts: &mut Vec<Vec<u8>>, h: &PacketHeader, b: &[u8]| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
};
for (c, &n) in chunks.iter().enumerate() {
let data: Vec<u8> = (0..n).map(|i| (c * 57 + i * 131 + 7) as u8).collect();
src.extend_from_slice(&data);
pk.push_streamed(&mut au, &data, true, coder.as_ref(), |h, b| {
sink(&mut pkts, h, b);
Ok(())
})
.unwrap();
}
pk.finish_streamed(au, coder.as_ref(), |h, b| {
sink(&mut pkts, h, b);
Ok(())
})
.unwrap();
(pkts, src)
}
/// An AU whose length is an exact multiple of the shard payload must still reassemble.
///
/// Regression: the slice flush drained `pending` to empty, so `finish_streamed` sealed a final
/// block of one zero-padded FILLER shard. Its derived base (`total_data 1`) overlapped the
/// sentinel block flushed a moment earlier, the receiver's retro-validation read that as a lying
/// header, and the whole AU was destroyed — one frame in every `shard_payload` (~12 s at 120 fps),
/// each costing a re-anchor freeze and a recovery keyframe.
#[test]
fn slice_streamed_exact_shard_multiple_completes() {
let cfg = prod_slice_config();
let coder = coder_for(FecScheme::Gf16);
let payload = cfg.shard_payload;
for shards in [16usize, 29, 30, 64] {
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[shards * payload]);
// Whatever the block split, the final block must carry real bytes — never a lone
// zero-pad shard sitting on top of the previous block's range.
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("{shards}-shard AU (exact multiple) must complete"));
assert_eq!(f.data, src, "{shards}-shard AU must be byte-identical");
}
// ...and the sweep around one of them, so an off-by-one in the keep-back can't hide.
for extra in 0..3usize {
let n = 30 * payload + extra;
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &[n]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("{n}-byte AU must complete"));
assert_eq!(f.data, src);
}
}
/// A slice-streamed frame must cost the reassembler its OWN size, not the negotiated ceiling.
///
/// Regression: sentinel-opened frames allocated `max_frame_bytes` (8-64 MiB) each. Since the
/// slice wire makes every ordinary AU sentinel-opened, the in-flight budget
/// (`IN_FLIGHT_BUF_FACTOR × max_frame_bytes`) was spent after ~3 concurrent frames and every
/// packet of every further frame was dropped outright — a permanent loss storm on any link with
/// normal reorder, plus a multi-megabyte zeroing per access unit.
#[test]
fn slice_streamed_in_flight_budget_matches_legacy() {
let cfg = prod_slice_config();
let coder = coder_for(FecScheme::Gf16);
// A normal 40 KB access unit, opened but not completed — the shape a link with reorder
// holds several of at once.
for slice in [false, true] {
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
for i in 0..12u32 {
let (pkts, _) = streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
}
assert_eq!(
stats
.packets_dropped
.load(std::sync::atomic::Ordering::Relaxed),
0,
"slice={slice}: 12 ordinary AUs in flight must fit the in-flight budget"
);
}
}
// ---------------------------------------------------------------------------
// Per-frame shard geometry (mid-session shard-payload renegotiation — W0.1,
// design/shard-payload-reneg.md). The 0.23.0 lesson applies in full: geometry
// changes breed sizing bugs, so the slice/sentinel suite re-runs at every
// production shard size and mixed-geometry streams are tortured under reorder.
// ---------------------------------------------------------------------------
/// The shard sizes the renegotiation actually moves between: the clamp floor (512), a
/// WARP/Tailscale-shaped 1280-MTU path (1216), the 1500-MTU default (1408), and 9000-MTU
/// jumbo (8908 — sealed 8972, inside [`MAX_DATAGRAM_BYTES`]).
const PRODUCTION_SHARDS: [usize; 4] = [512, 1216, 1408, 8908];
/// [`prod_slice_config`] at an arbitrary shard payload.
fn geo_config(shard_payload: usize) -> Config {
let mut c = prod_slice_config();
c.shard_payload = shard_payload;
c.validate().expect("geometry config must be valid");
c
}
/// Packetize one legacy AU at the packetizer's CURRENT shard payload with an explicit
/// frame index, returning wire packets + source bytes.
fn legacy_packets_with(
pk: &mut Packetizer,
frame_index: u32,
pts_ns: u64,
len: usize,
coder: &dyn crate::fec::ErasureCoder,
) -> (Vec<Vec<u8>>, Vec<u8>) {
let src: Vec<u8> = (0..len)
.map(|i| (i * 131 + frame_index as usize * 7 + 3) as u8)
.collect();
let mut pkts: Vec<Vec<u8>> = Vec::new();
pk.packetize_each(&src, pts_ns, 0, Some(frame_index), coder, |h, b| {
let mut p = Vec::with_capacity(HEADER_LEN + b.len());
p.extend_from_slice(h.as_bytes());
p.extend_from_slice(b);
pkts.push(p);
Ok(())
})
.unwrap();
(pkts, src)
}
/// The slice-wire regression suite re-run at every production shard size (the design's
/// non-negotiable verification): the exact-multiple sweep (the 0.23.0 filler-shard bug
/// shape), lossy + reversed slice roundtrips, the legacy-streamed sentinel path, and the
/// in-flight budget — each asserting DELIVERED byte-identical frames, never just an
/// absence of errors.
#[test]
fn slice_wire_suite_at_production_shard_sizes() {
let coder = coder_for(FecScheme::Gf16);
for &shard in &PRODUCTION_SHARDS {
let cfg = geo_config(shard);
// Exact-shard-multiple AUs + the off-by-one sweep around one of them.
for shards in [16usize, 30, 64] {
for extra in 0..3usize {
let n = shards * shard + extra;
let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[n]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("shard {shard}: {n}-byte slice AU must complete"));
assert_eq!(
f.data, src,
"shard {shard}: {n}-byte AU must be byte-identical"
);
assert_eq!(
r.in_flight(),
0,
"shard {shard}: budget must return to zero"
);
}
}
// A multi-slice AU under loss (one data shard of the first flushed block — within
// its ≥ 20% parity) in both delivery orders. Reversed is the critical order: the
// final block's totals arrive first and every sentinel validates against the pin.
for reverse in [false, true] {
let chunks = [20 * shard + 13, 7 * shard + 1, 17 * shard];
let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &chunks);
let killed = pkts
.iter()
.position(|p| {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
h.shard_index < h.data_shards && h.recovery_shards >= 1
})
.expect("suite frame must have a recoverable data shard");
let mut delivery: Vec<Vec<u8>> = pkts
.iter()
.enumerate()
.filter(|(i, _)| *i != killed)
.map(|(_, p)| p.clone())
.collect();
if reverse {
delivery.reverse();
}
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &delivery).unwrap_or_else(|| {
panic!("shard {shard} reverse={reverse}: lossy slice AU must complete")
});
assert_eq!(f.data, src, "shard {shard} reverse={reverse}");
assert_eq!(r.in_flight(), 0);
}
// Legacy-streamed (uniform full-K sentinel) path: one AU spanning a sentinel block
// (K = 200) plus a final block.
{
let (pkts, src) = streamed_packets_with(&cfg, 3, 3000, false, &[230 * shard]);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("shard {shard}: legacy-streamed AU must complete"));
assert_eq!(f.data, src);
assert_eq!(r.in_flight(), 0);
}
// The budget regression at this size: 12 ordinary AUs opened concurrently, no drops.
for slice in [false, true] {
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
for i in 0..12u32 {
let (pkts, _) =
streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]);
r.push(&pkts[0], coder.as_ref(), &stats).unwrap();
}
assert_eq!(
stats
.packets_dropped
.load(std::sync::atomic::Ordering::Relaxed),
0,
"shard {shard} slice={slice}: 12 AUs in flight must fit the budget"
);
}
}
}
/// One packetizer, one reassembler, one continuous stream — the shard payload swapped
/// live between AUs ([`Packetizer::set_shard_payload`], the Phase 1 host seam): every
/// frame across shrink → grow-to-jumbo → shrink-again delivers byte-identically under its
/// own per-frame pin, and the budget returns to zero.
#[test]
fn mid_stream_shard_swap_delivers_every_frame() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut pk = Packetizer::new(&cfg);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
// (shard size to swap to, AU length) — swaps happen between AUs, as Phase 1 will.
let schedule = [
(1408usize, 3 * 1408 + 100),
(1408, 9 * 1408),
(512, 5 * 512 + 17), // shrink (the VPN heal)
(512, 512),
(8908, 12 * 8908 + 1), // grow (jumbo)
(1216, 4 * 1216 + 9), // revert (a mis-proven jumbo hop self-corrects)
];
for (i, &(shard, len)) in schedule.iter().enumerate() {
pk.set_shard_payload(shard);
let pts = 1_000_000 * (i as u64 + 1);
let (pkts, src) = legacy_packets_with(&mut pk, i as u32, pts, len, coder.as_ref());
for p in &pkts {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
assert_eq!(
h.shard_bytes as usize, shard,
"sender must stamp the live size"
);
}
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts)
.unwrap_or_else(|| panic!("frame {i} at shard {shard} must complete"));
assert_eq!(
f.data, src,
"frame {i} at shard {shard} must be byte-identical"
);
assert!(f.complete);
}
assert_eq!(
r.in_flight(),
0,
"budget must be exact across geometry swaps"
);
assert_eq!(stats.snapshot().frames_dropped, 0);
}
/// The reorder race the design kills structurally: an old-geometry frame still in flight
/// when new-geometry frames start arriving completes under its OWN pin — its straggler
/// lands in its own buffer, not the new geometry's.
#[test]
fn old_geometry_frame_completes_after_new_geometry_arrived() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut pk = Packetizer::new(&cfg);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
// Frame 0 at 1408: 7 data shards + 2 parity (20% FEC), data-first wire order. Withhold
// THREE data shards — more than parity can bridge — so the frame genuinely stays
// incomplete until a straggler returns (fewer, and FEC would complete it early).
let (pkts0, src0) = legacy_packets_with(&mut pk, 0, 1_000_000, 6 * 1408 + 50, coder.as_ref());
assert_eq!(
pkts0.len(),
9,
"expected geometry changed — update the split"
);
let head: Vec<Vec<u8>> = pkts0[..4].iter().chain(&pkts0[7..]).cloned().collect();
let straggler = &pkts0[4];
assert!(
push_all(&mut r, coder.as_ref(), &stats, &head).is_none(),
"frame 0 must still be incomplete"
);
// The stream re-keys to 512: frames 1..=2 arrive whole and deliver.
pk.set_shard_payload(512);
for i in 1..=2u32 {
let pts = 1_000_000 + 1_000_000 * i as u64;
let (pkts, src) = legacy_packets_with(&mut pk, i, pts, 3 * 512 + 7, coder.as_ref());
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts).expect("new-geometry frame");
assert_eq!(f.data, src);
}
// Frame 0's old-geometry straggler arrives last — the frame completes byte-identically.
let f = r
.push(straggler, coder.as_ref(), &stats)
.unwrap()
.expect("old-geometry frame must complete under its own pin");
assert_eq!(f.data, src0);
assert_eq!(f.frame_index, 0);
assert_eq!(r.in_flight(), 0);
assert_eq!(stats.snapshot().frames_dropped, 0);
}
/// The anti-splice pin: a packet claiming a DIFFERENT (but in-bounds) shard size for an
/// already-pinned frame is dropped — and the frame still completes from its real packets.
#[test]
fn cross_geometry_packet_for_a_pinned_frame_is_dropped() {
let cfg = geo_config(1408);
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let stats = StatsCounters::default();
let mut pk_a = Packetizer::new(&geo_config(1408));
let mut pk_b = Packetizer::new(&geo_config(1216));
let (pkts, src) = legacy_packets_with(&mut pk_a, 0, 1_000_000, 5 * 1408 + 9, coder.as_ref());
// The impostor: the same frame index packetized at 1216 — self-consistent (it passes
// the firewall standalone), wrong for THIS frame's pin.
let (impostor, _) = legacy_packets_with(&mut pk_b, 0, 1_000_000, 5 * 1216, coder.as_ref());
assert!(r.push(&pkts[0], coder.as_ref(), &stats).unwrap().is_none());
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&impostor[1], coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"cross-geometry packet must be dropped by the frame pin"
);
let f = push_all(&mut r, coder.as_ref(), &stats, &pkts[1..])
.expect("the pinned frame must still complete from its real packets");
assert_eq!(f.data, src, "no impostor bytes may reach the frame");
}
/// The firewall bounds on a frame's pinned size: below the floor, above the receive
/// ceiling, or odd ⇒ dropped before any allocation; the exact floor and ceiling are
/// accepted AND deliver (proving the rejections aren't vacuous).
#[test]
fn shard_size_firewall_bounds() {
let cfg = geo_config(1408);
let lim = ReassemblerLimits::from_config(&cfg);
assert_eq!(lim.min_shard_bytes, crate::config::MIN_SHARD_PAYLOAD);
assert_eq!(lim.max_shard_bytes, crate::config::max_shard_payload());
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let single = |shard: usize, frame_index: u32| {
let mut h = base_header();
h.frame_index = frame_index;
h.shard_bytes = shard as u16;
h.frame_bytes = shard as u32;
h
};
// Below the floor (even), above the ceiling (even), odd within bounds: all dropped.
for (i, shard) in [510usize, 9154, 1409].into_iter().enumerate() {
let before = stats.snapshot().packets_dropped;
assert!(r
.push(&packet(single(shard, i as u32)), coder.as_ref(), &stats)
.unwrap()
.is_none());
assert_eq!(
stats.snapshot().packets_dropped,
before + 1,
"shard {shard} must be firewalled"
);
}
// The exact bounds deliver whole single-shard frames.
for (i, shard) in [
crate::config::MIN_SHARD_PAYLOAD,
crate::config::max_shard_payload(),
]
.into_iter()
.enumerate()
{
let f = r
.push(
&packet(single(shard, 10 + i as u32)),
coder.as_ref(),
&stats,
)
.unwrap()
.unwrap_or_else(|| panic!("boundary shard {shard} must deliver"));
assert_eq!(f.data.len(), shard);
}
}
mod geometry_proptests {
use super::*;
use proptest::prelude::*;
/// One generated frame: shard size, slice-vs-legacy wire, size factor, and whether to
/// kill one recoverable data shard.
type GenFrame = (usize, bool, usize, bool);
fn frame_strategy() -> impl Strategy<Value = GenFrame> {
(
proptest::sample::select(&PRODUCTION_SHARDS[..]),
any::<bool>(),
1usize..30,
any::<bool>(),
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
/// Mixed-geometry reorder torture: frames of DIFFERENT shard sizes and wire shapes
/// interleaved into one shuffled delivery, with per-frame recoverable loss — every
/// frame must deliver byte-identically and the in-flight budget must return to
/// exactly zero (the 0.23.0 budget-drift shape, now across geometries).
#[test]
fn mixed_geometry_reorder_torture(
frames in proptest::collection::vec(frame_strategy(), 2..6),
seed in any::<u64>(),
) {
let coder = coder_for(FecScheme::Gf16);
let mut r = Reassembler::new(ReassemblerLimits::from_config(&geo_config(1408)));
let stats = StatsCounters::default();
let mut all: Vec<(u64, u32, Vec<u8>)> = Vec::new(); // (shuffle key, frame, pkt)
let mut sources: Vec<(u32, Vec<u8>)> = Vec::new();
for (i, &(shard, slice, factor, kill)) in frames.iter().enumerate() {
let cfg = geo_config(shard);
let pts = 1_000_000 * (i as u64 + 1);
let len = factor * shard + (factor % shard.min(7));
let (mut pkts, src) = if slice {
streamed_packets_with(&cfg, i as u32, pts, true, &[len.max(1)])
} else {
let mut pk = Packetizer::new(&cfg);
legacy_packets_with(&mut pk, i as u32, pts, len.max(1), coder.as_ref())
};
if kill {
if let Some(k) = pkts.iter().position(|p| {
let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
h.shard_index < h.data_shards && h.recovery_shards >= 1
}) {
pkts.remove(k);
}
}
for (j, p) in pkts.into_iter().enumerate() {
// Deterministic pseudo-shuffle key: interleaves frames and reorders
// within a frame, differently per proptest case.
let key = (seed | 1)
.wrapping_mul(j as u64 + 1)
.wrapping_add((i as u64) << 17)
.rotate_left((j % 61) as u32);
all.push((key, i as u32, p));
}
sources.push((i as u32, src));
}
all.sort_by_key(|(k, _, _)| *k);
let mut delivered: std::collections::HashMap<u32, Vec<u8>> =
std::collections::HashMap::new();
for (_, _, p) in &all {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
prop_assert!(f.complete);
prop_assert!(delivered.insert(f.frame_index, f.data).is_none(),
"a frame must deliver exactly once");
}
}
for (i, src) in &sources {
let got = delivered.get(i);
prop_assert!(got.is_some(), "frame {i} must be DELIVERED, not merely error-free");
prop_assert_eq!(got.unwrap(), src, "frame {} must be byte-identical", i);
}
prop_assert_eq!(r.in_flight(), 0, "budget must be exact after all frames terminate");
prop_assert_eq!(stats.snapshot().frames_dropped, 0u64);
}
}
}
+206 -2
View File
@@ -1,9 +1,105 @@
//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md):
//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so
//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host
//! the client-side half of the controller's v2 error signal, plus the panel-grid learner every
//! vsync-aware presenter paces against. Pure math, no features — shared so every presenter
//! (Android today, iOS and the desktop session client next) computes the SAME statistic the host
//! controller was tuned against, and so the controller's simulation tests can generate their
//! synthetic reports through the identical code path.
/// Plausible panel periods: ~24 Hz to ~500 Hz. A spacing outside this is a clock glitch, not a
/// display mode, and must never reach the estimate.
const PANEL_PERIOD_RANGE_NS: std::ops::RangeInclusive<i64> = 2_000_000..=42_000_000;
/// Spacings within this of the estimate are the same grid — absorbs ordinary timeline jitter.
const PANEL_GRID_TOLERANCE_NS: i64 = 200_000;
/// Consecutive WIDER observations required before the estimate grows. One stray wide sample is a
/// scheduling hiccup; eight in a row (~66 ms at 120 Hz) is a display that really did slow down.
const PANEL_WIDEN_STREAK: u8 = 8;
/// The panel's true refresh period, learned from observed vsync/frame-timeline spacing.
///
/// A presenter subdivides its release targets onto this grid, so an estimate FINER than the panel
/// makes it aim at instants that never arrive and release faster than the display consumes —
/// which is why the estimate has to be able to move both ways.
///
/// Seeding is the reason this is not simply "believe the last sample". The platform's *configured*
/// mode is not the panel: under a per-uid frame-rate override a 120 Hz panel reports 60
/// (`Display.getRefreshRate` returns the override — observed on-glass, A024), and the app's own
/// choreographer callbacks arrive at the down-rated rate while the panel scans at its own. The
/// mode TABLE is honest about what the panel *can* do, so it is the seed; the timeline spacing is
/// honest about what it is *doing*, so it is the correction.
///
/// The asymmetry is deliberate. **Narrowing is immediate**: a finer real grid is always safe to
/// subdivide onto, and it is the down-rate case the seed most often gets wrong. **Widening needs
/// [`PANEL_WIDEN_STREAK`] consecutive agreeing observations** and then adopts the *narrowest* of
/// them, because a wide sample is far more likely to be a missed callback than a mode change.
///
/// ⚠ 0.23.0 shipped this learner as narrow-only, seeded from the display mode the app *requests*
/// (`preferredDisplayModeId` is a hint the system may refuse). A refused 120 Hz switch therefore
/// left the presenter pacing a 60 Hz panel on an 8.33 ms grid with no way back — permanently.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PanelGrid {
period_ns: i64,
widen_streak: u8,
/// Narrowest wider-than-estimate spacing seen during the current streak.
widen_candidate: i64,
}
impl PanelGrid {
/// Seed from the display mode's refresh rate (`0` = unknown — the first plausible observation
/// then sets the estimate outright).
pub fn seeded(hz: i32) -> PanelGrid {
PanelGrid {
period_ns: if hz > 0 { 1_000_000_000 / hz as i64 } else { 0 },
widen_streak: 0,
widen_candidate: 0,
}
}
/// The learned period, or `0` while unknown.
pub fn period_ns(&self) -> i64 {
self.period_ns
}
/// Fold one observed grid spacing. Returns `true` when [`period_ns`](Self::period_ns) changed.
pub fn observe(&mut self, spacing_ns: i64) -> bool {
if !PANEL_PERIOD_RANGE_NS.contains(&spacing_ns) {
return false; // implausible — a clock glitch, not a display mode
}
if self.period_ns == 0 {
self.reset_streak();
self.period_ns = spacing_ns;
return true;
}
if spacing_ns < self.period_ns - PANEL_GRID_TOLERANCE_NS {
self.reset_streak();
self.period_ns = spacing_ns;
return true;
}
if spacing_ns > self.period_ns + PANEL_GRID_TOLERANCE_NS {
self.widen_streak = self.widen_streak.saturating_add(1);
self.widen_candidate = if self.widen_candidate == 0 {
spacing_ns
} else {
self.widen_candidate.min(spacing_ns)
};
if self.widen_streak >= PANEL_WIDEN_STREAK {
self.period_ns = self.widen_candidate;
self.reset_streak();
return true;
}
return false;
}
self.reset_streak(); // this sample agreed — the run of wider ones is broken
false
}
fn reset_streak(&mut self) {
self.widen_streak = 0;
self.widen_candidate = 0;
}
}
/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch
/// mod the period (ns) and the coherence (‰).
///
@@ -90,3 +186,111 @@ mod tests {
assert!(circular_latch(&[1_000; 16], 0).is_none());
}
}
#[cfg(test)]
mod panel_grid_tests {
use super::*;
const P120: i64 = 8_333_333;
const P60: i64 = 16_666_666;
#[test]
fn seeds_from_the_mode_and_reports_unknown_without_one() {
assert_eq!(PanelGrid::seeded(120).period_ns(), 8_333_333);
assert_eq!(PanelGrid::seeded(0).period_ns(), 0);
let mut g = PanelGrid::seeded(0);
assert!(
g.observe(P120),
"the first plausible sample sets an unseeded grid"
);
assert_eq!(g.period_ns(), P120);
}
#[test]
fn narrows_immediately_when_the_panel_is_faster_than_the_mode_said() {
// The down-rate case: the mode table read 60, the timelines run at 120.
let mut g = PanelGrid::seeded(60);
assert!(g.observe(P120));
assert_eq!(g.period_ns(), P120, "a finer real grid is adopted at once");
}
/// The 0.23.0 bug: `preferredDisplayModeId` is a request, so a refused 120 Hz switch seeds a
/// 120 Hz grid on a panel that is really running 60. The narrow-only learner could never
/// climb back, and the presenter aimed at instants the panel never reached.
#[test]
fn widens_back_out_when_the_requested_mode_was_refused() {
let mut g = PanelGrid::seeded(120);
for i in 0..PANEL_WIDEN_STREAK - 1 {
assert!(!g.observe(P60), "sample {i} must not widen on its own");
assert_eq!(g.period_ns(), P120);
}
assert!(
g.observe(P60),
"a sustained run of wider spacings widens the grid"
);
assert_eq!(g.period_ns(), P60);
}
#[test]
fn one_stray_wide_sample_never_widens() {
let mut g = PanelGrid::seeded(120);
for _ in 0..40 {
assert!(!g.observe(P60));
assert!(!g.observe(P120)); // an agreeing sample breaks the run
}
assert_eq!(
g.period_ns(),
P120,
"alternating samples must not accumulate"
);
}
#[test]
fn widening_adopts_the_narrowest_of_the_run() {
let mut g = PanelGrid::seeded(120);
// A run of wide spacings that includes some very wide outliers.
let run = [
P60,
33_000_000,
P60 + 400_000,
41_000_000,
P60,
P60,
P60,
P60,
];
for s in run {
g.observe(s);
}
assert_eq!(
g.period_ns(),
P60,
"the estimate takes the narrowest of the run, never an outlier"
);
}
#[test]
fn implausible_spacings_are_ignored_entirely() {
let mut g = PanelGrid::seeded(120);
for _ in 0..100 {
assert!(!g.observe(0));
assert!(!g.observe(-1));
assert!(!g.observe(1_000_000)); // 1000 Hz — below the range floor
assert!(!g.observe(100_000_000)); // 10 Hz — above the ceiling
}
assert_eq!(g.period_ns(), P120);
}
#[test]
fn a_transient_narrow_glitch_self_heals() {
// Narrowing is immediate, so a glitch DOES poison the estimate — the point is that it is
// no longer permanent (0.23.0's learner had no way back).
let mut g = PanelGrid::seeded(120);
assert!(g.observe(2_100_000), "a glitch narrows the estimate");
assert_eq!(g.period_ns(), 2_100_000);
for _ in 0..PANEL_WIDEN_STREAK {
g.observe(P120);
}
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
}
}

Some files were not shown because too many files have changed in this diff Show More