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
73 changed files with 7287 additions and 487 deletions
@@ -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))
@@ -435,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,
)
}
}
+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;
}
@@ -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 {
@@ -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,6 +29,10 @@ 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
@@ -52,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.
@@ -62,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
@@ -72,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)
@@ -80,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))
@@ -96,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)
@@ -138,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 {
@@ -164,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.
@@ -185,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)
@@ -219,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.
@@ -238,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) }
@@ -394,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
@@ -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
}
}
@@ -186,6 +186,16 @@ public final class StreamViewController: StreamViewControllerBase {
// 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.
@@ -446,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
@@ -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.
+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(),
)
+86 -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),
};
@@ -207,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;
@@ -221,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),
@@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow {
can_wake: false,
last_used: None,
os: "linux/arch/steamos".into(),
pin: None,
bound_profile: None,
}
}
@@ -506,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.
}
}
}
@@ -544,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| {
@@ -563,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,
@@ -581,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
@@ -612,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));
+11 -6
View File
@@ -1045,13 +1045,18 @@ 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 (%LOCALAPPDATA%\punktfunk\logs) — 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.
// Best-effort, like the log itself: a missing dir or a failed spawn stays silent.
// 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::log_dir() {
let _ = std::fs::create_dir_all(&dir);
if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) {
let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn();
}
});
+131 -3
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,14 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer.
pub(crate) fn log_dir() -> Option<PathBuf> {
/// 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 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
@@ -97,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()
);
}
}
+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)
+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,
}]
);
}
}
+237 -19
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,
@@ -50,7 +55,8 @@ 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.
// 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,
@@ -149,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,
@@ -168,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
@@ -180,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)
@@ -190,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
@@ -200,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(
@@ -224,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,
@@ -241,7 +309,38 @@ 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;
// 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
@@ -382,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,
@@ -477,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."
}
}
}
@@ -611,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()
}
@@ -736,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"
@@ -745,7 +857,7 @@ 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));
@@ -771,7 +883,7 @@ mod tests {
device_name: "t",
t: 0.0,
};
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
assert!(
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
"latency intent = thud"
@@ -781,14 +893,14 @@ mod tests {
// 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!(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);
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
}
#[test]
@@ -864,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());
}
}
+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
+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.
+536 -155
View File
@@ -150,11 +150,14 @@ const ENCODE_SEVERE_US: i64 = 12_000;
/// the same reason: the decoder's knee moves with content and thermals.
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
/// Two consecutive decode-driven backoffs latch the
/// Two decode-driven backoffs latch the
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
/// no knee and must not teach one.
/// no knee and must not teach one. Each sample must come from a rate the controller CLIMBED
/// back to (`climb_since_backoff`) — the knee's real signature is choke, recover, re-climb,
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
@@ -286,6 +289,20 @@ pub(crate) struct BitrateController {
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
/// to latch the cap — one spurious flush teaches nothing.
decode_backoff_kbps: u32,
/// Decode-flagged windows in the CURRENT bad-window streak. The ordinary two-window backoff
/// path is the decoder knee's most common presentation (a standing 1545 ms decode rise —
/// deep enough to hurt, not deep enough for the severe tier), and judging decode evidence
/// from the FINAL window alone threw that attribution away: the backoff the decode signal
/// itself caused then RESET the knee streak. Counted per bad window, cleared with the streak.
streak_decode_windows: u32,
/// Whether `current_kbps` has RISEN (via an ack — ours or a host-initiated re-target) since
/// the last backoff. A knee sample is only meaningful for a rate the controller climbed to
/// or held; a backoff that fires while the previous backoff's damage is still draining
/// samples a rate the decoder never choked at (the host acks a ×0.7 request in ~100 ms, so
/// a cascade's second backoff ALWAYS sits at the already-reduced rate — dissimilar to the
/// knee by construction, 0.7 < 7/8). Such a backoff neither samples nor erases the
/// reference.
climb_since_backoff: bool,
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
/// clock's own backoff interval — same schedule as the host cap's.
decode_cap_probe_windows: u32,
@@ -341,6 +358,10 @@ impl BitrateController {
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
decode_cap_kbps: None,
decode_backoff_kbps: 0,
streak_decode_windows: 0,
// The negotiated start rate was held, not drained to — the first backoff ever is a
// legitimate knee sample.
climb_since_backoff: true,
decode_cap_probe_windows: 0,
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
proven_kbps: 0,
@@ -433,6 +454,13 @@ impl BitrateController {
}
}
}
if kbps > self.current_kbps {
// The rate ROSE — whatever the pipeline chokes on next, it will choke at a rate
// it was driven up to: a fresh knee sample (see `climb_since_backoff`). An ack'd
// decrease deliberately does not arm this — the drain after a backoff is not a
// knee encounter.
self.climb_since_backoff = true;
}
self.current_kbps = kbps;
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
@@ -472,6 +500,8 @@ impl BitrateController {
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
self.decode_cap_kbps = None;
self.decode_backoff_kbps = 0;
self.streak_decode_windows = 0;
self.climb_since_backoff = true;
self.decode_cap_probe_windows = 0;
self.owd_means.clear();
self.decode_means.clear();
@@ -571,12 +601,20 @@ impl BitrateController {
}
if bad {
self.bad_windows += 1;
if decode_bad {
// Per-window decode attribution for the streak (see `streak_decode_windows`) —
// scored HERE because at backoff time only the final window's signals are in
// scope, and on the two-window path the first bad window never even reaches a
// decision (the cooldown eats it).
self.streak_decode_windows += 1;
}
self.clean_windows = 0;
// Any congestion signal ends slow start for good — from here on, climbs are additive.
self.probing = false;
} else {
self.clean_windows += 1;
self.bad_windows = 0;
self.streak_decode_windows = 0;
}
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
@@ -635,21 +673,42 @@ impl BitrateController {
&& self.current_kbps > self.floor_kbps
{
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
// with decode-severe evidence — the deep decode excursion, or the flush that
// drained the queue behind a stalled decoder — remembers its pre-backoff rate; the
// SECOND consecutive one at a similar rate latches that rate as the decoder's
// knee. One event never latches (a spurious flush must stay a one-off), and a
// backoff without decode evidence in between breaks the streak — whatever it saw,
// it wasn't the same knee.
// A bare flush counts as decode evidence only where the decode signal can't speak
// for itself. On an embedder that reports decode latency, a flush with FLAT decode
// is a network event (a stall, a clock step) that drained a queue the decoder was
// keeping up with — teaching a "decoder knee" from it caps the session on the wrong
// end of the pipe. Where the signal is absent the old reading stands: the flush is
// the only decoder-saturation evidence there is.
let decode_evidence =
decode_severe || (flushed && (decode_bad || decode_mean_us.is_none()));
if decode_evidence {
// with decode evidence remembers its pre-backoff rate; the next one at a similar
// rate latches that rate as the decoder's knee. One event never latches (a spurious
// flush must stay a one-off), and a decode-free backoff in between breaks the
// streak — whatever it saw, it wasn't the same knee.
//
// Decode evidence, in order:
// - a decode-SEVERE excursion in the deciding window;
// - the ordinary two-window path where EVERY bad window was decode-flagged
// (`streak_decode_windows`) — the knee's most common presentation is a standing
// 1545 ms rise, below the severe tier, and the deciding window alone can't see
// that the streak it ends was decode's doing;
// - a keyframe-ask storm without meaningful loss: a decoder begging for fresh
// pictures on a clean link is being overdriven, whatever its latency figure says
// (some decoders wedge rather than queue — the Steam Deck presentation). With
// real loss present the asks are network-attributed and teach nothing here;
// - a flush, where the decode signal can't speak against it: on an embedder that
// reports decode latency, a flush with FLAT decode is a network event (a stall, a
// clock step) that drained a queue the decoder was keeping up with — teaching a
// "decoder knee" from it caps the session on the wrong end of the pipe. Where the
// signal is absent the flush is the only decoder-saturation evidence there is.
let decode_evidence = decode_severe
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|| (flushed && (decode_bad || decode_mean_us.is_none()));
if !self.climb_since_backoff {
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
// so this window's rate is one the decoder never choked at while keeping up —
// its distress is residue of the choke above. Not a knee sample either way:
// neither latch against it nor let it erase the reference the real knee set.
tracing::debug!(
at_kbps = self.current_kbps,
reference_kbps = self.decode_backoff_kbps,
"adaptive bitrate: backoff without an intervening climb — draining the \
previous choke, not a knee sample"
);
} else if decode_evidence {
let rate = self.current_kbps;
let similar = self.decode_backoff_kbps > 0
&& rate.abs_diff(self.decode_backoff_kbps)
@@ -683,8 +742,10 @@ impl BitrateController {
} else {
self.decode_backoff_kbps = 0;
}
self.climb_since_backoff = false;
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
self.bad_windows = 0;
self.streak_decode_windows = 0;
return self.request(next, now);
}
// Climbs only fire off a UTILIZED clean window (actual delivered ≥ ¾ of the target — the
@@ -1945,71 +2006,100 @@ mod tests {
assert_eq!(run_clean(&mut c, start, 24, 20), None);
}
fn calm_window(c: &mut BitrateController, at: Instant) {
// One calm, unutilized window (2 Mb/s actual): seeds the latency baselines without
// authorizing climbs, and must decide nothing.
assert_eq!(
c.on_window(at, 0, 0, Some(10_000), Some(8_000), None, 2_000, false, 0),
None
);
}
/// Drive clean, fully-utilized windows (1 Gb/s actual), acking every climb the controller
/// asks for — a live host answers in ~100 ms — until `current_kbps` reaches `target`.
/// Bounded so a climb-path regression fails loudly instead of spinning.
fn climb_to(c: &mut BitrateController, start: Instant, tick: &mut u32, target: u32) {
for _ in 0..600 {
if c.current_kbps >= target {
return;
}
if let Some(k) = c.on_window(
ticks(start, *tick),
0,
0,
Some(10_000),
Some(8_000),
None,
1_000_000,
false,
0,
) {
c.on_ack(k);
}
*tick += 1;
}
panic!(
"no climb to {target} within 600 windows (stuck at {})",
c.current_kbps
);
}
/// One decode-SEVERE window (60 ms against the ~8 ms baseline) at the current rate — a
/// knee choke. Steps past the change cooldown first so the decision can fire.
fn choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
*tick += 2;
let r = c.on_window(
ticks(start, *tick),
0,
0,
Some(10_000),
Some(60_000),
None,
c.current_kbps,
false,
0,
);
*tick += 1;
r
}
/// The latch's only production-reachable shape: choke at the knee, the host ACKS the ×0.7
/// (a live host answers in ~100 ms, so a cascade's second backoff always sits at the
/// already-reduced rate — dissimilar by construction), the controller climbs back, and the
/// re-climb chokes inside the ±1/8 band. Latches, acks the backoff, returns the cap.
fn latch_knee(c: &mut BitrateController, start: Instant, tick: &mut u32) -> u32 {
for _ in 0..4 {
calm_window(c, ticks(start, *tick));
*tick += 1;
}
let knee = c.current_kbps;
let r1 = choke(c, start, tick).expect("first choke must back off");
assert!(c.decode_cap_kbps.is_none(), "one event must not latch");
c.on_ack(r1);
climb_to(c, start, tick, knee - knee / DECODE_CAP_SIMILAR_DIV);
let rate = c.current_kbps;
let r2 = choke(c, start, tick).expect("re-climb choke must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
c.on_ack(r2);
rate - rate / 16
}
#[test]
fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() {
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
// dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the
// same rate, and the second decode-severe backoff must latch the knee.
// dropped-frame burst. Choke, recover, climb back, choke again inside the band: latch.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
// Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes).
for i in 0..4 {
assert_eq!(
c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0
),
None
);
}
// First deep decode excursion → immediate ×0.7, but ONE event must not latch.
assert_eq!(
c.on_window(
ticks(start, 4),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert!(c.decode_cap_kbps.is_none());
// Second consecutive decode-severe backoff at the same pre-backoff rate: latch.
assert_eq!(
c.on_window(
ticks(start, 6),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
// The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps
let mut t = 0;
latch_knee(&mut c, start, &mut t);
// The latch applies; from here every climb must stop AT the knee — not the 900 Mbps
// link ceiling the old sawtooth kept re-poking.
c.on_ack(350_000);
let mut max_req = 0;
for i in 8..70 {
for _ in 0..62 {
if let Some(k) = c.on_window(
ticks(start, i),
ticks(start, t),
0,
0,
Some(10_000),
@@ -2030,6 +2120,7 @@ mod tests {
max_req = max_req.max(k);
c.on_ack(k);
}
t += 1;
}
assert!(
max_req < 600_000,
@@ -2039,37 +2130,82 @@ mod tests {
#[test]
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
// The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can
// flush once at ANY rate) backs off but teaches nothing…
// The latch's false-positive guards, every event at a rate the controller climbed to
// or held (drain-time backoffs are no sample at all —
// `cascade_backoffs_neither_sample_nor_erase_the_knee_reference` owns those). A lone
// jump-to-live flush (a Wi-Fi clump can flush once at ANY rate) backs off but teaches
// nothing…
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
assert_eq!(
c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0),
Some(350_000)
);
let mut t = 0;
let r1 = c
.on_window(ticks(start, t), 0, 0, None, None, None, 490_000, true, 0)
.expect("flush must back off");
assert_eq!(r1, 350_000);
assert!(c.decode_cap_kbps.is_none());
c.on_ack(350_000);
// …a LOSS-driven backoff in between breaks the streak
assert_eq!(
c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0),
Some(245_000)
);
c.on_ack(r1);
// …a LOSS-driven backoff at the re-climbed rate breaks the streak (whatever choked
// there, it wasn't the decoder — even inside the similarity band)…
climb_to(&mut c, start, &mut t, 460_000);
t += 2;
let r2 = c
.on_window(
ticks(start, t),
1,
0,
None,
None,
None,
c.current_kbps,
false,
0,
)
.expect("loss must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(245_000);
assert_eq!(
c.decode_backoff_kbps, 0,
"a climbed-to non-decode backoff must reset the knee reference"
);
c.on_ack(r2);
// …so the next flush counts as a FIRST decode event again — still no latch…
assert_eq!(
c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0),
Some(171_500)
);
climb_to(&mut c, start, &mut t, 460_000);
t += 2;
let r3 = c
.on_window(
ticks(start, t),
0,
0,
None,
None,
None,
c.current_kbps,
true,
0,
)
.expect("flush must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(171_500);
// …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no
c.on_ack(r3);
// …and two decode events at DISSIMILAR climbed-to rates (~460 vs ~350 Mbps — no
// common knee) must not latch either.
assert_eq!(
c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0),
Some(120_050)
);
let dissimilar_target = c.current_kbps + 20_000;
climb_to(&mut c, start, &mut t, dissimilar_target);
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
0,
None,
None,
None,
c.current_kbps,
true,
0,
)
.expect("flush must back off");
assert!(c.decode_cap_kbps.is_none());
}
@@ -2082,38 +2218,14 @@ mod tests {
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
for i in 0..4 {
let mut t = 0;
let knee = latch_knee(&mut c, start, &mut t);
// The host parks the session at the knee (an unsolicited re-target up to it — its
// clamp is authoritative).
c.on_ack(knee);
for _ in 0..CAP_REPROBE_WINDOWS_MIN {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0,
);
}
for i in [4, 6] {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0,
);
}
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
// The host's ack parks the session at the knee (its clamp is authoritative).
c.on_ack(500_000 - 500_000 / 16);
for i in 0..CAP_REPROBE_WINDOWS_MIN {
let _ = c.on_window(
ticks(start, 8 + i),
ticks(start, t),
0,
0,
Some(10_000),
@@ -2123,8 +2235,8 @@ mod tests {
false,
0,
);
t += 1;
}
let knee = 500_000 - 500_000 / 16;
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
}
@@ -2135,38 +2247,307 @@ mod tests {
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
for i in 0..4 {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(8_000),
None,
2_000,
false,
0,
);
}
for i in [4, 6] {
let _ = c.on_window(
ticks(start, i),
0,
0,
Some(10_000),
Some(60_000),
None,
490_000,
false,
0,
);
}
assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16));
let mut t = 0;
let _ = latch_knee(&mut c, start, &mut t);
c.on_mode_switch();
assert!(c.decode_cap_kbps.is_none());
assert_eq!(c.ceiling_kbps, 900_000);
}
#[test]
fn ordinary_decode_bad_window_pairs_latch_the_knee_field_trace() {
// The 2026-08-03 780M field trace, numbers from the log. The knee's most common
// presentation is a standing ~26 ms decode rise — deep enough for the ordinary
// two-window backoff, below the 45 ms severe tier. Judging evidence from the deciding
// window alone read those backoffs as decode-free and RESET the knee streak each
// time; the session sawtoothed 220↔450 Mbps for its remaining minutes.
let mut c = BitrateController::new(20_000);
c.set_ceiling(657_788); // the log's probe ceiling
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
// A single heavy-loss window ends slow start (as the field session's startup hitch
// did) so the climb below is the additive one the trace shows.
let _ = c.on_window(
ticks(start, t),
0,
HEAVY_LOSS_PPM,
Some(10_000),
Some(8_000),
None,
15_000,
false,
0,
);
t += 1;
// Choke #1 (00:35:56Z): flush + 40 ms decode at ~417 Mbps — evidence, first sample.
climb_to(&mut c, start, &mut t, 417_277);
let first = c.current_kbps;
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(8_313),
Some(40_087),
None,
first,
true,
1,
)
.expect("flush choke must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
assert_eq!(c.decode_backoff_kbps, first);
c.on_ack(r1);
// Choke #2 (00:36:32Z): TWO consecutive ~26 ms decode-bad windows at ~446 Mbps — the
// ordinary two-window path, no flush, nothing severe. This is the backoff the old
// evidence gate threw away.
climb_to(&mut c, start, &mut t, 440_000);
let second = c.current_kbps;
t += 2;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(6_877),
Some(26_474),
None,
second,
false,
0
),
None,
"the first bad window must not decide"
);
t += 1;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(6_877),
Some(26_474),
None,
second,
false,
0
),
Some(((second as u64 * 7 / 10) as u32).max(FLOOR_KBPS))
);
assert_eq!(
c.decode_cap_kbps,
Some(second - second / 16),
"two decode-bad windows are knee evidence"
);
}
#[test]
fn cascade_backoffs_neither_sample_nor_erase_the_knee_reference() {
// Choke at the knee (reference set), the host acks the ×0.7 within ~100 ms, and the
// drain flushes → a second backoff fires at the REDUCED rate. That rate is one the
// decoder never choked at while keeping up — the old code overwrote the reference
// with it (and could never latch from a cascade at all: ×0.7 sits outside the ±1/8
// band by construction). A drain backoff must neither latch nor erase; the eventual
// re-climb's choke latches against the ORIGINAL sample.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
let r1 = choke(&mut c, start, &mut t).expect("knee choke must back off");
assert_eq!(c.decode_backoff_kbps, 500_000);
c.on_ack(r1);
t += 2;
let r2 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
Some(43_305),
None,
r1,
true,
1,
)
.expect("drain flush must back off");
t += 1;
assert!(
c.decode_cap_kbps.is_none(),
"a drain backoff must not latch"
);
assert_eq!(
c.decode_backoff_kbps, 500_000,
"…nor erase the knee reference"
);
c.on_ack(r2);
climb_to(&mut c, start, &mut t, 460_000);
let rate = c.current_kbps;
choke(&mut c, start, &mut t).expect("re-climb choke must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
}
#[test]
fn keyframe_storms_on_a_clean_link_latch_the_knee() {
// The Steam Deck presentation of the knee: an overdriven decoder that WEDGES instead
// of queueing — decode latency reads absent-to-flat while the client begs for
// keyframes with zero loss (the field traces: 1419 asks at ~300 Mbps, loss_ppm=0).
// The asks are the decode evidence.
let mut c = BitrateController::new(300_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
300_000,
false,
RECOVERY_KF_SEVERE,
)
.expect("keyframe storm must back off");
t += 1;
assert!(c.decode_cap_kbps.is_none());
c.on_ack(r1);
climb_to(&mut c, start, &mut t, 280_000);
let rate = c.current_kbps;
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
rate,
false,
RECOVERY_KF_SEVERE,
)
.expect("second storm must back off");
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
}
#[test]
fn keyframe_storms_with_real_loss_teach_no_knee() {
// The same storm WITH heavy loss is network-attributed (a lost reference forces
// recovery asks; loss_ppm already prices that path): it must not latch, and it must
// break the streak like any other non-decode backoff.
let mut c = BitrateController::new(300_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
let r1 = c
.on_window(
ticks(start, t),
0,
0,
Some(10_000),
None,
None,
300_000,
false,
RECOVERY_KF_SEVERE,
)
.expect("clean storm must back off");
t += 1;
assert_eq!(c.decode_backoff_kbps, 300_000);
c.on_ack(r1);
climb_to(&mut c, start, &mut t, 280_000);
t += 2;
let _ = c
.on_window(
ticks(start, t),
0,
SEVERE_LOSS_PPM,
Some(10_000),
None,
None,
c.current_kbps,
false,
RECOVERY_KF_SEVERE,
)
.expect("lossy storm must back off");
assert!(c.decode_cap_kbps.is_none());
assert_eq!(
c.decode_backoff_kbps, 0,
"a loss-attributed storm must reset the knee reference"
);
}
#[test]
fn a_mixed_streak_without_decode_attribution_is_no_knee_evidence() {
// Two bad windows, only ONE decode-flagged (OWD carried the other): the backoff is
// not decode-attributed — the reference must reset, not sample.
let mut c = BitrateController::new(500_000);
c.set_ceiling(900_000);
let start = Instant::now();
let mut t = 0;
for _ in 0..4 {
calm_window(&mut c, ticks(start, t));
t += 1;
}
t += 2;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(40_000),
Some(8_000),
None,
490_000,
false,
0
),
None,
"one OWD-bad window must not decide"
);
t += 1;
assert_eq!(
c.on_window(
ticks(start, t),
0,
0,
Some(10_000),
Some(26_000),
None,
490_000,
false,
0
),
Some(350_000)
);
assert!(c.decode_cap_kbps.is_none());
assert_eq!(
c.decode_backoff_kbps, 0,
"a mixed-attribution backoff must reset the knee reference"
);
}
#[test]
fn ack_silence_disables_the_controller() {
let mut c = BitrateController::new(20_000);
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.
@@ -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)]
+43 -15
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.
@@ -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
+64 -14
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);
@@ -513,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 },
@@ -527,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.
@@ -883,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
@@ -1024,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 {
+411 -3
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()
@@ -1769,3 +1772,408 @@ fn slice_streamed_in_flight_budget_matches_legacy() {
);
}
}
// ---------------------------------------------------------------------------
// 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);
}
}
}
+23
View File
@@ -111,6 +111,17 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01;
/// simply ignored — no behavior change in either direction.
pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
/// carries a copy of the previous frame so a single lost packet is reconstructed instead of
/// papered over with packet-loss concealment.
///
/// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
/// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
/// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04;
/// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
/// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
/// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD
@@ -132,6 +143,18 @@ pub const HOST_CAP_CURSOR: u8 = 0x08;
/// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard.
pub const HOST_CAP_PEN: u8 = 0x10;
/// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
/// datagram carries its own frame plus a copy of the previous one.
///
/// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
/// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
/// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
/// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
/// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
pub const HOST_CAP_AUDIO_RED: u8 = 0x20;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this.
+101
View File
@@ -55,6 +55,36 @@ pub struct RfiRequest {
pub last_frame: u32,
}
/// `host → client`, any time after [`Start`]: the video data plane's sealed shard payload
/// changes mid-session (design/shard-payload-reneg.md Phase 1). Sent ONLY to a client whose
/// [`Hello::max_shard_payload`] advertised per-frame geometry (0/absent = legacy — the host
/// must never send this), and never above that advertised ceiling. Asymmetric semantics:
///
/// - **Shrink** (the mid-session MTU heal): the host may re-key its packetizer at the next
/// AU boundary immediately after sending — per-frame pinning on the client makes the
/// control-vs-datagram reorder race irrelevant and a smaller shard always fits existing
/// buffers. The [`ShardPayloadAck`] is telemetry.
/// - **Grow** (jumbo): the host must not emit a single sealed datagram above the OLD size
/// until the ack arrives — the ack IS the gate, even when the client's buffers would
/// happen to fit (the rule must not erode if the buffer strategy changes later).
///
/// No `effective_frame_index`: per-frame pinning makes it redundant — every video packet
/// carries its own `shard_bytes` and the receiver follows each frame's pin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShardPayloadChanged {
/// The new sealed shard payload in bytes (even, within the client's advertised bounds).
pub shard_payload: u16,
}
/// `client → host`: answer to [`ShardPayloadChanged`] — echoes the value the client applied.
/// Only sent for an in-bounds request; an out-of-bounds one is dropped WITHOUT an ack (a
/// buggy host must not read silence-then-garbage as a granted grow). The host treats the
/// echoed value as the grant for a pending grow.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShardPayloadAck {
pub shard_payload: u16,
}
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
@@ -200,6 +230,10 @@ pub const MSG_SET_BITRATE: u8 = 0x05;
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
/// Type byte of [`RfiRequest`].
pub const MSG_RFI_REQUEST: u8 = 0x07;
/// Type byte of [`ShardPayloadChanged`].
pub const MSG_SHARD_PAYLOAD_CHANGED: u8 = 0x08;
/// Type byte of [`ShardPayloadAck`].
pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09;
/// Type byte of [`ProbeRequest`].
pub const MSG_PROBE_REQUEST: u8 = 0x20;
/// Type byte of [`ProbeResult`].
@@ -306,6 +340,46 @@ impl RfiRequest {
}
}
impl ShardPayloadChanged {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] shard_payload[5..7]
let mut b = Vec::with_capacity(7);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_SHARD_PAYLOAD_CHANGED);
b.extend_from_slice(&self.shard_payload.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<ShardPayloadChanged> {
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_CHANGED {
return Err(PunktfunkError::InvalidArg("bad ShardPayloadChanged"));
}
Ok(ShardPayloadChanged {
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
})
}
}
impl ShardPayloadAck {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] shard_payload[5..7]
let mut b = Vec::with_capacity(7);
b.extend_from_slice(CTL_MAGIC);
b.push(MSG_SHARD_PAYLOAD_ACK);
b.extend_from_slice(&self.shard_payload.to_le_bytes());
b
}
pub fn decode(b: &[u8]) -> Result<ShardPayloadAck> {
if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_ACK {
return Err(PunktfunkError::InvalidArg("bad ShardPayloadAck"));
}
Ok(ShardPayloadAck {
shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()),
})
}
}
impl LossReport {
pub fn encode(&self) -> Vec<u8> {
// magic[0..4] type[4] loss_ppm[5..9]
@@ -1146,6 +1220,33 @@ mod tests {
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
}
#[test]
fn shard_payload_messages_roundtrip() {
for shard_payload in [512u16, 1216, 1408, 8908] {
let chg = ShardPayloadChanged { shard_payload };
assert_eq!(ShardPayloadChanged::decode(&chg.encode()).unwrap(), chg);
let ack = ShardPayloadAck { shard_payload };
assert_eq!(ShardPayloadAck::decode(&ack.encode()).unwrap(), ack);
// Identical payload shape — the type byte alone must keep the pair disjoint (a
// change echoed back must never re-decode as a change).
assert!(ShardPayloadChanged::decode(&ack.encode()).is_err());
assert!(ShardPayloadAck::decode(&chg.encode()).is_err());
}
// Exact length — no trailing bytes, no truncation.
let bytes = ShardPayloadChanged { shard_payload: 512 }.encode();
assert!(ShardPayloadChanged::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
assert!(ShardPayloadChanged::decode(&bytes[..bytes.len() - 1]).is_err());
// Disjoint from the neighboring ids either side (0x07 RfiRequest / 0x20 ProbeRequest).
assert!(ShardPayloadChanged::decode(
&RfiRequest {
first_frame: 1,
last_frame: 2
}
.encode()
)
.is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {
+153
View File
@@ -42,6 +42,80 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
Some((seq, pts_ns, &b[13..]))
}
/// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
/// frame, so a single lost datagram is *reconstructed* rather than concealed.
///
/// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
///
/// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
/// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
/// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
/// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
/// mode at 10 ms, and *does* use real in-band FEC.)
///
/// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
/// client is already holding 1590 ms of de-jitter buffer — far more than the 5 ms the successor
/// takes to arrive. So the recovery happens inside slack that already exists.
///
/// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
/// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
/// which decodes to `None`.
///
/// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
/// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
/// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
/// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
///
/// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
/// per-pad audio plane.
pub const AUDIO_RED_MAGIC: u8 = 0xD2;
/// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
pub const AUDIO_RED_HEADER: usize = 1 + 4 + 8 + 2;
/// Encode a redundant audio datagram. `prev` is the immediately-preceding frame's Opus payload
/// (empty when there is none yet).
pub fn encode_audio_red_datagram(seq: u32, pts_ns: u64, opus: &[u8], prev: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(AUDIO_RED_HEADER + opus.len() + prev.len());
b.push(AUDIO_RED_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
// A frame longer than u16::MAX cannot occur (5 ms of Opus is tens of bytes; the buffer the
// encoder writes into is 4 KiB) — but truncating silently would desync the split, so clamp
// the redundancy off instead of the primary.
let primary_len = u16::try_from(opus.len()).unwrap_or(u16::MAX);
b.extend_from_slice(&primary_len.to_le_bytes());
b.extend_from_slice(opus);
if opus.len() == primary_len as usize {
b.extend_from_slice(prev);
}
b
}
/// Parse a redundant audio datagram → `(seq, pts_ns, primary, previous)`. `previous` is `None`
/// when the host had nothing to duplicate. `None` overall on bad tag/length, including a
/// `primary_len` that overruns the datagram (a truncated or hostile packet must not panic).
///
/// The tuple shape deliberately mirrors [`decode_audio_datagram`] (one extra slot for the
/// redundant copy) so the two planes read the same at every call site; a named struct here would
/// be the odd one out on this module's decode surface, and cbindgen would then have to be taught
/// to skip it.
#[allow(clippy::type_complexity)]
pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[u8]>)> {
if b.len() < AUDIO_RED_HEADER || b[0] != AUDIO_RED_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
let primary_len = u16::from_le_bytes(b[13..15].try_into().unwrap()) as usize;
let rest = &b[AUDIO_RED_HEADER..];
if primary_len > rest.len() {
return None; // truncated: the split point is outside the datagram
}
let (primary, prev) = rest.split_at(primary_len);
Some((seq, pts_ns, primary, (!prev.is_empty()).then_some(prev)))
}
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
@@ -806,6 +880,8 @@ mod tests {
#[test]
fn audio_datagram_roundtrip() {
let opus = [0x42u8; 97];
let d = encode_audio_red_datagram(7, 42, &opus, &[]);
assert_eq!(d[0], AUDIO_RED_MAGIC);
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
assert_eq!(d[0], AUDIO_MAGIC);
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
@@ -820,6 +896,83 @@ mod tests {
assert!(empty.is_empty());
}
#[test]
fn audio_red_datagram_roundtrip() {
let cur = [0x42u8; 97];
let prev = [0x37u8; 88];
let d = encode_audio_red_datagram(7, 1_000_000_123, &cur, &prev);
assert_eq!(d[0], AUDIO_RED_MAGIC);
let (seq, pts, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!((seq, pts), (7, 1_000_000_123));
assert_eq!(primary, cur);
assert_eq!(previous, Some(&prev[..]));
// No predecessor yet (first frame of a session / after a capture reopen).
let d = encode_audio_red_datagram(0, 5, &cur, &[]);
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!(primary, cur);
assert_eq!(
previous, None,
"an empty tail must decode as absent, not as a zero-length frame"
);
// Frames of equal length must still split at the right place — the length prefix is the
// only thing that can tell them apart.
let a = [1u8; 64];
let b = [2u8; 64];
let d = encode_audio_red_datagram(9, 0, &a, &b);
let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap();
assert_eq!(primary, a);
assert_eq!(previous, Some(&b[..]));
}
/// A truncated or hostile `0xD2` must be rejected, never panic — the split point comes off
/// the wire, so an over-long `primary_len` is the obvious attack on `split_at`.
#[test]
fn audio_red_datagram_rejects_bad_input() {
let d = encode_audio_red_datagram(1, 2, &[0xAAu8; 30], &[0xBBu8; 20]);
for n in 0..AUDIO_RED_HEADER {
assert!(decode_audio_red_datagram(&d[..n]).is_none(), "len {n}");
}
// primary_len larger than the datagram: must be refused, not sliced.
let mut bad = d.clone();
bad[13..15].copy_from_slice(&u16::MAX.to_le_bytes());
assert!(decode_audio_red_datagram(&bad).is_none());
// Wrong tag.
let mut wrong = d.clone();
wrong[0] = AUDIO_MAGIC;
assert!(decode_audio_red_datagram(&wrong).is_none());
}
/// The two audio planes must not alias each other or any neighbouring plane: a client
/// demultiplexes purely on the first byte.
#[test]
fn audio_red_tag_is_disjoint() {
for other in [
AUDIO_MAGIC,
RUMBLE_MAGIC,
MIC_MAGIC,
RICH_INPUT_MAGIC,
HIDOUT_MAGIC,
HDR_META_MAGIC,
HOST_TIMING_MAGIC,
CURSOR_STATE_MAGIC,
crate::input::INPUT_MAGIC,
] {
assert_ne!(AUDIO_RED_MAGIC, other);
}
let red = encode_audio_red_datagram(1, 2, &[9u8; 40], &[8u8; 40]);
assert!(
decode_audio_datagram(&red).is_none(),
"0xC9 must not accept a 0xD2"
);
let plain = encode_audio_datagram(1, 2, &[9u8; 40]);
assert!(
decode_audio_red_datagram(&plain).is_none(),
"0xD2 must not accept a 0xC9"
);
}
#[test]
fn rumble_datagram_roundtrip() {
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
@@ -47,6 +47,38 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
// MTU discovery probes up to EXACTLY the sealed size of a full IPv4 video datagram (1472)
// instead of quinn's stock 1452. Two reasons: (a) on a clean 1500-MTU path QUIC gets the
// last 20 bytes per packet; (b) the ceiling turns discovery into a video-path verdict the
// host's wire-MTU watcher reads (`punktfunk-host` `native/wire_mtu.rs`) — settled == ceiling
// proves the path carries full-size video datagrams, settled BELOW it proves it cannot (a
// VPN/overlay adapter at MTU ~1280 blackholes every video packet while all the small flows
// pass: the "connects fine, black screen forever" field shape). With the stock 1452 ceiling
// a healthy path and a constrained one are indistinguishable at the top. This is the ONLY
// behavioral change on healthy paths, and it's confined to discovery: probes are padded
// PINGs quinn already expects to lose above a constrained hop — a lost probe settles the
// search lower, exactly as it did before.
let mut mtud = quinn::MtuDiscoveryConfig::default();
// Jumbo opt-in (design/shard-payload-reneg.md Phase 2): with `PUNKTFUNK_JUMBO=1` /
// `PUNKTFUNK_WIRE_MTU` > 1500 set, discovery probes up to the sealed JUMBO datagram
// size so a settled connection can PROVE a jumbo path — the actual grow stays
// client-ack-gated (`native/wire_mtu.rs`). The ceiling is per-ENDPOINT, not
// per-connection: with the opt-in set, connections to non-jumbo peers spend a few extra
// failed probes (one PTO each) settling lower; zero cost for anyone who doesn't opt in.
// Derived with the IPv4 overhead — a v6 peer's sealed jumbo target is smaller, so the
// ceiling covers it and discovery settles at the v6 path's own budget.
let probe_ceiling = match crate::config::jumbo_wire_mtu() {
Some(mtu) => {
let shard = crate::config::jumbo_shard_payload_for(
mtu,
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
);
crate::config::sealed_datagram_bytes(shard) as u16
}
None => crate::config::video_datagram_udp_ceiling() as u16,
};
mtud.upper_bound(probe_ceiling);
t.mtu_discovery_config(Some(mtud));
Arc::new(t)
}
+119 -7
View File
@@ -90,8 +90,19 @@ pub struct Hello {
/// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after
/// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This
/// caps everything after `display_hdr` at `HDR_META_BODY_LEN 1` bytes total — document any
/// future field here and mind the budget. Omitted when zero and by older clients (→ `0`).
/// future field here and mind the budget (`client_caps` 1 + `max_shard_payload` 2 = 3 of the
/// 27 spent). Omitted when zero and by older clients (→ `0`).
pub client_caps: u8,
/// The largest video shard payload this client's receive path accepts — sealed datagrams for
/// shards up to this size fit its transport buffers ([`crate::config::max_shard_payload`]).
/// One field carries BOTH facts the host needs for mid-session shard renegotiation
/// (design/shard-payload-reneg.md W0.3): non-zero ⇒ the client reassembles per-frame
/// geometry (a mid-session `shard_payload` change is safe to send), and the value is the
/// hard ceiling a jumbo grow may never exceed. Appended after `client_caps` as 2 trailing
/// LE bytes (forcing the earlier placeholders). Omitted by older clients (decodes to `0`
/// = legacy: the host must not change the sealed geometry mid-session, and never above
/// the `Welcome` value).
pub max_shard_payload: u16,
}
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
@@ -254,12 +265,14 @@ impl Hello {
let pref_present = self.preferred_codec != 0;
let hdr_present = self.display_hdr.is_some();
let ccaps_present = self.client_caps != 0;
let msp_present = self.max_shard_payload != 0;
let need_placeholders = self.video_caps != 0
|| ac_present
|| vcodecs_present
|| pref_present
|| hdr_present
|| ccaps_present;
|| ccaps_present
|| msp_present;
match (&self.name, &self.launch) {
(None, None) if !need_placeholders => {}
(name, _) => {
@@ -280,15 +293,21 @@ impl Hello {
b.push(self.video_caps);
}
// audio_channels: emitted when non-stereo OR a later field follows.
if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present {
if ac_present
|| vcodecs_present
|| pref_present
|| hdr_present
|| ccaps_present
|| msp_present
{
b.push(self.audio_channels);
}
// video_codecs: emitted when non-zero OR a later field follows.
if vcodecs_present || pref_present || hdr_present || ccaps_present {
if vcodecs_present || pref_present || hdr_present || ccaps_present || msp_present {
b.push(self.video_codecs);
}
// preferred_codec: emitted when non-zero OR a later field follows.
if pref_present || hdr_present || ccaps_present {
if pref_present || hdr_present || ccaps_present || msp_present {
b.push(self.preferred_codec);
}
// display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if
@@ -297,10 +316,15 @@ impl Hello {
if let Some(m) = &self.display_hdr {
super::datagram::write_hdr_meta_body(m, &mut b);
}
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero.
if ccaps_present {
// client_caps: single byte after the (optional) HDR block. Emitted when non-zero OR a
// later field follows.
if ccaps_present || msp_present {
b.push(self.client_caps);
}
// max_shard_payload: 2 trailing LE bytes after client_caps. Emitted when non-zero.
if msp_present {
b.extend_from_slice(&self.max_shard_payload.to_le_bytes());
}
b
}
@@ -386,6 +410,19 @@ impl Hello {
};
b.get(off).copied().unwrap_or(0)
},
// max_shard_payload: 2 LE bytes after client_caps (same post-HDR offset rule).
// Absent on an older client → 0 = no mid-session renegotiation, no jumbo.
max_shard_payload: {
let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN
{
tail + 4 + super::datagram::HDR_META_BODY_LEN
} else {
tail + 4
};
b.get(off + 1..off + 3)
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
.unwrap_or(0)
},
})
}
}
@@ -867,6 +904,7 @@ mod tests {
preferred_codec: CODEC_H264,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = h.encode();
let dec = Hello::decode(&enc).unwrap();
@@ -944,6 +982,7 @@ mod tests {
preferred_codec: CODEC_HEVC,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
@@ -975,6 +1014,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
@@ -1093,6 +1133,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let enc = base.encode();
assert_eq!(
@@ -1145,6 +1186,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello {
@@ -1205,6 +1247,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL).
let vol = HdrMeta {
@@ -1273,6 +1316,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
@@ -1306,6 +1350,7 @@ mod tests {
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
let vol = HdrMeta {
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
@@ -1319,6 +1364,7 @@ mod tests {
// fixed block length, so the decoder must NOT read it as a truncated HdrMeta).
let caps_only = Hello {
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 0,
..base.clone()
};
assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only);
@@ -1326,6 +1372,7 @@ mod tests {
let both = Hello {
display_hdr: Some(vol),
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 0,
..base.clone()
};
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
@@ -1344,8 +1391,73 @@ mod tests {
Hello::decode(&enc[..enc.len() - 1]).unwrap(),
Hello {
client_caps: 0,
max_shard_payload: 0,
..both.clone()
}
);
}
/// `max_shard_payload` (mid-session shard renegotiation, design/shard-payload-reneg.md
/// W0.3): roundtrips, forces the earlier placeholders (deterministic offset), composes
/// with the optional HDR block, and degrades to 0 = legacy in BOTH directions.
#[test]
fn hello_max_shard_payload_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
display_hdr: None,
client_caps: 0,
max_shard_payload: 0,
};
// The advertisement alone: every earlier trailing field is emitted as a placeholder
// so the 2 LE bytes land at a deterministic offset — and the whole thing roundtrips.
let adv = Hello {
max_shard_payload: crate::config::max_shard_payload() as u16,
..base.clone()
};
assert_eq!(Hello::decode(&adv.encode()).unwrap(), adv);
// Composes with client_caps AND the fixed HDR block (the remaining-length
// disambiguation must still find both fields after it).
let vol = HdrMeta {
display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]],
white_point: [15635, 16450],
max_display_mastering_luminance: 8_000_000,
min_display_mastering_luminance: 500,
max_cll: 0,
max_fall: 400,
};
let full = Hello {
display_hdr: Some(vol),
client_caps: CLIENT_CAP_CURSOR,
max_shard_payload: 8908,
..base.clone()
};
assert_eq!(Hello::decode(&full.encode()).unwrap(), full);
// An older client (no trailing bytes at all) decodes to 0 = legacy: the host must
// not change the sealed geometry mid-session.
assert_eq!(Hello::decode(&base.encode()).unwrap().max_shard_payload, 0);
// An older HOST reading an advertising Hello never looks past the fields it knows —
// truncating the 2 trailing bytes yields the same Hello minus the advertisement.
let enc = full.encode();
assert_eq!(
Hello::decode(&enc[..enc.len() - 2]).unwrap(),
Hello {
max_shard_payload: 0,
..full.clone()
}
);
}
}
+168
View File
@@ -603,6 +603,31 @@ impl Session {
self.packetizer.set_fec_percent(pct);
}
/// Host: live-swap the wire shard payload between AUs (mid-session shard renegotiation,
/// design/shard-payload-reneg.md). Affects the next sealed AU; call only between AUs
/// (never with a `StreamedAu` in flight — see [`Packetizer::set_shard_payload`]). The new
/// value must satisfy the exact bounds `Config::validate` imposed on the negotiated one
/// (even, > 0, fits a datagram, block count fits the wire) — validated here against a
/// probe of the session config. The PROTOCOL side is the caller's contract: a current
/// client reassembles any in-bounds size per-frame, but a shrink may be sent immediately
/// while a grow must be client-acked and never exceed the client's advertised
/// `Hello::max_shard_payload` ceiling.
pub fn set_shard_payload(&mut self, shard_payload: usize) -> Result<()> {
if self.config.role != Role::Host {
return Err(PunktfunkError::InvalidArg(
"set_shard_payload called on a client session",
));
}
// Full `Config::validate` parity, zero drift: probe a copy (its key/salt copies are
// zeroized on drop) rather than re-spelling the shard clauses here.
let mut probe = self.config.clone();
probe.shard_payload = shard_payload;
probe.validate()?;
self.config.shard_payload = shard_payload;
self.packetizer.set_shard_payload(shard_payload);
Ok(())
}
/// The current FEC recovery percentage (host side).
pub fn fec_percent(&self) -> u8 {
self.packetizer.fec_percent()
@@ -1060,4 +1085,147 @@ mod wire_equivalence_tests {
"unflagged AUs must never be delivered partial"
);
}
/// The low-MTU PyroWave guarantee (design/shard-payload-reneg.md): mid-session
/// renegotiation is gated OFF for chunk-aligned sessions, so a constrained path serves
/// them through the leg-1 SESSION-START clamp instead — the learned budget (or
/// `PUNKTFUNK_WIRE_MTU`) sizes `Welcome::shard_payload`, and everything chunk-aligned
/// derives from that ONE number fixed at the handshake: the host packetizes at it, the
/// client's parse window reads it back ([`Session::shard_payload`] → the C-ABI
/// `punktfunk_connection_shard_payload` every embedder walks windows with), and partial
/// delivery zero-fills exact windows of it. Pin that consistency at the clamp shapes a
/// constrained path actually produces: the WARP/Tailscale budget (1216) and the floor
/// (512) — chunk-aligned frames deliver, lose whole windows (never splice), and the
/// window arithmetic matches the session value end to end.
#[test]
fn chunk_aligned_sessions_work_at_clamped_shard_sizes() {
use crate::packet::USER_FLAG_CHUNK_ALIGNED;
for shard in [1216usize, crate::config::MIN_SHARD_PAYLOAD] {
let mk = |role| Config {
role,
phase: ProtocolPhase::P2Punktfunk,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0, // no parity — any drop leaves a hole
max_data_per_block: 64,
},
shard_payload: shard,
max_frame_bytes: 8 * 1024 * 1024,
encrypt: true,
key: SessionKey::Aes128Gcm([7u8; 16]),
salt: [3, 1, 4, 1],
loopback_drop_period: 0,
};
let (h, c) = crate::transport::loopback_pair(3, 1);
let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap();
client.set_deliver_partial_frames(true);
// The window every embedder parses with IS the clamped session value.
assert_eq!(client.shard_payload(), shard);
assert_eq!(host.shard_payload(), shard);
let frame = pattern(8 * shard);
host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
let mut got_partial = None;
let mut completes = 0;
for i in 0..80u64 {
host.submit_frame(&pattern(shard), 2_000 + i, USER_FLAG_CHUNK_ALIGNED)
.unwrap();
loop {
match client.poll_frame() {
Ok(f) if !f.complete => got_partial = Some(f),
Ok(_) => completes += 1,
Err(PunktfunkError::NoFrame) => break,
Err(e) => panic!("shard {shard}: unexpected: {e}"),
}
}
}
let p = got_partial.expect("the lossy frame must be delivered partial");
assert_eq!(p.data.len(), frame.len(), "shard {shard}");
// Loss lands on exact `shard`-sized window boundaries: zeroed windows for the
// dropped datagrams, byte-identical survivors — nothing spliced across windows.
let mut zero_windows = 0;
for w in 0..8 {
let win = &p.data[w * shard..(w + 1) * shard];
if win.iter().all(|&b| b == 0) {
zero_windows += 1;
} else {
assert_eq!(
win,
&frame[w * shard..(w + 1) * shard],
"shard {shard}: window {w} corrupt"
);
}
}
assert!(
(1..8).contains(&zero_windows),
"shard {shard}: dropped shards zero-filled (got {zero_windows})"
);
assert!(
completes > 40,
"shard {shard}: surviving filler frames flow normally"
);
}
}
/// Mid-session shard renegotiation end to end over the SEALED loopback wire
/// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs
/// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one
/// client session must DELIVER every frame byte-identically (the vacuous-green lesson:
/// assert delivered frames, never the absence of errors).
#[test]
fn mid_session_shard_swap_delivers_frames_over_the_sealed_wire() {
let mk = |role: Role| {
let mut c = host_cfg(FecScheme::Gf16, 20, true);
c.role = role;
c.shard_payload = 1408;
c.fec.max_data_per_block = 64;
c
};
let (ht, ct) = loopback_pair(0, 0);
let mut host = Session::new(mk(Role::Host), Box::new(ht)).unwrap();
let mut client = Session::new(mk(Role::Client), Box::new(ct)).unwrap();
let phases: [(usize, &[usize]); 4] = [
(1408, &[3000, 3 * 1408]), // the negotiated default (incl. exact multiple)
(512, &[2000, 5 * 512 + 17]), // shrink — the mid-session VPN heal
(8908, &[100_000]), // grow — jumbo on a 9000-MTU LAN
(1216, &[2 * 1216 + 9]), // revert — a mis-proven jumbo hop self-corrects
];
let mut pts = 0u64;
let mut delivered = 0usize;
for (shard, lens) in phases {
host.set_shard_payload(shard).unwrap();
assert_eq!(host.shard_payload(), shard);
for &len in lens {
pts += 1_000_000;
let src = pattern(len);
host.submit_frame(&src, pts, 0).unwrap();
let f = client
.poll_frame()
.unwrap_or_else(|e| panic!("shard {shard}: frame must be DELIVERED ({e})"));
assert_eq!(
f.data, src,
"shard {shard}: {len} B frame must be byte-identical"
);
assert!(f.complete);
delivered += 1;
}
}
assert_eq!(delivered, 6, "every submitted frame must be delivered");
// The setter is host-side machinery: a client session must refuse it, and an
// invalid size (odd / oversized) must be rejected without touching the live config.
assert!(client.set_shard_payload(1408).is_err());
assert!(
host.set_shard_payload(1407).is_err(),
"odd must be rejected"
);
assert!(
host.set_shard_payload(crate::config::max_shard_payload() + 2)
.is_err(),
"oversized must be rejected"
);
assert_eq!(host.shard_payload(), 1216, "failed swaps must not stick");
}
}
+5
View File
@@ -192,6 +192,11 @@ mod wasapi_mic;
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[path = "audio/wiring_plan.rs"]
pub(crate) mod wiring_plan;
// Pure capture-loop policy, split out for the same reason `wiring_plan` is: it encodes field
// behaviour, so its tests must run on every platform's CI, not only Windows.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[path = "audio/capture_policy.rs"]
pub(crate) mod capture_policy;
mod mic_jitter;
mod mic_pump;
@@ -0,0 +1,260 @@
//! Desktop-audio capture POLICY — the parts of [`wasapi_cap`](super::wasapi_cap) that are pure
//! decisions rather than WASAPI plumbing, split out for the same reason
//! [`wiring_plan`](super::wiring_plan) is: so they compile and their unit tests RUN on every
//! platform. Both of these encode field-report behaviour, and regressing either must fail CI on
//! Linux too, not only on a Windows box.
//!
//! * [`FightDamper`] — how hard to fight another program for the default playback device.
//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken
//! endpoint from one we are damaging ourselves.
use std::time::{Duration, Instant};
/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting.
pub(crate) const FIGHT_LIMIT: u32 = 4;
pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20);
/// How long to leave the default alone once another program has proven it will take it back.
pub(crate) const FIGHT_BACKOFF: Duration = Duration::from_secs(60);
/// Damping for the default-playback tug-of-war (WP2.4).
///
/// The 2026-08-03 field log recorded seven full re-assert cycles in sixteen seconds — something on
/// that box re-set the default playback to CABLE Input every ~4 s and we snapped it back every
/// time, each round a capture teardown plus a wiring pass with `IPolicyConfig` writes. Winning that
/// argument is not possible and every round was an audible dropout, so: re-assert a few times
/// (transient churn does settle), then concede for a minute and say so once.
///
/// Time is passed IN rather than read here, which keeps the policy pure and testable.
pub(crate) struct FightDamper {
/// Re-assertions in the current window, and when the window opened.
count: u32,
window_started: Instant,
/// Set while we are deliberately not fighting.
paused_until: Option<Instant>,
/// One warning per fight burst, and one per concession.
warned_fighting: bool,
warned_giving_up: bool,
now: Instant,
}
impl FightDamper {
pub(crate) fn new(now: Instant) -> FightDamper {
FightDamper {
count: 0,
window_started: now,
paused_until: None,
warned_fighting: false,
warned_giving_up: false,
now,
}
}
/// A dud default-device change was observed at `now`.
pub(crate) fn observed_at(&mut self, now: Instant) {
self.now = now;
if now.duration_since(self.window_started) >= FIGHT_WINDOW {
self.window_started = now;
self.count = 0;
self.warned_fighting = false;
}
if self.paused_until.is_some_and(|t| now >= t) {
self.paused_until = None;
self.warned_giving_up = false;
self.count = 0;
self.window_started = now;
}
}
/// Should we put the default back? False while paused, or once this window's budget is spent.
pub(crate) fn should_reassert(&mut self) -> bool {
if self.paused_until.is_some() {
return false;
}
if self.count >= FIGHT_LIMIT {
self.paused_until = Some(self.now + FIGHT_BACKOFF);
return false;
}
self.count += 1;
true
}
/// Warn on the FIRST re-assert of a burst only (the rest are noise).
pub(crate) fn warn_now(&mut self) -> bool {
!std::mem::replace(&mut self.warned_fighting, true)
}
/// Warn once when we concede.
pub(crate) fn warn_giving_up(&mut self) -> bool {
self.paused_until.is_some() && !std::mem::replace(&mut self.warned_giving_up, true)
}
/// Currently conceding (test/diagnostic accessor).
pub(crate) fn is_paused(&self) -> bool {
self.paused_until.is_some()
}
}
/// How often the capture loop reports its vitals (WP0.2).
pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30);
/// One reporting window's worth of capture vitals.
///
/// The point is to make three states that used to look identical in a log tell themselves apart: a
/// genuinely quiet host (`peak` ~0, no drops), a working stream (`peak` > 0), and a stream we are
/// damaging ourselves (`dropped_chunks` > 0). The 2026-08-03 field log — 3,600 lines, filed over an
/// audio-quality complaint — could distinguish none of them, because the audio plane logged nothing
/// at all between "capturing" and the session ending.
#[derive(Default)]
pub(crate) struct CaptureStats {
pub(crate) frames: u64,
/// Interleaved SAMPLES seen — the RMS denominator. Deliberately separate from `frames`:
/// dividing the sum of squares by the frame count instead inflates RMS by sqrt(channels),
/// which made a sine report an RMS equal to its own peak.
pub(crate) samples: u64,
/// Loudest |sample| in the window — tells a silent endpoint from a working one.
pub(crate) peak: f32,
/// Sum of squares, for the window's RMS: a level far below peak means a badly attenuated
/// endpoint (a parked device sitting at 20 % volume costs ~14 dB before Opus ever sees it).
pub(crate) sumsq: f64,
/// Chunks the encode thread was too slow to take. Silent data loss, previously uncounted:
/// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of
/// everything after it.
pub(crate) dropped_chunks: u64,
}
impl CaptureStats {
pub(crate) fn observe(&mut self, samples: &[f32], channels: u32) {
self.frames += (samples.len() / channels.max(1) as usize) as u64;
self.samples += samples.len() as u64;
for &s in samples {
let a = s.abs();
if a > self.peak {
self.peak = a;
}
self.sumsq += (s as f64) * (s as f64);
}
}
/// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than
/// -inf so the log line stays parseable.
pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) {
let rms = (self.sumsq / (self.samples as f64).max(1.0)).sqrt();
let db = |v: f64| if v > 0.0 { 20.0 * v.log10() } else { -120.0 };
// Expected frames for the window — a shortfall means the endpoint is not delivering at
// real time (a stalling virtual device), which a peak/RMS alone cannot show.
let expected = elapsed.as_secs_f64() * sample_rate as f64;
(
db(self.peak as f64),
db(rms),
(self.frames as f64 / expected.max(1.0)) * 100.0,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Replays the 2026-08-03 field shape: a dud default change every ~2 s, forever. We must put
/// the default back a few times, then concede — and warn exactly once for each.
#[test]
fn fight_damper_concedes_instead_of_looping_forever() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
let mut reasserts = 0;
let (mut warns_fighting, mut warns_giving_up) = (0, 0);
for i in 0..8 {
d.observed_at(t0 + Duration::from_millis(i * 2_000));
if d.should_reassert() {
reasserts += 1;
if d.warn_now() {
warns_fighting += 1;
}
} else if d.warn_giving_up() {
warns_giving_up += 1;
}
}
assert_eq!(
reasserts, FIGHT_LIMIT,
"must stop after the window's budget"
);
assert_eq!(warns_fighting, 1, "one warning per burst, not one per flip");
assert_eq!(warns_giving_up, 1, "concede exactly once");
}
/// Occasional, genuinely transient churn must ALWAYS be corrected — the damper must not
/// accumulate across widely-spaced events and quietly stop doing its job.
#[test]
fn fight_damper_always_fixes_isolated_changes() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
let mut reasserts = 0;
for i in 1..=10 {
d.observed_at(t0 + FIGHT_WINDOW * i);
if d.should_reassert() {
reasserts += 1;
}
}
assert_eq!(reasserts, 10, "isolated changes must always be corrected");
}
/// After the backoff expires the damper re-arms, so a program that goes quiet and comes back
/// later is fought again rather than being conceded to for the rest of the session.
#[test]
fn fight_damper_rearms_after_the_backoff() {
let t0 = Instant::now();
let mut d = FightDamper::new(t0);
for i in 0..FIGHT_LIMIT + 2 {
d.observed_at(t0 + Duration::from_millis(i as u64 * 500));
d.should_reassert();
}
assert!(d.is_paused(), "should have conceded");
d.observed_at(t0 + FIGHT_BACKOFF + FIGHT_WINDOW * 2);
assert!(d.should_reassert(), "must re-arm once the backoff expires");
}
/// Peak/RMS must separate the states a log could not previously tell apart.
#[test]
fn capture_stats_separate_silence_from_signal() {
let mut quiet = CaptureStats::default();
quiet.observe(&[0.0; 480], 2);
let (peak, rms, _) = quiet.summary(Duration::from_secs(1), 48_000);
assert_eq!(peak, -120.0, "digital silence reports the floor, not -inf");
assert_eq!(rms, -120.0);
let mut loud = CaptureStats::default();
let tone: Vec<f32> = (0..480).map(|i| (i as f32 * 0.13).sin() * 0.5).collect();
loud.observe(&tone, 2);
assert_eq!(
loud.frames, 240,
"480 interleaved stereo samples = 240 frames"
);
let (peak, rms, _) = loud.summary(Duration::from_secs(1), 48_000);
assert!(
peak > -8.0 && peak <= 0.0,
"peak {peak} dBFS should track a 0.5 tone"
);
// A sine's RMS is its amplitude / sqrt(2) — about 3 dB below peak. Getting this equal to
// peak is exactly what a frames-vs-samples mix-up in the denominator looks like, so the
// margin is asserted rather than just the ordering.
assert!(
rms < peak - 2.0,
"RMS {rms} vs peak {peak}: a sine must sit ~3 dB below its peak"
);
}
/// The delivered-percentage is what shows an endpoint that has stopped feeding us in real
/// time — invisible in peak/RMS, and the shape a stalling virtual device makes.
#[test]
fn capture_stats_report_a_delivery_shortfall() {
let mut full = CaptureStats::default();
full.observe(&vec![0.1f32; 48_000 * 2], 2); // exactly 1 s of stereo
let (_, _, pct) = full.summary(Duration::from_secs(1), 48_000);
assert!((pct - 100.0).abs() < 1.0, "expected ~100 %, got {pct}");
let mut half = CaptureStats::default();
half.observe(&vec![0.1f32; 48_000], 2); // 0.5 s of stereo in a 1 s window
let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000);
assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}");
}
}
+60 -4
View File
@@ -674,6 +674,8 @@ fn pw_thread(
})
.register();
// Which source the negotiated format below actually describes — see the note there.
let sink_mode = sink_name.is_some();
let props = match &sink_name {
// Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps
// play into it, PipeWire mixes them, process() receives the mix. Mirrors the
@@ -710,8 +712,25 @@ fn pw_thread(
let stream = pw::stream::StreamBox::new(&core, "punktfunk-audio", props)
.context("pw audio Stream")?;
// The capture callback's state: the hand-off channel plus this plane's vitals. Before
// this it was the bare `tx`, and the desktop-audio plane logged NOTHING between "capture
// started" and the session ending — no level, no cadence, and in particular no sign of
// the silent drop below. That is exactly what made the 2026-08-03 Windows field report
// un-triageable, and the Linux half kept it after the Windows half was fixed.
struct CapUd {
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
channels: u32,
stats: crate::audio::capture_policy::CaptureStats,
last_stats: std::time::Instant,
}
let ud = CapUd {
tx,
channels,
stats: Default::default(),
last_stats: std::time::Instant::now(),
};
let _listener = stream
.add_local_listener_with_user_data(tx)
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
@@ -723,22 +742,32 @@ fn pw_thread(
}
}
})
.param_changed(|_stream, _tx, id, param| {
.param_changed(move |_stream, _tx, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// `stream_sink` says WHICH source this format describes, and that changes how
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
// format apps render into and the desktop mix cannot have been narrowed before
// we saw it. In LEGACY monitor mode we are capturing someone else's sink
// through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would
// still be reported here as a clean 48 kHz, exactly the way WASAPI's
// autoconvert hid the same thing on Windows (the 2026-08-03 report). 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.
tracing::info!(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
stream_sink = sink_mode,
"audio format negotiated"
);
}
})
.process(|stream, tx| {
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
@@ -774,7 +803,34 @@ fn pw_thread(
];
samples.push(f32::from_le_bytes(b));
}
let _ = tx.try_send(samples); // drop if the encoder is behind
ud.stats.observe(&samples, ud.channels);
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
// encode thread is not keeping up, and because the encoder simply
// concatenates across the hole every dropped chunk is a click AND a
// permanent shift of everything after it.
if ud.tx.try_send(samples).is_err() {
ud.stats.dropped_chunks += 1;
}
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
let (peak_db, rms_db, delivered_pct) =
ud.stats.summary(ud.last_stats.elapsed(), SAMPLE_RATE);
if ud.stats.dropped_chunks > 0 {
tracing::warn!(
dropped_chunks = ud.stats.dropped_chunks,
"the audio encode thread could not keep up — captured audio was \
DROPPED; the stream will click and everything after it shifts"
);
}
tracing::info!(
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
dropped_chunks = ud.stats.dropped_chunks,
"desktop audio capture"
);
ud.stats = Default::default();
ud.last_stats = std::time::Instant::now();
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire audio callback — chunk dropped");
@@ -14,8 +14,11 @@
//! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture
//! is open (`set_playback` — the mic pump must never park the playback default while the host is
//! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so
//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on
//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live;
//! audio plays on the client only; `audio.output_mode = host_and_client` (formerly
//! `PUNKTFUNK_HOST_AUDIO`) prefers real hardware instead (audible on both ends). Since 2026-08 a
//! silent sink must also be able to CARRY the mix — one that narrows it (a voice-carrier endpoint
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
//! Steam Streaming Speakers, whose loopback is silent — validated live;
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//!
@@ -33,18 +36,44 @@
//!
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
//! defaults untouched (the plan is still computed — the mic must still pick a target).
//! same call `mmsys.cpl` makes). The `audio.output_mode = follow_default` setting (formerly
//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched the plan is still
//! computed, since the mic must still pick a target.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::wiring_plan::{self, plan, Endpoint, Wiring};
use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring};
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
use std::sync::Mutex;
use wasapi::Direction;
/// A render endpoint's engine mix format, or `None` if it cannot be asked right now.
///
/// This is the number the 2026-08-03 field report needed and no log had: the capture side requests
/// 48 kHz f32 with `autoconvert`, so WASAPI converts silently from whatever the endpoint really
/// runs — and a voice-carrier endpoint (Steam's Streaming Microphone) narrowing the desktop mix to
/// mono or 24 kHz was invisible. Reading it costs one `IAudioClient` activation per endpoint, done
/// only during a wiring pass.
///
/// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan
/// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly
/// as it did before formats existed, instead of mis-demoting a perfectly good endpoint.
fn mix_format_of(ep: &Endpoint) -> Option<MixFormat> {
let fmt = open_endpoint(ep)
.ok()?
.get_iaudioclient()
.ok()?
.get_mixformat()
.ok()?;
Some(MixFormat {
rate_hz: fmt.get_samplespersec(),
channels: fmt.get_nchannels(),
bits: fmt.get_bitspersample(),
})
}
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
let mut out = Vec::new();
@@ -69,10 +98,22 @@ fn list_endpoints(dir: Direction) -> Vec<Endpoint> {
out
}
/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback
/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior).
/// The operator wants the stream audible on the host too — the loopback plan prefers real
/// hardware over the silent sink (the pre-client-only-default behavior).
///
/// Now driven by the first-class `audio.output_mode` setting
/// ([`AudioOutputMode`](pf_host_config::AudioOutputMode)), which still honours the older
/// `PUNKTFUNK_HOST_AUDIO` spelling.
pub(crate) fn host_audio_requested() -> bool {
std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some()
pf_host_config::config()
.audio_output_mode
.prefers_host_hardware()
}
/// The operator's default playback/recording devices must not be touched at all — the
/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`.
pub(crate) fn keep_default_devices() -> bool {
pf_host_config::config().audio_output_mode.keeps_default()
}
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
@@ -118,7 +159,27 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
let want = std::env::var("PUNKTFUNK_MIC_DEVICE")
.ok()
.map(|s| s.to_lowercase());
let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested());
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
// activation per render endpoint on every pass.
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
&mix_format_of
} else {
&wiring_plan::no_formats
};
let wiring = plan_with_formats(
&renders,
&captures,
want.as_deref(),
host_audio_requested(),
probe,
// The loopback is opened at the session's negotiated channel count, but the wiring pass
// runs before (and outside) any session. Stereo is the floor every session uses and the
// only count a *narrowing* verdict can be made against without guessing: an endpoint that
// cannot carry stereo cannot carry 5.1 either.
2,
);
let done = |wiring: Wiring| WiredPlan {
wiring,
fingerprint,
@@ -142,6 +203,18 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
"audio wiring plan"
);
// The quality warning the 2026-08-03 report had no way to produce. Says WHICH endpoint,
// WHY it is narrow, and the two things the operator can actually do about it.
if let (Some(why), Some((name, _))) = (&wiring.loopback_narrowing, &wiring.loopback_render)
{
tracing::warn!(
device = %name,
"the desktop-audio loopback endpoint {why} — streamed audio will sound worse \
than it does on the host. Attach or select a 48 kHz stereo output device, or \
set audio.output_mode = host_and_client (PUNKTFUNK_HOST_AUDIO=1) to prefer \
real hardware"
);
}
if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() {
// Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old
// static advice here suggested installing the Steam pair to a field box that had it
@@ -153,10 +226,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
}
}
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
if keep_default_devices() {
if changed {
tracing::info!(
"PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched"
mode = %pf_host_config::config().audio_output_mode.as_str(),
"audio output mode is follow_default — leaving the audio default devices untouched"
);
}
return done(wiring);
@@ -317,6 +391,25 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
}
}
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
/// wiring pass (WP2.4).
///
/// The capture loop uses this when something else takes the default mid-stream: in Assert mode the
/// capture is bound to the planned endpoint explicitly, so the only thing a hijacked default
/// changes is where *apps* render — one `IPolicyConfig` write fixes that, where the old path tore
/// the capture down and re-ran the whole wiring pass. Deliberately does not touch the [`PARKED`]
/// memo: the endpoint is the one we already parked, so the operator's original default is
/// unchanged and still owed back at stream end.
pub(crate) fn reassert_default_playback(id: &str) -> bool {
match set_default_endpoint(id) {
Ok(()) => true,
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "failed to re-assert the default playback device");
false
}
}
}
/// Put the operator's default playback device back after streaming — the inverse of
/// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed
/// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread
@@ -27,6 +27,7 @@
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
//! device is restored.
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque;
@@ -359,7 +360,7 @@ fn capture_once(
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some();
let keep_default = audio_control::keep_default_devices();
// Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default.
let assert_plan = mode == TargetMode::Assert && !keep_default;
let mut plan = audio_control::wire_now_full(assert_plan);
@@ -454,12 +455,25 @@ fn capture_once(
channels as usize,
Some(mask),
);
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
// WP0.1 — the endpoint's ACTUAL engine mix format, read BEFORE we initialize. Everything the
// old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI
// silently converts from whatever the endpoint really runs, so a voice-carrier endpoint
// narrowing the desktop mix to mono or 24 kHz was invisible in a 3,600-line field log. This
// line is what makes an audio-quality report triageable without a round trip.
let engine = audio_client.get_mixformat().ok();
// NB the plan's WP4.5 ("open the loopback at the MINIMUM device period, worth ~510 ms") is
// deliberately NOT done here, because its premise is wrong: in shared mode
// `IAudioClient::Initialize` cannot change the engine period at all — `hnsBufferDuration` sizes
// the buffer, and the callback still fires at the engine's fixed default period. Lowering it
// needs `IAudioClient3::InitializeSharedAudioStream`, which the `wasapi` crate does not wrap.
// Passing `min_period` here would therefore be a no-op at best and a new Initialize failure
// path at worst, on a device this tree cannot compile for, let alone test. Left as real work.
let (default_period, min_period) = audio_client.get_device_period().context("device period")?;
let stream_mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
let used_period = default_period;
audio_client
.initialize_client(&desired, &Direction::Capture, &stream_mode)
.context("initialize loopback client")?;
@@ -476,7 +490,17 @@ fn capture_once(
tracing::info!(device = %dev_name,
follow = matches!(mode, TargetMode::Follow) || keep_default,
last_resort,
// The endpoint's own format — NOT the one we asked for.
engine_hz = engine.as_ref().map(|f| f.get_samplespersec()),
engine_ch = engine.as_ref().map(|f| f.get_nchannels()),
engine_bits = engine.as_ref().map(|f| f.get_bitspersample()),
buffer_ms = used_period as f32 / 10_000.0,
min_buffer_ms = min_period as f32 / 10_000.0,
"audio loopback capturing");
if let Some(why) = &wiring.loopback_narrowing {
tracing::warn!(device = %dev_name,
"capturing an endpoint that {why} — the stream cannot sound better than this source");
}
// Watchdog seed: the default as it stands right after our open. In Assert mode the plan just
// parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge
@@ -514,6 +538,15 @@ fn capture_once(
let opened_at = Instant::now();
let mut saw_packets = false;
let mut silence_noted = false;
// WP0.2 — the audio plane's own vitals, logged periodically. Before this, a host log said
// nothing whatsoever about audio between "capturing" and the session ending: no level, no
// cadence, and in particular no sign of the SILENT, uncounted drop below, where a stalled
// encode thread loses chunks and the encoder simply concatenates across the hole (a click,
// and a permanent A/V offset, with nothing in any log).
let mut stats = CaptureStats::default();
let mut last_stats = Instant::now();
// WP2.4 — damping for the default-playback tug-of-war.
let mut fight = FightDamper::new(Instant::now());
loop {
if stop.load(Ordering::Relaxed) {
audio_client.stop_stream().ok();
@@ -556,7 +589,34 @@ fn capture_once(
for c in raw.chunks_exact(4) {
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire
stats.observe(&samples, channels);
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
// means the encode thread is not keeping up, and every dropped chunk is a click plus
// a permanent shift of everything after it.
if tx.try_send(samples).is_err() {
stats.dropped_chunks += 1;
}
}
if last_stats.elapsed() >= STATS_EVERY {
let (peak_db, rms_db, delivered_pct) = stats.summary(last_stats.elapsed(), SAMPLE_RATE);
if stats.dropped_chunks > 0 {
tracing::warn!(
device = %dev_name,
dropped_chunks = stats.dropped_chunks,
"the audio encode thread could not keep up — captured audio was DROPPED; the \
stream will click and everything after it shifts"
);
}
tracing::info!(
device = %dev_name,
peak_db = format!("{peak_db:.1}"),
rms_db = format!("{rms_db:.1}"),
delivered_pct = format!("{delivered_pct:.0}"),
dropped_chunks = stats.dropped_chunks,
"desktop audio capture"
);
last_stats = Instant::now();
stats = CaptureStats::default();
}
// Watchdog: react when the default render device CHANGES from what we last observed —
@@ -568,29 +628,68 @@ fn capture_once(
if seen_default.as_deref() != Some(nid.as_str()) {
seen_default = Some(nid.clone());
if nid != dev_id {
audio_client.stop_stream().ok();
// NB the stream is stopped per-branch below, NOT here: the WP2.4 Dud
// path deliberately keeps capturing, and stopping first would have made
// the "no teardown" fix silently useless.
if keep_default {
audio_client.stop_stream().ok();
tracing::info!(
"default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \
following it"
);
return Ok(Next::Reopen(TargetMode::Follow));
}
return Ok(match judge_default(&en, wiring, &nid) {
match judge_default(&en, wiring, &nid) {
DefaultKind::Capturable(name) => {
audio_client.stop_stream().ok();
tracing::info!(device = %name,
"operator changed the output device mid-stream — following \
it (audio now also plays on the host)");
Next::Reopen(TargetMode::Follow)
return Ok(Next::Reopen(TargetMode::Follow));
}
// WP2.4 — a DUD default does not affect what we are capturing:
// Assert mode binds the capture to the plan's endpoint EXPLICITLY,
// not to whatever the default happens to be. Only where *apps*
// render has moved. So put the default back and KEEP THE STREAM —
// the old full reopen tore the capture down for nothing, and the
// 2026-08-03 field log shows what that cost: something re-set the
// default to CABLE Input every ~4 s and each round trip was a
// teardown, a re-plan with IPolicyConfig writes, and an audible
// dropout — seven of them in sixteen seconds, one ending in a 2 s
// error backoff.
DefaultKind::Dud(name) => {
tracing::warn!(device = %name,
"default playback moved to an endpoint whose loopback cannot \
work re-asserting the audio wiring plan");
Next::Reopen(TargetMode::Assert)
if !assert_plan {
// Follow/KEEP_DEFAULT shapes still need the old behaviour:
// there the capture IS bound to the default.
audio_client.stop_stream().ok();
return Ok(Next::Reopen(TargetMode::Assert));
}
fight.observed_at(Instant::now());
if fight.should_reassert() {
audio_control::reassert_default_playback(&dev_id);
// Believe our own write: the next watchdog tick sees the
// default back on our endpoint and stays quiet.
seen_default = Some(dev_id.clone());
if fight.warn_now() {
tracing::warn!(device = %name, planned = %dev_name,
"something keeps moving the default playback to an \
endpoint whose loopback cannot work putting it \
back (the capture is unaffected)");
}
} else if fight.warn_giving_up() {
tracing::warn!(device = %name, planned = %dev_name,
backoff_s = FIGHT_BACKOFF.as_secs(),
"another program is repeatedly taking the default \
playback device backing off rather than fighting it. \
Desktop audio keeps streaming from the planned endpoint, \
but apps rendering to the other device will not be heard");
}
}
DefaultKind::Unknown => Next::Reopen(TargetMode::Assert),
});
DefaultKind::Unknown => {
audio_client.stop_stream().ok();
return Ok(Next::Reopen(TargetMode::Assert));
}
}
}
}
}
+268 -6
View File
@@ -43,6 +43,59 @@
/// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI.
pub(crate) type Endpoint = (String, String);
/// A render endpoint's ENGINE MIX FORMAT, as `IAudioClient::GetMixFormat` reports it.
///
/// This is the number the 2026-08-03 field report needed and the log did not have. The capture
/// side opens with `autoconvert: true` and asks for 48 kHz f32 in the wire layout, so WASAPI
/// silently converts whatever the endpoint really runs — and the "48 kHz f32 channels=2" we
/// logged was our REQUEST, not the source. An endpoint that mixes at 24 kHz mono therefore
/// produced a 48 kHz stereo stream that had already been through a 24 kHz mono bottleneck, with
/// nothing in any log to say so.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MixFormat {
pub rate_hz: u32,
pub channels: u16,
pub bits: u16,
}
impl MixFormat {
/// Why this endpoint would NARROW a `want`-channel desktop mix, or `None` if it carries it
/// intact. Bit depth is deliberately not a criterion: 16-bit is ~96 dB of headroom, far below
/// Opus's own noise floor, whereas a lost channel or halved bandwidth is plainly audible.
pub(crate) fn narrowing(&self, want: u8) -> Option<String> {
if self.rate_hz < 48_000 && self.channels < want as u16 {
return Some(format!(
"mixes at {} Hz and only {} channel(s)",
self.rate_hz, self.channels
));
}
if self.rate_hz < 48_000 {
return Some(format!(
"mixes at {} Hz, so the stream is band-limited to ~{} kHz before Opus sees it",
self.rate_hz,
self.rate_hz / 2000
));
}
if self.channels < want as u16 {
return Some(format!(
"mixes {} channel(s), so a {want}-channel desktop mix is downmixed and re-expanded",
self.channels
));
}
None
}
}
/// Looks up a render endpoint's mix format by endpoint id. `None` = unknown (enumeration failed,
/// or the caller has no way to ask) — treated as "assume it is fine", so a probe failure can
/// never make the plan worse than it was before formats existed.
pub(crate) type FormatProbe<'a> = &'a dyn Fn(&Endpoint) -> Option<MixFormat>;
/// A [`FormatProbe`] that knows nothing — the pre-WP2.1 behaviour.
pub(crate) fn no_formats(_: &Endpoint) -> Option<MixFormat> {
None
}
/// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture
/// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a
/// once-per-process plan goes stale.
@@ -60,6 +113,11 @@ pub(crate) struct Wiring {
/// the mic reservation. The capture side treats it as a stopgap: it warns when the silence
/// materializes and re-plans on any endpoint-set change instead of riding it out.
pub loopback_last_resort: bool,
/// Set when the chosen loopback endpoint's mix format NARROWS the desktop mix (see
/// [`MixFormat::narrowing`]) and the plan took it anyway because nothing better existed. Carries
/// the human-readable reason for the capture side to log — a quality risk the operator can act
/// on (attach a real output, or set the output mode to prefer hardware), not a failure.
pub loopback_narrowing: Option<String>,
}
impl Wiring {
@@ -137,6 +195,32 @@ pub(crate) fn plan(
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
) -> Wiring {
plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2)
}
/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the
/// session wants to carry.
///
/// **The 2026-08-03 field report is this function's reason to exist.** The default client-only
/// preference takes the "silent sink" — 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.
///
/// So a silent sink now has to EARN its preference: if its mix format narrows the mix (see
/// [`MixFormat::narrowing`]) it drops below real hardware. It is still taken when nothing better
/// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the
/// capture side can say why. An unknown format (probe failed) counts as fine, so this can never
/// make the plan worse than it was before formats existed.
pub(crate) fn plan_with_formats(
renders: &[Endpoint],
captures: &[Endpoint],
mic_want: Option<&str>,
host_audio: bool,
format_of: FormatProbe,
want_channels: u8,
) -> Wiring {
let find_render = |needle: &str| {
renders
@@ -172,10 +256,18 @@ pub(crate) fn plan(
not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln)
})
};
let silent = || {
renders
.iter()
.find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase()))
// A silent sink splits in two: one that carries the mix intact, and one that narrows it. The
// first keeps the historical preference; the second falls BELOW real hardware.
let narrowing_of = |ep: &Endpoint| format_of(ep).and_then(|f| f.narrowing(want_channels));
let silent_intact = || {
renders.iter().find(|ep| {
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_none()
})
};
let silent_narrow = || {
renders.iter().find(|ep| {
not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_some()
})
};
// LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent
// (validated live): a QUALITY risk, flagged so the capture side can warn when the silence
@@ -192,10 +284,13 @@ pub(crate) fn plan(
.iter()
.find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers"))
};
// A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the
// host is a routing choice, but it must not silently cost audio quality when a clean endpoint
// is right there.
let preferred = if host_audio {
real_hw().or_else(silent)
real_hw().or_else(silent_intact).or_else(silent_narrow)
} else {
silent().or_else(real_hw)
silent_intact().or_else(real_hw).or_else(silent_narrow)
};
let (loopback_render, loopback_last_resort) = match preferred {
Some(ep) => (Some(ep.clone()), false),
@@ -204,12 +299,16 @@ pub(crate) fn plan(
None => (None, false),
},
};
// Report narrowing for whatever we actually chose — including real hardware, which can also
// be a 24 kHz mono endpoint (a headset's hands-free profile is exactly that).
let loopback_narrowing = loopback_render.as_ref().and_then(narrowing_of);
Wiring {
mic_render,
mic_capture,
loopback_render,
loopback_last_resort,
loopback_narrowing,
}
}
@@ -550,6 +649,169 @@ mod tests {
}
}
// ---- format-aware loopback selection (WP2.1) -----------------------------------------
fn fmt(rate_hz: u32, channels: u16) -> MixFormat {
MixFormat {
rate_hz,
channels,
bits: 32,
}
}
/// Probe helper: give endpoints whose (lowercased) name contains a needle that format,
/// everything else unknown. Owns its table so call sites can pass a literal inline.
fn probe(table: Vec<(&'static str, MixFormat)>) -> impl Fn(&Endpoint) -> Option<MixFormat> {
move |ep: &Endpoint| {
let name = ep.0.to_lowercase();
table
.iter()
.find_map(|(needle, f)| name.contains(needle).then_some(*f))
}
}
/// THE 2026-08-03 field case, with formats. The reporter's exact endpoint inventory: the plan
/// took the Steam Streaming Microphone on all 31 opens while a clean AMD HD Audio endpoint sat
/// idle. Once we can see that the silent sink narrows the mix, real hardware must win.
#[test]
fn narrowing_silent_sink_loses_to_real_hardware() {
let renders = [
ep("CABLE In 16ch (VB-Audio Virtual Cable)"),
ep("Altavoces (Steam Streaming Speakers)"),
ep("Altavoces (Steam Streaming Microphone)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("1 - Odyssey G60SD (AMD High Definition Audio Device)"),
];
let captures = [
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
];
// A voice-carrier endpoint: 24 kHz mono.
let p = probe(vec![
("steam streaming microphone", fmt(24_000, 1)),
("odyssey", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &captures, None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"1 - Odyssey G60SD (AMD High Definition Audio Device)",
"a narrowing silent sink must not beat clean real hardware"
);
assert!(
w.loopback_narrowing.is_none(),
"the chosen endpoint is intact"
);
// The mic assignment is untouched by any of this.
assert_eq!(
w.mic_render.unwrap().0,
"CABLE Input (VB-Audio Virtual Cable)"
);
}
/// …but a silent sink that carries the mix intact keeps its historical preference: the
/// client-only routing default is not being abandoned, only made conditional on quality.
#[test]
fn intact_silent_sink_still_wins() {
let renders = [
ep("Speakers (Realtek HD Audio)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![
("steam streaming microphone", fmt(48_000, 2)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
}
/// Narrow audio still beats NO audio: with nothing else available the narrowing sink is taken
/// and flagged, not refused.
#[test]
fn narrowing_sink_is_taken_when_it_is_all_there_is() {
let renders = [
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Speakers (Steam Streaming Microphone)"
);
let why = w.loopback_narrowing.expect("must be flagged");
assert!(why.contains("16000"), "{why}");
}
/// Real hardware can narrow too — a headset in its hands-free profile is 16 kHz mono — and
/// must be flagged just the same. The flag is about the CHOSEN endpoint, not about which tier
/// it came from.
#[test]
fn narrowing_is_reported_for_real_hardware_too() {
let renders = [ep("Headset (Hands-Free AG Audio)")];
let p = probe(vec![("headset", fmt(16_000, 1))]);
let w = plan_with_formats(&renders, &[], None, false, &p, 2);
assert_eq!(
w.loopback_render.as_ref().unwrap().0,
"Headset (Hands-Free AG Audio)"
);
assert!(w.loopback_narrowing.is_some());
}
/// An unknown format must never make the plan WORSE than it was before formats existed: a
/// probe that answers nothing has to reproduce `plan` exactly.
#[test]
fn unknown_formats_reproduce_the_formatless_plan() {
let renders = [
ep("Speakers (Apple Audio Device)"),
ep("CABLE Input (VB-Audio Virtual Cable)"),
ep("Speakers (Steam Streaming Speakers)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")];
for host_audio in [false, true] {
let a = plan(&renders, &captures, None, host_audio);
let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2);
assert_eq!(a, b, "host_audio={host_audio}");
assert!(a.loopback_narrowing.is_none());
}
}
/// `host_audio` still prefers real hardware, and a narrowing silent sink stays last in that
/// mode too.
#[test]
fn host_audio_ordering_survives_formats() {
let renders = [
ep("Speakers (Realtek HD Audio)"),
ep("Speakers (Steam Streaming Microphone)"),
];
let p = probe(vec![
("steam streaming microphone", fmt(24_000, 1)),
("realtek", fmt(48_000, 2)),
]);
let w = plan_with_formats(&renders, &[], None, true, &p, 2);
assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)");
}
/// The narrowing test is channel-count aware: an endpoint that is fine for stereo narrows a
/// 5.1 session.
#[test]
fn narrowing_depends_on_the_session_channel_count() {
let stereo_only = fmt(48_000, 2);
assert_eq!(stereo_only.narrowing(2), None);
assert!(stereo_only.narrowing(6).is_some());
// Rate is judged independently of channels.
assert!(fmt(44_100, 8).narrowing(2).is_some());
// And an endpoint wider than the session is never "narrowing".
assert_eq!(fmt(48_000, 8).narrowing(2), None);
// Both wrong: the message must name both problems.
let both = fmt(16_000, 1).narrowing(6).unwrap();
assert!(both.contains("16000") && both.contains("channel"), "{both}");
}
/// Operator override beats the candidate order.
#[test]
fn env_override_wins() {
+41 -4
View File
@@ -26,9 +26,7 @@
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::{
mtu1500_shard_payload_for, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
};
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
use punktfunk_core::quic::{
@@ -72,6 +70,9 @@ use input::{input_thread, ClientInput};
/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate`
/// after the pairing gate.
mod handshake;
/// MTU resilience for the video data plane: `PUNKTFUNK_WIRE_MTU` override, the per-session
/// path-MTU watch on the control connection, and the per-peer learned shard-payload clamp.
mod wire_mtu;
/// The mid-stream control task (plan §W1); `serve_session` spawns `control::run` after the
/// handshake to multiplex renegotiation / speed-test control messages onto the data-plane channels.
@@ -1090,6 +1091,30 @@ async fn serve_session(
// just never fires then.
let (cursor_shape_tx, cursor_shape_rx) =
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
// Mid-session shard renegotiation (design/shard-payload-reneg.md Phase 2): the wire-MTU
// watcher decides (constrained-path shrink / ack-gated jumbo grow), the control task
// writes the `ShardPayloadChanged` and routes the acks back, and the data-plane loop
// applies `Session::set_shard_payload` between AUs (drained next to `bitrate_rx`).
// Channels are wired unconditionally (they just never fire); the DRIVER exists only for
// a client that advertised `Hello::max_shard_payload` on a non-chunk-aligned session —
// PyroWave clients parse chunk-aligned AUs in windows of the `Welcome` value pinned at
// session start (read once over the C ABI), so those sessions keep the leg-1
// next-session clamp instead of a mid-stream re-key.
let (shard_change_tx, shard_change_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
let (shard_ack_tx, shard_ack_rx) = tokio::sync::mpsc::unbounded_channel::<u16>();
let (shard_apply_tx, shard_apply_rx) = std::sync::mpsc::channel::<usize>();
let shard_reneg = (hello.max_shard_payload > 0 && codec != crate::encode::Codec::PyroWave)
.then_some(wire_mtu::ShardReneg {
client_ceiling: hello.max_shard_payload,
change_tx: shard_change_tx,
ack_rx: shard_ack_rx,
apply_tx: shard_apply_tx,
});
// The session is real: watch this connection's MTU discovery settle and turn it into a
// path verdict (WARN + learned clamp for the next session on a constrained path; clears
// a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS
// session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard.
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg);
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
// blend-capability gate — re-running it here could drift, and would re-probe).
@@ -1145,6 +1170,8 @@ async fn serve_session(
probe_result_rx,
reconfig_result_rx,
retarget_rx,
shard_change_rx,
shard_ack_tx,
cursor_shape_rx,
cursor_client_draws,
clip_enabled,
@@ -1307,9 +1334,18 @@ async fn serve_session(
let stop = stop.clone();
let cap = audio_cap.clone();
let channels = welcome.audio_channels;
// Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire
// the client was promised and the wire we actually send cannot disagree — then re-derive
// the SAME budget rung from it, so the encode tier and the redundancy decision are one
// choice made once rather than two settings that can drift apart.
let budget = handshake::audio_budget(
welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0,
welcome.bitrate_kbps,
channels,
);
std::thread::Builder::new()
.name("punktfunk1-audio".into())
.spawn(move || audio_thread(conn, stop, cap, channels))
.spawn(move || audio_thread(conn, stop, cap, channels, budget))
.map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio"))
.ok()
} else {
@@ -1578,6 +1614,7 @@ async fn serve_session(
keyframe: keyframe_rx,
rfi: rfi_rx,
bitrate_rx,
shard_rx: shard_apply_rx,
compositor,
gamescope_route,
bitrate_kbps,
+69 -17
View File
@@ -1,8 +1,13 @@
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
//! (48 kHz, 5 ms, CBR the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
//! stub, so a dev build streams video-only rather than failing to compile.
//! (48 kHz, 5 ms, constrained VBR at the configured [`AudioTier`](punktfunk_core::audio::AudioTier))
//! → `AUDIO_MAGIC` QUIC datagrams — or `AUDIO_RED_MAGIC` when the session negotiated redundancy —
//! at the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send
//! loop ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets
//! get the stub, so a dev build streams video-only rather than failing to compile.
//!
//! Two things here deliberately DIVERGE from the GameStream plane, which used to share this
//! tuning: hard CBR (its audio FEC needs fixed-size packets; this plane has no FEC, so CBR was a
//! pure quality tax) and the fixed 128 kbps stereo bitrate. See [`NativeAudioEnc::new`].
use super::*;
@@ -17,20 +22,36 @@ enum NativeAudioEnc {
#[cfg(any(target_os = "linux", target_os = "windows"))]
impl NativeAudioEnc {
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
/// Build the encoder for `channels` (2/6/8) at `tier`, RESTRICTED_LOWDELAY like the GameStream
/// path but — unlike it — in CONSTRAINED VBR.
///
/// **Why not hard CBR (WP1.2).** The layout table's comment justifies `set_vbr(false)` with
/// "constant packet size, which GameStream's audio FEC relies on" — true of the GameStream
/// plane, and irrelevant here: the native `punktfunk/1` audio plane has no FEC at all (see
/// `punktfunk_core::audio::AudioGapTracker`, which exists precisely because a lost packet has
/// nothing to rebuild it from). So this path was paying a pure quality tax for a constraint
/// that does not apply to it. Constrained VBR keeps the same average bitrate and the same
/// bounded packet size, and spends the bits where the signal needs them.
///
/// The GameStream encoder (`crate::gamestream::audio`) is deliberately NOT changed: its FEC
/// really does need fixed-size packets.
fn new(
channels: u8,
tier: punktfunk_core::audio::AudioTier,
) -> Result<NativeAudioEnc, opus::Error> {
let l = punktfunk_core::audio::layout_for(channels, false);
let bitrate = l.bitrate_for(tier);
if channels == 2 {
let mut e = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
e.set_vbr(false).ok();
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
e.set_vbr(true).ok();
e.set_vbr_constraint(true).ok();
Ok(NativeAudioEnc::Stereo(e))
} else {
let l = punktfunk_core::audio::layout_for(channels, false);
let mut e = opus::MSEncoder::new(
crate::audio::SAMPLE_RATE,
l.streams,
@@ -38,8 +59,9 @@ impl NativeAudioEnc {
l.mapping,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
e.set_vbr(false).ok();
e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
e.set_vbr(true).ok();
e.set_vbr_constraint(true).ok();
Ok(NativeAudioEnc::Surround(e))
}
}
@@ -52,8 +74,8 @@ impl NativeAudioEnc {
}
}
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, constrained VBR at the configured
/// tier) → `AUDIO_MAGIC` (or `AUDIO_RED_MAGIC`) datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
#[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -62,11 +84,16 @@ pub(super) fn audio_thread(
stop: Arc<AtomicBool>,
audio_cap: AudioCapSlot,
channels: u8,
budget: punktfunk_core::audio::AudioBudget,
) {
use crate::audio::SAMPLE_RATE;
const FRAME_MS: usize = 5;
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
let want = punktfunk_core::audio::normalize_channels(channels);
// Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see
// `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there
// and fell back to the default, so nothing here can silently downgrade someone's audio.
let (tier, redundancy) = (budget.tier, budget.redundancy);
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
@@ -92,7 +119,7 @@ pub(super) fn audio_thread(
}
}
};
let mut enc = match NativeAudioEnc::new(want) {
let mut enc = match NativeAudioEnc::new(want, tier) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
@@ -120,9 +147,16 @@ pub(super) fn audio_thread(
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
let mut opus_encode_errs: u64 = 0;
// WP3.1 — the previous frame's Opus bytes, for the redundant `0xD2` plane. Cleared whenever
// continuity breaks (a capture reopen), so we never advertise a predecessor the client's
// sequence numbering does not agree with.
let mut prev_frame: Vec<u8> = Vec::new();
if capturer.is_some() {
tracing::info!(
channels = want,
tier = tier.as_str(),
kbps = budget.kbps,
redundancy,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
);
}
@@ -138,6 +172,10 @@ pub(super) fn audio_thread(
capturer = Some(c);
last_failed = None;
acc.clear(); // drop the partial frame straddling the gap
// The next frame has no valid predecessor across the gap: sending the
// pre-gap frame as "the previous one" would hand the client audio from
// before the discontinuity to splice in.
prev_frame.clear();
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
@@ -162,11 +200,24 @@ pub(super) fn audio_thread(
let pts_ns = now_ns();
match enc.encode_float(&frame, &mut opus_buf) {
Ok(n) => {
let d =
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
let opus = &opus_buf[..n];
let d = if redundancy {
punktfunk_core::quic::encode_audio_red_datagram(
seq,
pts_ns,
opus,
&prev_frame,
)
} else {
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, opus)
};
if conn.send_datagram(d.into()).is_err() {
break 'session; // connection gone
}
if redundancy {
prev_frame.clear();
prev_frame.extend_from_slice(opus);
}
seq = seq.wrapping_add(1);
}
Err(e) => {
@@ -199,6 +250,7 @@ pub(super) fn audio_thread(
_stop: Arc<AtomicBool>,
_audio_cap: AudioCapSlot,
_channels: u8,
_budget: punktfunk_core::audio::AudioBudget,
) {
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
}
@@ -43,6 +43,11 @@ pub(super) async fn run(
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
cursor_client_draws: Arc<AtomicBool>,
clip_enabled: Arc<AtomicBool>,
@@ -56,6 +61,9 @@ pub(super) async fn run(
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
// stops firing on a perpetually-ready `None`.
let mut clip_offer_closed = false;
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
// on every healthy path.
let mut shard_change_closed = false;
let mut active = initial_mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming
// Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already
@@ -214,6 +222,16 @@ pub(super) async fn run(
if bitrate_tx.send(resolved).is_err() {
break; // data plane gone
}
} else if let Ok(ack) = punktfunk_core::quic::ShardPayloadAck::decode(&msg) {
// Mid-session shard renegotiation: the client applied (or granted) a
// geometry change. Forward to the wire-MTU watcher — for a grow this IS
// the gate that lets the packetizer go above the old size. A dropped
// send just means the watcher already ended (shrink acks are telemetry).
tracing::info!(
shard_payload = ack.shard_payload,
"client acked shard-payload change"
);
let _ = shard_ack_tx.send(ack.shard_payload);
} else if let Ok(req) = ProbeRequest::decode(&msg) {
tracing::info!(
target_kbps = req.target_kbps,
@@ -317,6 +335,19 @@ pub(super) async fn run(
break;
}
}
n = shard_change_rx.recv(), if !shard_change_closed => {
// Mid-session shard renegotiation: the wire-MTU watcher decided (shrink on a
// constrained-path verdict / ack-gated jumbo grow). Only ever fires toward a
// client that advertised `Hello::max_shard_payload` — the watcher owns that
// gate. `None` = the watcher's bounded lifetime ended (normal, NOT a session
// end): disable this branch, exactly the `clip_offer_closed` pattern — a
// closed mpsc yields `None` perpetually and would busy-spin the select.
let Some(n) = n else { shard_change_closed = true; continue };
let msg = punktfunk_core::quic::ShardPayloadChanged { shard_payload: n };
if io::write_msg(&mut ctrl_send, &msg.encode()).await.is_err() {
break;
}
}
shape = cursor_shape_rx.recv() => {
// Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap.
// Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by
+80 -1
View File
@@ -24,6 +24,63 @@ use super::*;
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
/// from it, and the session wiring reads that bit back.
/// THE single audio-plane decision for a session: the encode tier AND whether the redundant
/// `0xD2` plane is sent. The Welcome's `HOST_CAP_AUDIO_RED` bit is computed from it, and
/// `serve_session` reads that bit back to configure the audio thread — so the wire the client is
/// promised and the wire we send cannot disagree.
///
/// Capable-and-agreed for redundancy: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a
/// session with an older client keeps the plain `0xC9` wire byte-for-byte.
///
/// **Both halves are then BUDGETED against the session's video bitrate**
/// ([`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget)). Tier `High` and redundancy
/// were introduced separately, each costed as "~1 % of the video budget", and they multiply:
/// 256 kbps stereo sent twice is 512 kbps — ~10 % of a 5 Mbps session. Audio rides QUIC datagrams,
/// outside the ABR loop, so ABR can neither see that nor reclaim it. The budget is what stops a
/// constrained link silently handing a tenth of its bandwidth to audio.
///
/// The operator's `audio.quality` / `audio.redundancy` settings are the REQUEST; the budget may
/// lower them, never raise them.
///
/// NB the plan's "only while the link is actually losing packets" gate is deliberately not here:
/// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would
/// have to re-derive which plane it is on from every datagram. Deciding once, at handshake, against
/// a bitrate we already know is both cheaper and more predictable.
/// `wants_redundancy` is the caller's answer to "is `0xD2` even on the table" — at handshake that
/// is the client's cap AND the operator's setting; afterwards it is the GRANTED
/// `HOST_CAP_AUDIO_RED` bit, so the audio thread re-derives the same rung of the same ladder.
pub(super) fn audio_budget(
wants_redundancy: bool,
video_kbps: u32,
channels: u8,
) -> punktfunk_core::audio::AudioBudget {
let configured = pf_host_config::config().audio_quality.as_deref();
let requested = match configured {
None => punktfunk_core::audio::AudioTier::default(),
Some(s) => punktfunk_core::audio::AudioTier::parse(s).unwrap_or_else(|| {
// Once per process: this runs per session, and an operator with a typo in host.env
// does not need it on every connect. Never silently downgrade someone's audio.
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
value = %s,
"audio.quality (PUNKTFUNK_AUDIO_QUALITY) is not one of low/standard/high — \
using the default"
);
});
punktfunk_core::audio::AudioTier::default()
}),
};
punktfunk_core::audio::plan_audio_budget(video_kbps, channels, requested, wants_redundancy)
}
/// The operator's answer to "may this session use redundancy at all", before the budget is
/// consulted: the client must be able to decode it and the operator must not have forced it off.
pub(super) fn redundancy_offered(client_caps: u8) -> bool {
client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED != 0
&& pf_host_config::config().audio_redundancy.unwrap_or(true)
}
pub(super) fn cursor_forward(
client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>,
@@ -491,7 +548,12 @@ pub(super) async fn negotiate(
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
// Resolution order (wire_mtu.rs): `PUNKTFUNK_WIRE_MTU` operator override, then a path
// budget learned from a prior session whose QUIC MTU discovery settled below the
// video-datagram ceiling (the "VPN on the host blackholes every video packet" field
// shape — small flows pass, the stream is an endless black screen), then this family
// default. Healthy paths take the default branch and are byte-identical to before.
shard_payload: wire_mtu::negotiated_shard_payload(peer.ip()) as u16,
encrypt: true,
key,
salt,
@@ -564,6 +626,20 @@ pub(super) async fn negotiate(
punktfunk_core::quic::HOST_CAP_PEN
} else {
0
}
// Redundant desktop-audio plane (0xD2): the client asked, the operator has not forced
// it off, AND it fits the session's audio budget. Capable-and-agreed like the cursor
// bit — a client that did not ask keeps the plain 0xC9 wire byte-for-byte.
| if audio_budget(
redundancy_offered(hello.client_caps),
bitrate_kbps,
audio_channels,
)
.redundancy
{
punktfunk_core::quic::HOST_CAP_AUDIO_RED
} else {
0
},
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
@@ -658,6 +734,9 @@ pub(super) async fn negotiate(
let start =
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
bringup.mark("start");
// The wire-MTU watch (`wire_mtu::spawn_watch`) is spawned by `serve_session` after the
// control-task channels exist — it now also DRIVES the mid-session shard renegotiation
// (design/shard-payload-reneg.md), which needs the control stream's writer.
Ok::<_, anyhow::Error>((
hello,
welcome,
@@ -762,6 +762,9 @@ fn send_loop(
slice_wire: bool,
burst_cap: Option<usize>,
fec_target: Arc<AtomicU8>,
// Mid-session shard-payload re-keys from the wire-MTU watcher (validated + ack-gated
// there) — applied between AUs only (design/shard-payload-reneg.md Phase 1).
shard_rx: std::sync::mpsc::Receiver<usize>,
stats: SendStats,
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
@@ -818,6 +821,25 @@ fn send_loop(
}
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
apply_fec_target(&mut session, &fec_target);
// Mid-session shard renegotiation: apply a re-key from the wire-MTU watcher — between
// AUs only, NEVER with a streamed AU open (its shard-aligned tiling derives from the
// size it began with; same gate as the probe burst above). Drain to the newest; the
// protocol side (client advertisement, ack-gated grow) was enforced by the watcher.
if streamed.is_none() {
let mut want_shard = None;
while let Ok(s) = shard_rx.try_recv() {
want_shard = Some(s);
}
if let Some(s) = want_shard {
match session.set_shard_payload(s) {
Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"),
// Can't fire for a watcher-driven value (it validates the same bounds) —
// belt-and-suspenders for a future driver.
Err(e) => tracing::warn!(shard_payload = s, error = ?e,
"shard re-key refused by session validation"),
}
}
}
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(send_msg) => {
@@ -1171,6 +1193,11 @@ pub(super) struct SessionContext {
/// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder
/// alone is rebuilt in place at the new rate; capture + virtual output are untouched.
pub(super) bitrate_rx: std::sync::mpsc::Receiver<u32>,
/// Mid-session shard-payload changes from the wire-MTU watcher (already validated +
/// protocol-gated there; a grow arrives only after the client's ack). Applied between
/// AUs via [`Session::set_shard_payload`] — the packetizer re-keys, capture/encoder/
/// virtual output are untouched (design/shard-payload-reneg.md Phase 1).
pub(super) shard_rx: std::sync::mpsc::Receiver<usize>,
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
pub(super) compositor: crate::vdisplay::Compositor,
/// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here
@@ -1385,6 +1412,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
keyframe,
rfi,
bitrate_rx,
shard_rx,
compositor,
gamescope_route,
mut bitrate_kbps,
@@ -1771,6 +1799,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
slice_wire,
burst_cap,
fec_target,
shard_rx,
send_stats,
timing_conn,
phase_send,
@@ -0,0 +1,308 @@
//! MTU resilience for the video data plane (the "connects fine, black screen forever" field
//! shape).
//!
//! Video datagrams are sealed at a per-session `shard_payload` sized for a clean 1500-byte MTU
//! (1472-byte UDP payloads). A host whose route to the client runs through a smaller-MTU hop —
//! a VPN/overlay adapter (Tailscale/WARP/ZeroTier default to 1280) claiming the LAN route, or a
//! lowered NIC MTU — delivers every SMALL flow (QUIC control, hole punch, input, audio) while
//! 100 % of video datagrams die by fragmentation or local `WSAEMSGSIZE`: the client sits on a
//! black screen reporting `loss_ppm=0` (it can't see gaps in packets it never saw any of) and
//! the host streams into the void with every gauge green. Neither side observes the failure
//! directly — but the control connection CAN: its MTU discovery probes up to exactly the sealed
//! video-datagram size ([`video_datagram_udp_ceiling`], set in `quic/endpoint.rs`), so its
//! settled MTU is a verdict on the path.
//!
//! Three legs, none of which changes a session on a healthy path:
//! - **`PUNKTFUNK_WIRE_MTU=<bytes>`** — operator override; the shard payload is derived from
//! the given on-wire IP MTU. Wire-compatible with every deployed client:
//! `Welcome::shard_payload` is already negotiated per session (the v4/v6 split ships two
//! values today) and clients follow the negotiated value.
//! - **Watch** — a per-session task samples the control connection's discovered MTU once the
//! search has had time to finish. A connection still alive that settled BELOW the ceiling is
//! proof the path can't carry full-size video: log an actionable WARN and record the measured
//! budget for the peer.
//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded
//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases
//! the record (the learn/heal loop is self-correcting in both directions).
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, OnceLock};
use punktfunk_core::config::{
jumbo_shard_payload_for, jumbo_wire_mtu, mtu1500_shard_payload_for, sealed_datagram_bytes,
shard_payload_for_udp_budget, shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
};
/// Everything the MID-SESSION renegotiation driver needs (design/shard-payload-reneg.md
/// Phase 2) — `None` at [`spawn_watch`] makes the watcher observe-and-learn only (leg-1
/// behavior). Constructed ONLY when the client's `Hello::max_shard_payload` advertised
/// per-frame geometry AND the session's wire is not chunk-aligned: a PyroWave client parses
/// chunk-aligned AUs in windows of the `Welcome` value pinned at session start (Apple
/// `Stage2Pipeline` / `pf-client-core` video.rs read it once over the C ABI), so re-keying
/// such a session mid-stream would corrupt its parse — those sessions keep the leg-1
/// next-session clamp instead.
pub(super) struct ShardReneg {
/// The client's advertised receive ceiling (bytes of shard; > 0 by construction).
pub client_ceiling: u16,
/// → control task (the control stream's sole writer): send `ShardPayloadChanged{n}`.
pub change_tx: tokio::sync::mpsc::UnboundedSender<u16>,
/// ← control task: the client's `ShardPayloadAck`s (the grow gate).
pub ack_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
/// → data plane: apply [`Session::set_shard_payload`] between AUs
/// (drained next to `bitrate_rx` in the encode loop).
pub apply_tx: std::sync::mpsc::Sender<usize>,
}
/// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU
/// discovery settled below the video-datagram ceiling. In-memory only: a host restart
/// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower
/// re-measure overwrites).
fn learned() -> &'static Mutex<HashMap<IpAddr, u16>> {
static LEARNED: OnceLock<Mutex<HashMap<IpAddr, u16>>> = OnceLock::new();
LEARNED.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the
/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever
/// the result differs from the default.
pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize {
let env = match std::env::var("PUNKTFUNK_WIRE_MTU") {
Ok(v) => match v.trim().parse::<usize>() {
Ok(mtu) => Some(mtu),
Err(_) => {
tracing::warn!(value = %v, "PUNKTFUNK_WIRE_MTU is not a number — ignoring it");
None
}
},
Err(_) => None,
};
let learned_budget = learned().lock().unwrap().get(&peer).copied();
resolve(env, learned_budget, peer)
}
/// Pure resolution (env override > learned budget > family default) — the tested core of
/// [`negotiated_shard_payload`].
fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: IpAddr) -> usize {
let default = mtu1500_shard_payload_for(peer);
if let Some(mtu) = env_wire_mtu {
let p = shard_payload_for_wire_mtu(mtu, peer);
if p != default {
tracing::info!(
wire_mtu = mtu,
shard_payload = p,
default,
"wire MTU: shard payload set from PUNKTFUNK_WIRE_MTU"
);
}
return p;
}
if let Some(budget) = learned_udp_budget {
let p = shard_payload_for_udp_budget(budget as usize, peer);
if p != default {
tracing::info!(
peer = %peer,
udp_budget = budget,
shard_payload = p,
default,
"wire MTU: shard payload clamped to this peer's measured path MTU (learned \
from a prior session's QUIC MTU discovery) video datagrams now fit the \
constrained hop"
);
return p;
}
}
default
}
/// Sample the control connection's discovered MTU after the search has settled and turn it
/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION
/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at
/// the ~310 s mark (session 1 heals instead of staying black), and a settled-at-jumbo
/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated
/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime,
/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard
/// until the connection closes.
pub(super) fn spawn_watch(
conn: quinn::Connection,
session_shard_payload: usize,
reneg: Option<ShardReneg>,
) {
tokio::spawn(async move {
let peer = conn.remote_address().ip();
let ceiling = video_datagram_udp_ceiling() as u16;
// Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but
// needs a loss timeout per failed probe on a constrained path — the second sample
// covers that with margin. Max, because discovery only ever raises `current_mtu`
// (the post-grow revert guard below re-reads it live, where blackhole detection CAN
// lower it again).
let mut settled = 0u16;
for wait_s in [3u64, 7] {
tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await;
settled = settled.max(conn.stats().path.current_mtu);
if settled >= ceiling {
break;
}
}
// The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow.
let mut current = session_shard_payload;
let mut reneg = reneg;
if settled >= ceiling {
// The path carries full-size video datagrams — erase any stale learned clamp so
// the next session returns to the default wire.
if learned().lock().unwrap().remove(&peer).is_some() {
tracing::info!(peer = %peer,
"wire MTU: path re-measured at full size — learned clamp cleared");
}
} else {
// A closed connection stops discovering, so a session that ended before the final
// sample proves nothing (a healthy high-RTT path could still be mid-search): learn
// only from a connection that stayed alive through the whole window.
if conn.close_reason().is_some() {
return;
}
learned().lock().unwrap().insert(peer, settled);
if sealed_datagram_bytes(current) <= settled as usize {
// This session was already clamped small enough — the path is still constrained
// (keep the record fresh) but video fits, so no alarm.
tracing::info!(peer = %peer, discovered_udp_mtu = settled,
"wire MTU: constrained path re-measured; this session's video is sized to fit");
} else {
tracing::warn!(
peer = %peer,
discovered_udp_mtu = settled,
needed_udp_mtu = ceiling,
"wire MTU: this path CANNOT carry full-size video datagrams — the control \
plane works but every video packet is oversized for a hop, which streams as \
an endless black screen with zero reported loss. Typical cause: a VPN/overlay \
adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \
lowered NIC MTU compare `ping <client> -f -l 1450` vs `-l 1200` and check \
`netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \
measured budget is recorded: the NEXT session from this client sizes video to \
fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU."
);
// Phase 2 down-leg: heal THIS session at the verdict mark. Shrink is sent
// and applied immediately — per-frame pinning on the client makes ordering
// irrelevant and smaller always fits; the ack is telemetry. The learned
// record above still makes session 2 START right.
if let Some(r) = reneg.as_ref() {
let target = shard_payload_for_udp_budget(settled as usize, peer);
if target < current
&& r.change_tx.send(target as u16).is_ok()
&& r.apply_tx.send(target).is_ok()
{
tracing::info!(
peer = %peer,
shard_payload = target,
was = current,
"wire MTU: video re-keyed mid-session to fit the constrained path \
the stream heals now instead of on the next connect"
);
current = target;
}
}
}
}
// Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU
// > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach
// here), client-advertised headroom, and a settled-at-jumbo proof. The grow is
// ACK-GATED: not one sealed datagram above the old size leaves before the client's
// ack, even though its buffers are statically sized — the rule must not erode.
let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else {
return;
};
let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize);
let target = target - target % 2;
if target <= current || (settled as usize) < sealed_datagram_bytes(target) {
return;
}
if r.change_tx.send(target as u16).is_err() {
return;
}
let acked = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while let Some(v) = r.ack_rx.recv().await {
if v as usize == target {
return true;
}
}
false
})
.await
.unwrap_or(false);
if !acked {
tracing::warn!(peer = %peer, shard_payload = target,
"wire MTU: jumbo grow not acked — staying at the current wire");
return;
}
if r.apply_tx.send(target).is_err() {
return;
}
tracing::info!(
peer = %peer,
shard_payload = target,
was = current,
wire_mtu = mtu,
"wire MTU: jumbo grow acked and applied — packets-per-frame cut ~6×"
);
current = target;
// Revert guard: a mis-proven jumbo hop must self-correct instead of blackholing.
// quinn's PMTU blackhole detection lowers `current_mtu` when the big packets start
// vanishing; sample it and shrink back through the same path the down-leg uses.
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if conn.close_reason().is_some() {
return;
}
let mtu_now = conn.stats().path.current_mtu;
if (mtu_now as usize) < sealed_datagram_bytes(current) {
let back = shard_payload_for_udp_budget(mtu_now as usize, peer);
tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now,
shard_payload = back, was = current,
"wire MTU: jumbo path stopped fitting — reverting the wire to match");
if r.change_tx.send(back as u16).is_err() || r.apply_tx.send(back).is_err() {
return;
}
current = back;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2));
const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
#[test]
fn default_when_nothing_known() {
assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6));
}
#[test]
fn env_override_beats_learned() {
// 1280 wire 28 IP/UDP 64 header/crypto = 1188.
assert_eq!(resolve(Some(1280), Some(1472), V4), 1188);
}
#[test]
fn learned_budget_clamps() {
// A WARP-shaped path: 1280-byte UDP budget → 1280 64 = 1216.
assert_eq!(resolve(None, Some(1280), V4), 1216);
}
#[test]
fn learned_at_or_above_ceiling_is_the_default_wire() {
assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4));
}
#[test]
fn env_full_mtu_is_the_default_wire_both_families() {
assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4));
assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6));
}
}
+10 -8
View File
@@ -35,7 +35,7 @@ track per machine; switching is a one-line change.
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page |
| **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` |
| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page |
| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page |
| **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page |
The apt distribution and the rpm group are just path segments in the URL — switching tracks is a
@@ -120,14 +120,16 @@ major bump, or a patch), just tag it — the canary base re-derives from whateve
Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a
strictly-numeric version is required — MSIX, the App Store marketing version).
### App-store promotion (manual, after the tag)
### App-store publication (after the tag)
CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores:
- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect
(submit for review). The notarized `.dmg` on the release page is the direct-download path.
- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in
the Play Console when ready.
- **Android** — a `vX.Y.Z` tag publishes straight to Google Play **production** at 100%, with no
further click. Canary `main` builds go to Play **Internal testing**. To ramp a release gradually
instead of shipping it to everyone at once — or to halt or roll one back — use the Play Console,
or `android-promote.yml`, which moves a versionCode already on Play between tracks without
rebuilding.
- **Apple** — still manual. The build lands in **TestFlight**; promote it to the App Store from App
Store Connect (submit for review). The notarized `.dmg` on the release page is the
direct-download path.
## Why two tracks (the version-shadow trap)
+4 -5
View File
@@ -100,11 +100,10 @@ capture state, and the switch that turns this off is *DualSense / DualShock pass
Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and
the lightbar need the USB connection.
The app is on Google Play as a **test track** (closed testing for stable, internal testing for
canary) — request a tester invite on our [**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add
you, or sideload the public APK instead (see
[Install a Client](/docs/install-client#android)). Then open the app, pick your host,
[pair](/docs/pairing) once, and stream.
The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a
public listing — no invite — or you can sideload the public APK instead (see
[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play
Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream.
## Windows desktop client
+11 -11
View File
@@ -25,7 +25,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to
| **Windows** | [Signed MSIX](#windows) from the package registry |
| **macOS** | [Notarized `.dmg`](#macos) from the releases page |
| **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) |
| **Android / Android TV** | [Beta — a Play test track, or sideload the APK](#android) |
| **Android / Android TV** | [Google Play](#android), or sideload the APK |
| **LG webOS TV** | [Community client](#lg-webos-tv-community) (sideloaded `.ipk`) |
| Anything else (browser, old phone, TV) | [Moonlight](/docs/moonlight) |
@@ -162,19 +162,15 @@ Open the app, and your hosts appear automatically under *On this network*.
## Android
The Android client (phone + Android TV) is on Google Play as a **test track** — **closed testing**
for stable releases, **internal testing** for canary builds. To join, request a tester invite on our
[**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add your Google account:
**[Request access on Discord →](https://discord.gg/kaPNvzMuGU)**
Once you're added, install it from Google Play, then open the app and pick your host:
The Android client (phone + Android TV — one package, the TV layout is the same app in leanback
mode) is published on **Google Play**. It's a public listing: no invite, no tester list.
**[Get Punktfunk on Google Play →](https://play.google.com/store/apps/details?id=io.unom.punktfunk)**
_(only resolves once your account is on the tester list)_
**Prefer not to wait for an invite?** The signed APK is published publicly on every build, so you can
sideload it instead — no account, no invite:
Install it, open the app, and pick your host.
**Prefer not to go through Play?** The signed APK is published publicly on every build, so you can
sideload it instead — no Play account needed:
```text
https://git.unom.io/api/packages/unom/generic/punktfunk-android/latest/punktfunk-android.apk
@@ -184,6 +180,10 @@ Swap `latest` for `canary` to track `main`. Release APKs are also attached to ea
[release](https://git.unom.io/unom/punktfunk/releases). Android asks you to allow installs from your
browser or file manager the first time.
**Canary on Play** is a separate **Internal testing** track, and that one *is* invite-only — ask on
[Discord](https://discord.gg/kaPNvzMuGU) and we'll add your Google account. The `canary` APK above
needs no invite.
## LG webOS TV (community)
> **Community project.** [`pf-webos`](https://github.com/dyptan-io/pf-webos) is built and maintained
+1 -1
View File
@@ -513,7 +513,7 @@ capability.
| **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. |
| **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. |
| **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). |
| **Android client** (phone · TV) | Distributed on Play's **closed (alpha)** track for releases, Internal testing for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. |
| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. |
| **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. |
| **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). |
+67
View File
@@ -302,6 +302,73 @@ told your client so. [When the client and the host
disagree](/docs/client-settings#when-the-client-and-the-host-disagree) lists what it does with each
one.
## Streamed audio sounds worse than the host does
The host does not capture "the sound card" — it captures a **render endpoint**, and by default it
picks one that is *silent on the host* so the audio plays on your client only. On a PC with Steam
installed that silent endpoint is Steam's **Streaming Microphone**, which exists to carry remote
*voice*. If Windows has it configured as a narrow device — mono, or below 48 kHz — then the whole
desktop mix is squeezed through that before it is ever encoded, and no amount of bitrate will bring
it back.
Since 0.25 the host checks for this: it reads each candidate endpoint's real format, prefers a real
output device over a narrow virtual one, and says so in the log —
```
WARN the desktop-audio loopback endpoint mixes at 24000 Hz, so the stream is band-limited …
INFO audio loopback capturing device="…" engine_hz=48000 engine_ch=2 engine_bits=32
```
That `engine_*` line is the endpoint's **own** format, so it tells you directly whether the source
was ever full quality. To choose the routing yourself, set in `host.env`:
```ini
# client_only — default; audio plays on the client only (a silent endpoint)
# host_and_client — capture a real output device; audio plays on BOTH ends
# follow_default — capture whatever YOUR default playback device is, and never change it
PUNKTFUNK_AUDIO_OUTPUT_MODE=host_and_client
```
`host_and_client` is also the quickest way to A/B the problem: if the stream sounds right that way
and wrong on the default, the endpoint was the cause.
Two related knobs:
```ini
PUNKTFUNK_AUDIO_QUALITY=high # low | standard | high (default high — stereo 256 kbps)
PUNKTFUNK_AUDIO_REDUNDANCY=1 # force the loss-resilient audio plane on (default: automatic)
```
Both are a **request**, not a guarantee: the host budgets audio against the session's video
bitrate and steps it down on a narrow link, because audio is not managed by adaptive bitrate — so
whatever it takes is taken off the top. On a roomy link you get 256 kbps plus loss redundancy; as
the link narrows the host drops redundancy first, then the tier, and never goes below ~96 kbps. The
session log line says what it settled on:
```
INFO punktfunk/1 audio streaming … tier=high kbps=512 redundancy=true
```
`standard` reproduces the pre-0.25 encoder exactly if you want to A/B it.
## Audio lags behind the picture
The client buffers a little audio to absorb network jitter. Since 0.25 that buffer **corrects
itself**: if it drifts deeper — a Wi-Fi burst, a stall, or just the two devices' clocks running at
fractionally different speeds — it trims itself back a few milliseconds at a time, inaudibly.
Before, it could only grow, so a single hiccup left audio permanently behind the video and the only
cure was reconnecting.
If audio is still noticeably late:
- **Reconnect once.** It confirms whether the delay was accumulated (gone after a reconnect) or
constant (something else).
- **Check for underruns** rather than guessing. The client logs its buffer depth periodically; a
rising `underruns` count means the buffer is being starved, which is a network or CPU problem, not
a buffering one.
- **Wired or 5 GHz Wi-Fi.** Arrival jitter is what the buffer exists to absorb; less jitter lets it
run shallower.
## Windows: the host or the web console won't start
The **`PunktfunkHost` service** runs both halves of the Windows host: the streaming host itself and
+3 -2
View File
@@ -300,8 +300,9 @@ stop testing — that removes the app and its data with it.
### Android / Android TV
Uninstall the app from Google Play or from Settings → Apps. The Android client is still an invited
test track, so if you also want your account taken off the tester list, say so on
Uninstall the app from Google Play or from Settings → Apps. That's the whole job — it's a public
Play listing, so there's no tester list to leave. If you were on the invite-only **canary**
(Internal testing) track and want off that too, say so on
[Discord](https://discord.gg/kaPNvzMuGU).
### Steam Deck — Decky plugin
+112 -1
View File
@@ -312,6 +312,13 @@
// `PunktfunkStatus` code).
#define PUNKTFUNK_CLIP_ERROR 6
// The protocol's audio frame, in milliseconds — every host datagram carries exactly one
// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit.
#define PUNKTFUNK_AUDIO_FRAME_MS 5
// Sample rate of every audio plane in the protocol.
#define PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ 48000
#if defined(PUNKTFUNK_FEATURE_QUIC)
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
@@ -333,6 +340,12 @@
#define INBOUND_REQ_FLAG 2147483648
#endif
// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP
// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a
// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers
// bottom out here instead of producing degenerate confetti-sized shards.
#define MIN_SHARD_PAYLOAD 512
// 16-byte AEAD authentication tag appended by either session cipher.
#define TAG_LEN 16
@@ -475,7 +488,17 @@
// Largest UDP datagram the core will send or accept. `Config::validate` bounds
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
#define MAX_DATAGRAM_BYTES 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.
#define MAX_DATAGRAM_BYTES 9216
// The slice-flush floor: a sentinel block below this many data shards costs disproportionate
// per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush
@@ -627,6 +650,19 @@
#define CLIENT_CAP_PHASE_LOCK 2
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also
// carries a copy of the previous frame so a single lost packet is reconstructed instead of
// papered over with packet-loss concealment.
//
// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the
// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is
// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit.
// `0x04` — `0x01`/`0x02` are cursor / phase-lock.
#define CLIENT_CAP_AUDIO_RED 4
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor
// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope,
@@ -652,6 +688,20 @@
#define HOST_CAP_PEN 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane
// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each
// datagram carries its own frame plus a copy of the previous one.
//
// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE,
// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and
// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is
// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags
// unconditionally and treat this bit as "expect redundancy", not "only redundancy".
// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`].
#define HOST_CAP_AUDIO_RED 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
@@ -776,6 +826,16 @@
#define MSG_RFI_REQUEST 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadChanged`].
#define MSG_SHARD_PAYLOAD_CHANGED 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ShardPayloadAck`].
#define MSG_SHARD_PAYLOAD_ACK 9
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`ProbeRequest`].
#define MSG_PROBE_REQUEST 32
@@ -967,6 +1027,41 @@
#define HIDOUT_MAGIC 205
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS
// frame, so a single lost datagram is *reconstructed* rather than concealed.
//
// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]`
//
// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder
// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so
// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane —
// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP
// mode at 10 ms, and *does* use real in-band FEC.)
//
// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the
// client is already holding 1590 ms of de-jitter buffer — far more than the 5 ms the successor
// takes to arrive. So the recovery happens inside slack that already exists.
//
// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet
// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail,
// which decodes to `None`.
//
// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED)
// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the
// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps
// the plain [`AUDIO_MAGIC`] wire byte-for-byte.
//
// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the
// per-pad audio plane.
#define PUNKTFUNK_AUDIO_RED_MAGIC 210
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length).
#define PUNKTFUNK_AUDIO_RED_HEADER (((1 + 4) + 8) + 2)
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v1 (legacy, level) rumble datagram.
#define RUMBLE_V1_LEN 7
@@ -1397,6 +1492,14 @@ typedef uint8_t PunktfunkInputKind;
typedef struct ColorInfo ColorInfo;
#endif
// Tuning for [`JitterPolicy`], in MILLISECONDS.
//
// Denominating the depth in time rather than in device quanta is the point. Every client used to
// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms
// at a 20 ms one — the same source line meaning two very different latencies depending on what
// else happened to be using the audio graph that day.
typedef struct JitterTuning JitterTuning;
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Opaque handle to a live `punktfunk/1` connection (QUIC control plane + UDP data plane, all
// pumped on internal threads).
@@ -1788,6 +1891,14 @@ typedef struct {
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
// users reason about. Shared so every client's list stays identical.
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }