Commit Graph
2376 Commits
Author SHA1 Message Date
enricobuehler 2d43275fcb fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m15s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m57s
ci / rust (pull_request) Successful in 6m25s
ci / rust-arm64 (pull_request) Failing after 15s
ci / web (pull_request) Successful in 1m6s
BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION,
INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed.

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

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

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

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

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

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

The C ABI history had a hole and a symbol nobody versioned. v11 shipped without
its line, and the rumble policy engine's C surface was added while the version
constant still read 7, with no bump at all — so every core since has exported
those symbols while advertising a number that never promised them. A shipped
binary says what it says, so that cannot be corrected backwards; v15 instead
establishes the floor that guarantees the surface, and the v11 line is written
down. No code changed for the bump and nothing moved on the wire.
2026-08-04 20:52:44 +02:00
enricobuehler 454fa2e0cb Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42
2026-08-04 18:12:40 +00:00
enricobuehler 9f1f23eb40 Merge pull request 'feat(wire): mid-session shard-payload renegotiation — the black screen heals in seconds, jumbo behind an opt-in' (#41) from worktree-shard-payload-reneg into main
release / apple (push) Canceled after 2m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 16s
windows-host / package (push) Successful in 11m13s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 1m26s
android / android (push) Canceled after 3m42s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 3m45s
ci / rust (push) Canceled after 3m28s
ci / rust-arm64 (push) Canceled after 1m59s
ci / web (push) Canceled after 1m54s
ci / docs-site (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
Reviewed-on: #41
2026-08-04 18:06:41 +00:00
enricobuehler d1c4cb18dd test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m44s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 2m3s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m51s
ci / rust (pull_request) Successful in 8m33s
android / android (pull_request) Successful in 5m21s
PyroWave sessions are gated out of mid-session renegotiation, so a
constrained path serves them through the leg-1 SESSION-START clamp. This
pins the consistency that guarantee rests on: everything chunk-aligned
derives from the one Welcome::shard_payload number — the host
packetizes at it, the client's C-ABI parse window reads it back, and
partial delivery zero-fills exact windows of it — verified at the two
clamp shapes a constrained path actually produces (1216, the
WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire
with real loss.
2026-08-04 20:00:09 +02:00
enricobuehler 91aa684f0d Merge pull request 'docs: Android is on Google Play production, not a closed test track' (#40) from worktree-docs-play-production into main
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / deploy-docs (push) Successful in 37s
ci / web (push) Failing after 29s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 10m40s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
ci / rust-arm64 (push) Successful in 3m10s
docker / builders-arm64cross (push) Successful in 16s
Reviewed-on: #40
2026-08-04 17:53:23 +00:00
enricobuehler e629606e39 docs: Android is on Google Play production, not a closed test track
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / web (pull_request) Successful in 1m2s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 6m48s
Play production access landed 2026-08-01 and the listing is live, but the
docs still told Android users to beg for a tester invite on Discord and
warned that the Play link "only resolves once your account is on the
tester list". Both are now wrong, and the install page is the first thing
a new Android user reads.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in
punktfunk-planning design/haptics-sweep-2026-08-03.md).
2026-08-03 16:57:11 +02:00