Compare commits

...
Author SHA1 Message Date
enricobuehler 7b1554af4b fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit
ci / rust-arm64 (pull_request) Successful in 1m41s
ci / web (pull_request) Successful in 1m42s
ci / docs-site (pull_request) Successful in 2m34s
ci / rust (pull_request) Successful in 7m6s
apple / swift (pull_request) Successful in 1m24s
apple / screenshots (pull_request) Skipped
- Six mock hosts rather than three. An iPad-13 portrait grid is three columns
  wide and 2752 px tall; three cards left ~60% of the capture as black.
- Settings opens on Display, not General. Resolution, frame rate, bitrate,
  HDR and codec are what someone reads a streaming app's settings shot for.
- The wake scene is the modal-over-grid variant. The gamepad-UI one is a
  full-screen takeover over a bare gradient — correct, but four lines of text
  on an empty aurora; the modal shows the same overlay over the host grid.
- `requestGeometryUpdate` now reports a refusal instead of failing silently.
  It does not help on the simulator (an app's stdout doesn't reach the driver
  through `simctl launch`) but it will on macOS and on a device.
- Documented that `.landscape` does not rotate on iPad: a multitasking-capable
  iPad app is resizable, so iPadOS ignores the request and simctl cannot
  rotate a simulated device. The iPad set is portrait throughout.
2026-08-04 21:34:18 +02:00
enricobuehler 8f35155c14 fix(apple/shots): the store screenshots show the app as it actually is
Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a
hero that was never the orientation it claimed.

The capture harness:

- Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator`
  asked for the geometry update from `updateUIViewController`, where
  `view.window` is still nil — SwiftUI makes one update pass for a
  `.background` representable, before the hierarchy is in a window, so the
  guard fell through and nothing ever asked again. Both `.landscape` scenes
  (the stream hero, the trust card) shipped as portrait. Now a real
  UIViewController asks from `viewDidAppear` and pins
  `supportedInterfaceOrientations`.

- The shot host applied `.ignoresSafeArea()` to the whole scene, so the
  hero's HUD — resolution, bitrate, the latency breakdown, the entire point
  of that screenshot — sat under the Dynamic Island. Only the black backing
  ignores it now; scenes that want full bleed already ignore it themselves.

- `03-pair` was hand-composed into a ZStack rather than presented. PairSheet
  is a bottom sheet on iOS: its detents and the system's Liquid Glass only
  exist inside a real `.sheet`. Composed, the grouped Form stretched to full
  screen height and the capture was a strip of content over a black void,
  with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's
  own name — `pf-shot-iphone-6.9` — rendered in as the device name.

- Sheets do not inherit `.environment(\.colorScheme, .dark)` across the
  presentation boundary; they follow the DEVICE. The pairing sheet came out
  light grey over the dark app. The simulator is now set to dark appearance.

- Discovery browsed the live LAN mid-capture, so a bystanding machine's
  hostname went out on the listing and no two runs matched. `HostDiscovery`
  gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the
  mock hosts advertise, so cards read ONLINE through the real `advertises`
  path and the reachability probe never touches the network.

- Created simulators were named `pf-shot-<prefix>`, which the reuse regex
  never matches: every run created another simulator and none was reused.
  They are named after the device now — reusable, and not user-visible junk.

Two bugs found on the way, neither screenshot-only:

- HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac
  that is the same App-Group suite the real app reads, so running the
  script could replace the tester's saved hosts with "Battlestation" & co.
- GamepadHomeView drew the controller chip as a trailing `.overlay`, which
  reserves no width — on a portrait phone it sat on top of the centred
  "Select a Host". Laid out as a row with a hidden leading mirror.
- The pairing sheet's field prompt said "How the host lists this Mac" on
  iPhone and iPad.

Coverage: the listing set is six scenes in listing order, and is now the
stream, the machines it found, the couch/controller mode, waking a sleeping
host, the quality controls and pairing — the console and wake screens
already existed in `ShotScenes.all` and were simply never captured. Mock
hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is
full rather than three offline rows over an empty half-screen. `SCENES=`
overrides the set for the dev scenes.
2026-08-04 21:30:18 +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 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 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
49 changed files with 3451 additions and 396 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,
)
}
}
+5 -1
View File
@@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
host's virtual pad.
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details.
every main push. See the script header for details. The script's `SCENES` array is the listing
set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`)
to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is
seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN
(a stranger's hostname reached the live listing that way once).
- Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
@@ -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) }
}
@@ -176,18 +176,33 @@ struct GamepadHomeView: View {
// MARK: - Chrome
private var titleBar: some View {
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
// Which pad is driving this UI (name + battery) quiet, and only where there's
// room; a compact-height phone gives the pixels to the carousel instead.
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.padding(.trailing, 20)
}
}
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
private var cardSpacing: CGFloat {
@@ -24,6 +24,13 @@ import ImageIO
@MainActor
enum ScreenshotMode {
/// This process was launched to capture a screenshot. Cheap enough to consult from the
/// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their
/// mock contents back into a real user's App Group when the harness runs on a dev Mac.
static var isActive: Bool {
!(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty
}
/// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch.
static var requestedScene: ShotScene? {
let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? ""
@@ -41,8 +48,11 @@ struct ScreenshotHostView: View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
// line was unreadable in every 6.9" capture); scenes that genuinely want full bleed
// the streamed frame itself ignore it themselves.
.background(Color.black.ignoresSafeArea())
#if os(macOS)
.background(MacShotWindowConfigurator(scene: scene))
#elseif os(iOS)
@@ -129,18 +139,64 @@ enum MacSelfCapture {
#endif
#if os(iOS)
/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait
/// for chrome). Requires the app to allow those orientations in Info.plist.
/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome).
/// Requires the app to allow those orientations in Info.plist it does, for both.
private struct IOSOrientationConfigurator: UIViewControllerRepresentable {
let orientation: ShotOrientation
func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
func makeUIViewController(context: Context) -> ShotOrientationController {
ShotOrientationController(mask: mask)
}
func updateUIViewController(_ vc: UIViewController, context: Context) {
guard let scene = vc.view.window?.windowScene else { return }
let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
vc.setNeedsUpdateOfSupportedInterfaceOrientations()
func updateUIViewController(_ vc: ShotOrientationController, context: Context) {
vc.mask = mask
vc.applyGeometry()
}
private var mask: UIInterfaceOrientationMask {
orientation == .landscape ? .landscapeRight : .portrait
}
}
/// Asks the window scene to rotate, from a place where there IS a window.
///
/// The previous version made the request inside `updateUIViewController`, where `view.window` is
/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`,
/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again.
/// Every scene declared `.landscape` the stream hero and the trust card was therefore captured
/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well
/// keeps the scene from rotating back if the simulator reports a device orientation change.
final class ShotOrientationController: UIViewController {
var mask: UIInterfaceOrientationMask
init(mask: UIInterfaceOrientationMask) {
self.mask = mask
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("not from a nib") }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
applyGeometry()
}
func applyGeometry() {
// `view.window` once mounted; the connected-scene lookup covers the first update pass,
// which still runs before this controller is in a window.
let scene = view.window?.windowScene
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard let scene else { return }
// Report a refusal instead of silently shipping the wrong orientation that is exactly
// how every landscape scene went out as a portrait PNG for as long as it did.
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)")
fflush(stdout)
}
setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
#endif
@@ -81,24 +81,126 @@ enum ShotScenes {
@MainActor
enum ShotMock {
/// A populated saved-host grid: a pinned recent host, a couple more, mixed online state.
// Stable ids so the store, the adverts and the profile bindings all point at the same things
// across every scene and every run.
static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")!
static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")!
static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")!
static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")!
static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")!
static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")!
static let hdrProfileID = "a71c4e0d9f22"
static let couchProfileID = "3e88b107c4da"
/// The catalog the host cards read their chips and pinned cards from. Seeded once, on the
/// first store build `ProfileStore` is a singleton, and in shot mode its write-back is
/// suppressed, so this never reaches a real user's catalog.
static func installProfiles() {
guard !profilesInstalled else { return }
profilesInstalled = true
ProfileStore.shared.debugSet([
StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"),
StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"),
])
}
private static var profilesInstalled = false
/// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second
/// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS
/// chains give every tile its real vendor mark instead of a letter monogram.
///
/// No PINNED host+profile card: it renders a second tile for the SAME host, which is the
/// feature working as designed but reads as a duplicate to anyone meeting the app in a store
/// listing. The binding chip carries the profile story on its own.
static func hostStore() -> HostStore {
installProfiles()
let store = HostStore()
store.hosts = [
StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)),
StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: fingerprint),
StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777),
StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420),
macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID,
osChain: "windows/11"),
StoredHost(
id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777,
pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400),
macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"),
StoredHost(
id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777,
pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200),
profileID: couchProfileID, osChain: "linux/ubuntu"),
StoredHost(
id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777,
pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"],
osChain: "linux/arch"),
StoredHost(
id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777,
pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800),
osChain: "linux/nobara"),
StoredHost(
id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777,
pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"],
osChain: "windows/11"),
]
return store
}
static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint)
/// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE
/// through the real `advertises` path, and the reachability probe skips them no network from
/// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely
/// new host populates the "On this network" section.
///
/// A live browse made the shot non-deterministic AND leaked whatever was on the capturing
/// machine's LAN into the App Store listing.
static func discovery() -> HostDiscovery {
let discovery = HostDiscovery()
discovery.debugSet([
HostDiscovery.debugAdvert(
id: "battlestation", name: "Battlestation", host: "192.168.1.20",
fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"],
osChain: "windows/11"),
HostDiscovery.debugAdvert(
id: "living-room", name: "Living Room PC", host: "192.168.1.41",
fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"],
osChain: "linux/fedora/bazzite"),
HostDiscovery.debugAdvert(
id: "office-nuc", name: "Office NUC", host: "192.168.1.33",
fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"),
HostDiscovery.debugAdvert(
id: "studio", name: "Studio PC", host: "192.168.1.58",
fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false,
osChain: "windows/11"),
])
return discovery
}
static let host = StoredHost(
id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: fingerprint, osChain: "windows/11")
/// What the pairing sheet calls THIS device. Taken from the platform, not from
/// `UIDevice.current.name` on a capture simulator that is the harness's own throwaway name
/// (`pf-shot-iphone-6.9` went out on the store listing that way).
static var clientDeviceName: String {
#if os(tvOS)
"Apple TV"
#elseif os(macOS)
"MacBook Pro"
#else
UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone"
#endif
}
/// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs.
static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) })
static let fingerprint = hostFingerprint(0)
/// Distinct per host `StoredHost.matches` prefers a fingerprint comparison, so sharing one
/// across the mock grid made a single advert light up every card.
static func hostFingerprint(_ seed: Int) -> Data {
Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) })
}
}
// MARK: - Home
@@ -106,7 +208,7 @@ enum ShotMock {
private struct ShotHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
var body: some View {
#if os(macOS)
@@ -134,7 +236,7 @@ private struct ShotHome: View {
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -146,7 +248,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 {
@@ -164,7 +268,7 @@ private struct ShotConnect: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var discovery = ShotMock.discovery()
@StateObject private var waker = HostWaker()
var body: some View {
@@ -241,9 +345,9 @@ private struct ShotSettings: View {
#elseif os(iOS)
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
// rendered directly a wrapping NavigationStack would nest a split view in a stack. Open
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
// the General page) instead of the bare category list.
SettingsView(initialCategory: .general)
// on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and
// codec are what someone reads a streaming app's settings shot to find out.
SettingsView(initialCategory: .display)
#else
NavigationStack { SettingsView() }
#endif
@@ -253,16 +357,44 @@ private struct ShotSettings: View {
// MARK: - Pair (PIN ceremony)
private struct ShotPair: View {
/// The PIN as the host's web console shows it, and a device name that doesn't depend on what
/// the capture simulator happens to be called.
private var sheet: some View {
PairSheet(
host: ShotMock.host, shotPIN: "418 306",
shotClientName: ShotMock.clientDeviceName, onPaired: { _ in })
}
var body: some View {
#if os(iOS)
// PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS it carries its own
// `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both
// of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this
// scene used to do), the detents were inert, the grouped Form stretched to the full height
// of the screen, and the capture was a thin strip of content over a huge black void.
ShotHome()
.sheet(isPresented: .constant(true)) {
// Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over
// the keyboard, and the resting height leaves a wide empty band between the form
// and the button row; a capture wants the snug version.
sheet.presentationDetents([.fraction(0.52)])
}
#elseif os(tvOS)
// tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`).
NavigationStack { sheet }
#else
// macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so
// floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture
// -l<windowID>` grabs one window, and an AppKit sheet is a child window a real `.sheet`
// would fall outside the capture.
ZStack {
ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5))
PairSheet(host: ShotMock.host, onPaired: { _ in })
.frame(maxWidth: 460)
sheet
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(radius: 40, y: 16)
.padding(40)
}
#endif
}
}
@@ -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
@@ -191,6 +191,12 @@ final class HostStore: ObservableObject {
private func persist() {
#if DEBUG
// The screenshot harness fills a store with mock hosts (ShotMock) purely to render a
// scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so
// persisting would replace the tester's saved hosts with "Battlestation" & co.
if ScreenshotMode.isActive { return }
#endif
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
@@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject {
static let shared = ProfileStore()
@Published private(set) var catalog: ProfileCatalog {
didSet { catalog.save() }
didSet {
#if DEBUG
// Shot mode seeds this SINGLETON with mock profiles to populate the host cards.
// Saving would write them into the tester's real catalog see HostStore.persist().
if ScreenshotMode.isActive { return }
#endif
catalog.save()
}
}
var profiles: [StreamProfile] { catalog.profiles }
@@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject {
id.flatMap { catalog.profile(id: $0) }
}
#if DEBUG
/// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles
/// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode.
func debugSet(_ profiles: [StreamProfile]) {
catalog = ProfileCatalog(profiles: profiles)
}
#endif
/// This host's default profile, dangling ids dropped a deleted profile resolves as "Default
/// settings", never an error (§4.4).
func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) }
@@ -109,7 +109,7 @@ struct PairSheet: View {
#endif
TextField(
"Client name", text: $clientName,
prompt: Text("How the host lists this Mac"))
prompt: Text(Self.clientNamePrompt))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
@@ -184,6 +184,16 @@ struct PairSheet: View {
#endif
}
/// The field prompt names the device you are actually on it said "this Mac" on every
/// platform, which on an iPhone is simply wrong.
private static var clientNamePrompt: String {
#if os(macOS)
"How the host lists this Mac"
#else
"How the host lists this device"
#endif
}
private func runCeremony() {
busy = true
errorText = nil
@@ -229,3 +239,24 @@ struct PairSheet: View {
}
}
}
#if DEBUG
extension PairSheet {
/// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN
/// field, a DISABLED "Pair & Connect", and because the client name defaults to the device's
/// own whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App
/// Store Connect that way). Seeding both fields captures the ceremony as a user meets it,
/// mid-entry, with a live primary button.
///
/// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach
/// the private state.
init(
host: StoredHost, shotPIN: String, shotClientName: String,
onPaired: @escaping (Data) -> Void
) {
self.init(host: host, onPaired: onPaired)
_pin = State(initialValue: shotPIN)
_clientName = State(initialValue: shotClientName)
}
}
#endif
@@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject {
/// Start browsing `_punktfunk._udp`. Idempotent a second call while live is a no-op.
public func start() {
#if DEBUG
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
@@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject {
for conn in connections.values { conn.cancel() }
}
#if DEBUG
/// A seeded advert set is in force `start()` must not replace it with the live browse.
private var debugPinned = false
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
/// the machine's LAN the App Store screenshots shipped a stranger's hostname more than once
/// and every mock host reads Offline because nothing advertises it.
public func debugSet(_ adverts: [DiscoveredHost]) {
stop()
debugPinned = true
hosts = adverts
}
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
/// making it public would expose a wire-shaped model's construction to every consumer just to
/// serve the harness.
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
}
#endif
private func restart() {
stop()
start()
+36 -14
View File
@@ -11,9 +11,15 @@
# The captured pixels are exactly App Store Connect's required sizes:
# mac 2880×1800 (a 1× display yields 1440×900 — also accepted)
# iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero)
# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero)
# ipad-13 2064×2752 (portrait)
# appletv 1920×1080
#
# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking
# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app
# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore
# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a
# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene.
#
# Requirements:
# • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant
# for your terminal (System Settings → Privacy & Security → Screen Recording).
@@ -35,7 +41,11 @@ cd "$APPLE_DIR"
OUT="${OUT:-$APPLE_DIR/screenshots}"
BUNDLE_ID="io.unom.punktfunk"
SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings)
# The App Store set, in listing order — the first three are what most people ever see, so they are
# the stream itself, the machines it found, and the couch/controller mode. Everything else in
# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`.
SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair})
SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing
mkdir -p "$OUT"
@@ -89,13 +99,20 @@ shoot_macos() {
# $1 device-type regex (matches both existing device names and the device-type catalog)
# $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback)
# $6 name for a device we have to create — MUST satisfy $1 (see below)
shoot_sim() {
require_xcode
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5"
local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6"
# Reuse an existing device of this type; else create a throwaway one against the newest
# available runtime for the platform. CI runners commonly ship a runtime but not every device
# (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible.
# Reuse an existing device of this type; else create one against the newest available runtime
# for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro
# Max is absent on ours), so create-on-demand is what makes it reproducible.
#
# The created device is named after the DEVICE, not after this script, for two reasons. It used
# to be "pf-shot-<prefix>", which `$match` never matches — so every run created another
# simulator and none was ever reused (they piled up on the runner). And the name is user-visible:
# `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so
# "pf-shot-iphone-6.9" was rendered into an App Store screenshot.
local udid
udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)"
if [ -z "$udid" ]; then
@@ -105,8 +122,8 @@ shoot_sim() {
rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \
| grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)"
if [ -n "$devtype" ] && [ -n "$rt" ]; then
udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)"
udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)"
[ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)"
fi
fi
[ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created
@@ -114,6 +131,11 @@ shoot_sim() {
log "$prefix — Simulator $udid"
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
# Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override
# does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the
# DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the
# simulator itself to dark and the whole hierarchy, presentations included, agrees.
xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true
log "$prefix — building ($scheme)…"
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
@@ -150,15 +172,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri
for target in "$@"; do
case "$target" in
macos) shoot_macos ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;;
ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;;
ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;;
tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;;
all)
shoot_macos
if xcrun --find simctl >/dev/null 2>&1; then
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS
shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max'
shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)'
shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K'
else
warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)."
fi
+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));
+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.
+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
+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);
@@ -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.
@@ -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(),
)
+67 -6
View File
@@ -373,16 +373,54 @@ pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr)
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 {
let ip_udp = 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(wire_mtu.saturating_sub(ip_udp), peer)
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).
@@ -626,6 +664,29 @@ mod tests {
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);
}
}
}
+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 {
+19 -1
View File
@@ -59,7 +59,25 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfi
// 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();
mtud.upper_bound(crate::config::video_datagram_udp_ceiling() as u16);
// 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");
}
}
+27
View File
@@ -1091,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).
@@ -1146,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,
@@ -1588,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,
@@ -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
@@ -734,10 +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 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). Bounded ~10 s task, ends by itself.
wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize);
// 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,
+149 -33
View File
@@ -30,10 +30,30 @@ use std::net::IpAddr;
use std::sync::{Mutex, OnceLock};
use punktfunk_core::config::{
mtu1500_shard_payload_for, sealed_datagram_bytes, shard_payload_for_udp_budget,
shard_payload_for_wire_mtu, video_datagram_udp_ceiling,
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
@@ -96,15 +116,26 @@ fn resolve(env_wire_mtu: Option<usize>, learned_udp_budget: Option<u16>, peer: I
}
/// Sample the control connection's discovered MTU after the search has settled and turn it
/// into a verdict. Spawned once per negotiated session; the task ends by itself after the
/// final sample (bounded ~10 s lifetime, holding only a cheap `Connection` handle).
pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) {
/// 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`.
// 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;
@@ -113,6 +144,9 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize)
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.
@@ -120,34 +154,116 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize)
tracing::info!(peer = %peer,
"wire MTU: path re-measured at full size — learned clamp cleared");
}
return;
}
// 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(session_shard_payload) <= 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."
);
// 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;
}
}
});
}
+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). |
+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
+21 -1
View File
@@ -488,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
@@ -816,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