40 Commits

Author SHA1 Message Date
enricobuehler 64b9d11ee6 fix(ci/windows): reclaim runner disk before building
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 4m58s
ci / bench (push) Successful in 6m47s
android-screenshots / screenshots (push) Successful in 2m58s
windows-host / package (push) Successful in 14m30s
decky / build-publish (push) Successful in 16s
ci / rust (push) Successful in 18m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
android / android (push) Successful in 12m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m58s
web-screenshots / screenshots (push) Successful in 2m45s
flatpak / build-publish (push) Failing after 8m2s
linux-client-screenshots / screenshots (push) Successful in 7m33s
docker / deploy-docs (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m35s
arch / build-publish (push) Successful in 12m58s
deb / build-publish (push) Successful in 14m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m35s
release / apple (push) Successful in 28m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m43s
apple / screenshots (push) Successful in 18m28s
A full Windows CI pass writes ~50 GB of cargo target output into the shared
C:\t (x64) / C:\t-a64 (arm64) scratch dirs on the intentionally-small (100 GB)
windows-amd64 runner. Left to accumulate across runs, that overflowed the disk
and every build died with "no space on device" (os error 112) — bytemuck_derive,
cc, bindgen, windows, tracing-subscriber, fs4 all failing mid-compile, taking
down pf-vdisplay/host builds.

ensure-windows-toolchain.ps1 already runs first in every Windows job, so reclaim
disk there before provisioning/building: call the runner-baked reclaimer
(unom/infra installs C:\Users\Public\act-runner\clean-runner-disk.ps1 + a
scheduled task) in threshold mode so THIS job starts with headroom regardless of
when that task last ran, and keep incremental caches warm when there's room. A
small inline fallback covers a runner not yet re-baked with the reclaimer. The
whole step is best-effort — a cleanup hiccup never fails the build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:35:08 +02:00
enricobuehler ca61477c3c docs(roadmap): surround 5.1/7.1 is shipped, not planned
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 1m10s
decky / build-publish (push) Successful in 27s
apple / swift (push) Successful in 4m20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
ci / bench (push) Successful in 6m53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m56s
android / android (push) Successful in 12m10s
docker / deploy-docs (push) Successful in 23s
arch / build-publish (push) Successful in 14m55s
deb / build-publish (push) Successful in 16m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m24s
ci / rust (push) Successful in 23m13s
apple / screenshots (push) Successful in 19m9s
5.1 and 7.1 surround now works end to end (host encodes multichannel
via multistream Opus; native clients render >2 channels via
AudioDec::Surround). Move it to Shipped + the at-a-glance table, and
narrow the Planned entry to the genuinely-future object-based spatial
audio work. Corrects the stale 'every path is stereo end to end / no
client renders it yet' claim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:26:48 +02:00
enricobuehler 68a1ec41c2 Merge pull request 'Rumble envelopes (0xCA v2) + 0.9.2' (#3) from rumble-envelopes into main
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 2m16s
apple / swift (push) Successful in 4m51s
ci / bench (push) Successful in 6m19s
android-screenshots / screenshots (push) Successful in 2m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m22s
decky / build-publish (push) Successful in 21s
arch / build-publish (push) Successful in 10m40s
flatpak / build-publish (push) Successful in 5m58s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
deb / build-publish (push) Successful in 11m47s
ci / rust (push) Successful in 23m27s
docker / deploy-docs (push) Successful in 9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
linux-client-screenshots / screenshots (push) Successful in 8m30s
web-screenshots / screenshots (push) Successful in 2m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m19s
android / android (push) Successful in 11m50s
release / apple (push) Successful in 28m25s
apple / screenshots (push) Successful in 19m37s
windows-host / package (push) Successful in 8m29s
2026-07-11 01:35:01 +00:00
enricobuehler 4873e8925d fix(apple/test): unwrap optional leaseSeconds in RumbleTuningTests
ci / web (pull_request) Successful in 51s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 4m50s
apple / screenshots (pull_request) Has been skipped
ci / bench (pull_request) Successful in 6m18s
android / android (pull_request) Successful in 9m4s
ci / rust (pull_request) Successful in 18m21s
RumbleTuning.leaseSeconds returns TimeInterval? (nil for the no-lease sentinel);
XCTAssertEqual(_, _, accuracy:) needs a non-optional Double. Coalesce with .nan
so a nil (which must not happen for a real ttl) still fails the assertion.
Test-only — the production Swift built clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:16:56 +02:00
enricobuehler 01d10f46ea chore(release): bump workspace version to 0.9.2
ci / docs-site (pull_request) Successful in 53s
ci / web (pull_request) Successful in 57s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m17s
apple / swift (pull_request) Failing after 4m46s
apple / screenshots (pull_request) Has been skipped
ci / bench (pull_request) Successful in 5m26s
android / android (pull_request) Successful in 9m39s
ci / rust (pull_request) Successful in 18m50s
Release 0.9.2 — self-terminating rumble envelopes (0xCA v2) make "stuck rumble"
inexpressible on the wire and retire the per-client staleness guesses, plus the
Windows game-abandoned-residual stop and the Steam Deck Game-Mode HDR fix
(bind gamescope's Wayland socket).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:08:33 +02:00
enricobuehler 73c911cae4 feat(rumble): host-authoritative self-terminating envelopes (0xCA v2)
Rumble was level-triggered, unbounded state on a lossy channel: a non-zero
level meant "buzz until further notice", healed only by the host re-sending
state every 500 ms, and every client guessed when the host had died with its own
magic timeout (SDL 1.5 s, Apple 1.6 s, Android up to 60 s). A lost stop, a
reordered start, or a dead host could drone the motor for seconds.

Make "stuck rumble" inexpressible on the wire. The 0xCA datagram grows a
length-tolerant tail — [u8 seq][u16 ttl_ms] — so it self-terminates: the host
authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds,
letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder
gate (reusing GamepadSnapshot::seq_newer) so a reordered stale start can't
re-light a stopped motor. Decoders read the first 7 bytes as a plain level and
ignore the tail, so no wire-version bump: an old client renders a new host's
levels, and a new client falls back to its prior staleness heuristic against an
old host (ttl = None). All four generation pairings render correctly.

- core: encode_rumble_datagram_v2 / decode_rumble_envelope (datagram.rs); the
  client demux applies the seq gate then forwards (pad, low, high, Option<ttl>);
  next_rumble is unchanged (drops ttl), next_rumble_ttl keeps it; ABI adds
  punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4->5
  (WIRE_VERSION unchanged — the tail is backward-compatible).
- host (punktfunk1.rs): the flat 500 ms refresh becomes a renewal loop that bumps
  seq + stamps a fresh TTL on active pads and drains a short post-stop zero burst,
  then goes quiet. Hatches: PUNKTFUNK_RUMBLE_ENVELOPE=0 (legacy v1 + flat refresh,
  a bisect switch), PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]).
- renderers honor the TTL as their playback duration/deadline and keep their old
  heuristic only for a legacy (ttl=None) update: pf-client-core (the Deck haptic
  keep-alive is now deadline-bounded so it can't sustain a host-stopped rumble),
  clients/windows (SDL duration), android (JNI packs the lease out-of-band in bit
  48 so any u16 ttl is unambiguous; Kotlin createOneShot(ttl)), apple
  (RumbleRenderer.envelopeDeadline + nextRumble2; sessionStaleSeconds demoted to
  the legacy fallback).
- tests: codec round-trip + tail tolerance + seq-gate reorder (Rust); the probe
  asserts the v2 tail arrived under PUNKTFUNK_TEST_FEEDBACK; the Apple loopback
  asserts ttlMs round-trips end to end; RumbleTuning lease-decision cases.

The host-side idle-timeout from the previous commit is defense in depth on the
game side; this is the guarantee on the client side. Design:
punktfunk-planning/design/rumble-envelope-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:08:27 +02:00
enricobuehler 19e9828e8d fix(host/windows): force off a stale rumble residual the game abandoned
XInput vibration is level-triggered — it persists until the game sets it to
zero — so a game that latches a rumble and then stops calling XInputSetState (a
residual left at a menu/loading screen, or a plain forgotten stop) drones to the
client forever (measured: a stuck (0,512) resent every 500 ms for 5.5 minutes).
A real controller stops when the app stops driving it; mirror that. Keyed on
game ACTIVITY (any SET_STATE, even an unchanged one), so a rumble the game keeps
asserting is never cut — only an abandoned residual is; kept above SDL's ~2 s
resend so an SDL-driven host game refreshes the activity clock before it fires.

This is the game-facing half of the rumble-stop story; the wire-facing half is
the self-terminating envelope model in the following commit. They compose: this
bounds a game-abandoned rumble at the host, envelopes bound a host-abandoned
rumble at the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:07:58 +02:00
enricobuehler a1e79a0d69 fix(flatpak): bind gamescope's Wayland socket for Deck Game-Mode HDR
The Vulkan-layer env vars alone left hdr10_format=None on a Deck OLED: the
FROG gamescope WSI layer loads but must open a Wayland connection to
gamescope's private socket ($GAMESCOPE_WAYLAND_DISPLAY = gamescope-0) to
negotiate HDR10 via the gamescope_swapchain protocol. The Deck runs games as
X11 clients, so --socket=wayland binds nothing and that socket never enters the
sandbox → the layer silently can't reach the compositor → PQ tone-mapped to
SDR, badge dark. Bind xdg-run/gamescope-0 (as chiaki-ng does); with the layer
path + ENABLE_GAMESCOPE_WSI + the socket, the surface offers HDR10 and the
presenter's existing HDR10 swapchain path engages — no client code change.
Harmless off-Deck (the layer no-ops with no gamescope socket to bind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:07:50 +02:00
enricobuehler 313f194c63 fix(ci/flatpak): the DNS failures are runner-load packet drops, not nsswitch — widen the retry budget
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 1m9s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
ci / bench (push) Successful in 5m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5m20s
arch / build-publish (push) Successful in 11m35s
docker / deploy-docs (push) Successful in 22s
android / android (push) Successful in 13m7s
deb / build-publish (push) Successful in 12m36s
flatpak / build-publish (push) Failing after 8m5s
ci / rust (push) Successful in 17m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m15s
apple / swift (push) Successful in 5m13s
apple / screenshots (push) Successful in 21m49s
Live-debugged on home-runner-1: confirmed the dnf install in Tooling does
NOT actually rewrite /etc/nsswitch.conf (no authselect trigger fires), so
the nss-resolve sed from 68b5376f/5f687a70 was never the actual fix.
gitea-runner-fleet on this box is a shared, resource-capped fleet (3
replicas, --cpus 5/--memory 7g each, on 16c/24G) serving punktfunk AND
several other orgs' repos concurrently. A push to main fans out ~8
punktfunk workflows at once on top of that other traffic, and the box's
Docker embedded DNS resolver (127.0.0.11) drops UDP lookups under the
combined load — exactly what retry.sh's own header already documented.
Manually dispatching this job while the box was idle succeeded instantly,
with or without the sed. retry.sh's 5-attempt (~100s) budget isn't always
enough to outlast a synchronized multi-org burst; bumped the three
flathub-facing calls (remote-add, install-deps-only, download-only) to 10
attempts (~9min). Verified end-to-end on a live re-run (run 8993) after
this change: full success including publish + OSTree deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 02:22:17 +02:00
enricobuehler 55472495a4 Merge pull request 'feat(ci): accept deploy_host input in deploy-services for target-agnostic deploys' (#2) from feat/deploy-host-input into main
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m19s
ci / bench (push) Successful in 6m38s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 12m5s
android / android (push) Successful in 14m38s
docker / deploy-docs (push) Successful in 18s
ci / rust (push) Successful in 18m22s
deb / build-publish (push) Successful in 14m4s
apple / swift (push) Successful in 4m38s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m37s
apple / screenshots (push) Successful in 19m53s
2026-07-10 23:56:46 +00:00
enricobuehler 9e6bd790a3 feat(ci): accept deploy_host input so deploy-all can target a fresh box
ci / web (pull_request) Successful in 52s
ci / docs-site (pull_request) Successful in 1m2s
ci / bench (pull_request) Successful in 7m44s
android / android (pull_request) Successful in 12m47s
apple / swift (pull_request) Successful in 4m42s
ci / rust (pull_request) Successful in 18m33s
apple / screenshots (pull_request) Has been skipped
When unom/infra deploy-all provisions a new unom-1, it dispatches this
deploy with the just-created box IP. Falls back to the DEPLOY_HOST secret
on push-triggered runs, so existing behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 01:56:04 +02:00
enricobuehler 68b5376fb8 fix(ci/flatpak): reapply nss-resolve DNS fix after dnf pulls systemd upgrade
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 55s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
flatpak / build-publish (push) Failing after 2m12s
ci / bench (push) Successful in 6m49s
docker / deploy-docs (push) Successful in 21s
apple / swift (push) Successful in 4m35s
android / android (push) Successful in 12m53s
arch / build-publish (push) Successful in 14m4s
deb / build-publish (push) Successful in 15m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m33s
ci / rust (push) Successful in 18m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m30s
apple / screenshots (push) Successful in 18m50s
The earlier fix (5f687a70) dropped `resolve` from nsswitch.conf before the
Tooling step's dnf install. But that install pulls flatpak's recommended
xdg-desktop-portal -> pipewire/wireplumber, which drags in a systemd
package upgrade whose RPM trigger re-runs authselect and regenerates
nsswitch.conf — silently re-adding the `resolve` entry and clobbering the
fix before `flatpak remote-add` ever runs. Reapply the sed right after the
dnf install, immediately before the first flatpak network call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 01:40:03 +02:00
enricobuehler 35d97ae6ac feat(windows): parallel virtual displays — proto v3 ring binding, manager slot map, group topology (W0–W3)
windows-drivers / probe-and-proto (push) Successful in 41s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
windows-drivers / driver-build (push) Successful in 1m46s
ci / bench (push) Successful in 6m35s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 9m37s
deb / build-publish (push) Successful in 13m48s
arch / build-publish (push) Successful in 14m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m34s
android / android (push) Successful in 15m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m18s
ci / rust (push) Successful in 18m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m41s
release / apple (push) Successful in 19m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
flatpak / build-publish (push) Failing after 2m9s
apple / screenshots (push) Successful in 19m3s
design/windows-parallel-virtual-displays.md (display-management Stage 7 / §6.6): N
simultaneously-live pf-vdisplay monitors, one sealed ring each, every idd-push-security
invariant preserved per-ring.

- proto v3: SharedHeader._pad → target_id — the ring NAMES its monitor, host-stamped
  before the magic; the driver publisher refuses a cross-bound ring via the shared,
  unit-tested frame::check_attach (new DRV_STATUS_BIND_FAIL — the gamepad pad_index
  validation applied to frames, invariant #10); the host's wait_for_attach surfaces the
  refusal loudly and self-checks its own stamp.
- manager: the one-monitor MgrState becomes a slot map keyed by the client's identity
  slot (0 = anonymous/GameStream); per-slot reconnect + dead-WUDFHost preempts,
  slot-scoped begin_idd_setup (a different identity is an admission question, never a
  preempt), ONE device-level watchdog pinger, per-slot /display/state + /display/release.
- group topology: isolate_displays_ccd takes the managed target SET (a sibling slot is
  never deactivated); SavedConfig + the DDC/PnP axes move to the group record (first-in
  captures, last-out restores); desktop layout via CCD source origins from the pure
  layout::arrange (auto-row default, manual pins win), re-applied on create + reconfigure.
- admission: the Windows separate→reject override now sits behind the
  PUNKTFUNK_WIN_SEPARATE=1 validation hatch (the wedge it guarded is structurally gone —
  a second identity gets its own monitor + ring; default flips in W5 after soak);
  max_displays and NVENC session-unit budgets decline an unaffordable display AT
  admission; kick_dwm_compose is process-globally throttled and per-display — cursor
  jump + 35 ms dwell (a sub-tick jump composes nothing; DWM reads dirties from current
  state at the next vsync tick).

On-glass on the RTX box: V1/V2/V4/V5/V6/V9 green — two paired clients on two monitors
streaming ~60 fps each with zero mismatches and zero bind failures, churn-hammer clean
(no 0x80070490), per-ring mode-change recreate leaves the sibling untouched, typed
budget rejection, fault-injected cross-bind refused loudly with the sibling undisturbed.
V7: WUDFHost-kill shared fate is clean; in-process device recovery is a known follow-up
(the retired-never-closed control handles block the adapter cycle — reset-pf-vdisplay.ps1
recovers). DWM composes two IDD monitors concurrently at 60 fps — the plan's
load-bearing unknown, answered yes.

Also carries the client-HDR EDID forwarding that shared this working tree
(Hello::display_hdr → AddRequest luminance tail → the monitor's CTA-861.3 HDR block,
PUNKTFUNK_CLIENT_PEAK_NITS hatch) and the Deck client fixes (40 ms rumble keep-alive
with 1-LSB jitter, HDR self-diagnosing presenter warn, flatpak HDR env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:06:44 +02:00
enricobuehler 979e38523b Merge pull request 'ci: build-free deploy-services.yml for docs + flatpak server' (#1) from deploy-services-workflow into main
ci / bench (push) Failing after 30s
ci / rust (push) Failing after 34s
ci / docs-site (push) Successful in 58s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / screenshots (push) Successful in 13m22s
arch / build-publish (push) Successful in 15m18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16m3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15m34s
docker / deploy-docs (push) Successful in 21s
deb / build-publish (push) Successful in 21m49s
android / android (push) Successful in 25m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m16s
2026-07-10 21:31:17 +00:00
enricobuehler 9b0aac4b5c ci: fix deploy-services.yml content (was double-encoded)
ci / web (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 4m31s
apple / screenshots (pull_request) Has been skipped
ci / bench (pull_request) Successful in 6m36s
android / android (pull_request) Successful in 11m4s
ci / rust (pull_request) Failing after 14m45s
2026-07-10 19:12:38 +00:00
enricobuehler 124a81e861 ci: deploy-only workflow for docs + flatpak server (no rebuild)
docker.yml (5-image matrix) and flatpak.yml (full flatpak-builder) couple the
docs + flatpak-server deploys to heavy builds. This adds a dispatchable,
build-free deploy-services.yml that just (re)places the compose files and pulls
the already-published images, so unom/infra's deploy-all can bring a fresh
unom-1 fully up in one dispatch. The flatpak ./site OSTree content is restored
from the unom-1 backup or regenerated by flatpak.yml's next publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:11:46 +00:00
enricobuehler 3078528b28 feat(video): 4:4:4 defaults — the host allows it, the client chooses it
windows-host / package (push) Successful in 13m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m59s
ci / web (push) Successful in 43s
ci / docs-site (push) Successful in 48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m19s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m45s
arch / build-publish (push) Successful in 14m18s
docker / deploy-docs (push) Successful in 19s
android / android (push) Successful in 18m24s
deb / build-publish (push) Successful in 16m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m56s
flatpak / build-publish (push) Successful in 7m4s
ci / rust (push) Failing after 13m2s
release / apple (push) Has been cancelled
apple / swift (push) Successful in 1m25s
apple / screenshots (push) Successful in 19m29s
Capability now lives on the host, choice on the client. PUNKTFUNK_444 flips
to DEFAULT ON with an explicit-off grammar (0/false/off/no — the old
presence-only flag() would have read =0 as on); every existing gate still
applies (client advertisement, HEVC, full-chroma capture, encode probe,
Windows HDR-display downgrade), so an unset host merely stops refusing. The
Apple client's "Full chroma (4:4:4)" toggle flips to DEFAULT OFF: full chroma
is a per-session trade — a clear win for desktop/text, but at a fixed bitrate
game content spends those bits better at 4:2:0, and the encode/decode pixel
rate rises. Persisted user choices survive both flips.

Live-verified on the CachyOS VM: host with no env negotiates
chroma_format_idc=3 for a 4:4:4-advertising client; PUNKTFUNK_444=0 resolves
4:2:0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:28 +02:00
enricobuehler 0dacb37088 feat(linux): zero-copy 4:4:4 — the EGL worker converts to planar YUV444 on the GPU
A 4:4:4 session no longer falls to the CPU path (SHM capture + swscale
RGB→YUV444P + re-upload — the fps-ceiling triple tax). The zero-copy worker
grows a Yuv444Blit: three full-res R8 GL passes (the proven BT.709
coefficients; studio or full range per PUNKTFUNK_444_FULLRANGE, read by both
processes so pixels and VUI flip together) into ONE stacked 3-plane pitched
CUDA allocation — which keeps the worker↔host wire and IPC single-plane. The
encoder copies the planes into ffmpeg's yuv444p CUDA surface and hevc_nvenc
emits Range-Extensions 4:4:4 natively.

ImportKind::Tiled444 is APPENDED to the worker protocol (a worker outliving a
replaced host binary must keep the old tags stable; an old worker just errors
the import, which the fail machinery already handles). A 4:4:4 session on a
LINEAR/gamescope capture — no convert wired there — fails with a clear message
instead of letting hevc_nvenc silently subsample. caps().chroma_444 now keys
off the session (it missed the GPU path when keyed off the swscale's
existence).

Live-verified on the CachyOS VM (RTX 5070 Ti): per-frame "imported to CUDA
yuv444=true", stream Rext/yuv444p/bt709 in both tv and pc range, no CPU-path
warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:28 +02:00
enricobuehler 5f687a7083 fix(ci/flatpak): drop nss-resolve so flatpak can resolve DNS in the container
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 56s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
flatpak / build-publish (push) Failing after 2m20s
ci / bench (push) Successful in 13m9s
arch / build-publish (push) Successful in 20m58s
docker / deploy-docs (push) Successful in 24s
android / android (push) Successful in 26m8s
deb / build-publish (push) Successful in 28m2s
ci / rust (push) Successful in 31m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 31m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 29m11s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
fedora:43 nsswitch routes host lookups through the resolve module
(systemd-resolved), absent in a CI container. git/curl/dnf fall through
to the dns module (Docker 127.0.0.11); flatpak/ostree's resolver trips
[!UNAVAIL=return] and dies with '[6] Could not resolve hostname'. Was
masked as flaky 'busy runner drops DNS' + retry.sh, but it's
deterministic on runners where the resolve module tips that way (started
failing when jobs landed on the newly-added home-runner-2). Drop the
resolve entry -> plain dns lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:03:43 +02:00
enricobuehler d55cde61d3 style: cargo fmt — settle the CSC/tvOS changes' layout (CI Format gate)
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Successful in 18s
deb / build-publish (push) Successful in 14m10s
android / android (push) Successful in 16m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
ci / docs-site (push) Successful in 46s
ci / web (push) Successful in 51s
flatpak / build-publish (push) Failing after 2m15s
ci / bench (push) Successful in 15m8s
ci / rust (push) Successful in 18m22s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
Formatting only, no code change: the signaled-CSC and tvOS commits
(1fcf9e11, 3ba19f28) left six files unformatted and the rust job's
Format step rejects main. cargo fmt --all --check is clean after this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:11:27 +02:00
enricobuehler 3ba19f28a2 feat(apple): the gamepad UI comes to tvOS - focus-driven, with real session controls
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
release / apple (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
The console UI now runs on tvOS through the NATIVE focus engine: carousel
cards and settings rows are focusable Buttons (Siri Remote and pads both
navigate; imperative scrollTo replaces the drop-prone scrollPosition binding),
while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what
focus has no concept of: X/Y screen actions and left/right value adjust with
the poll's dominant-axis feel (onMoveCommand proved input-source-dependent:
keyboard intercepted, pad dpad not -> double steps). Text entry uses the
system fullscreen keyboard (TVTextEntry); pairing + library present as covers
under the launcher; the game library defaults ON; settings values slide a
quiet 14 pt in the step's direction.

Session controls: controller/remote input routes EXCLUSIVELY through
GameController during a stream (GCEventViewController, interaction disabled) -
a pad's B no longer doubles as a UIKit menu press that ended sessions
mid-game. Deliberate exits only: the cross-client escape chord (hold
L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all
Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream
banner (now also on tvOS) teaches both. The Siri Remote's touch surface
drives the host pointer - press = left click, Play/Pause = right click,
release-tail jumps gated so motion stays truly relative.

tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every
unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root
font shrank the whole platform (29 pt there), plus 10-foot sizing across host
cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu"
hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops
instead of suspending; full-width focusSections make the home actions
reachable from any column. The presenter defaults to stage-3 glass pacing on
tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind
the 50 ms display stage) and is pickable from the gamepad settings; HDR
capability advertises from AVPlayer.eligibleForHDRPlayback instead of the
current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:59:01 +02:00
enricobuehler 1fcf9e11ec fix(video): honor the signaled CSC matrix end-to-end + tvOS HDR presentation
Clients derive Y'CbCr->RGB from the stream's SIGNALED matrix x range x depth
via shared csc rows (Rust csc_rows + Swift CscRows) instead of hardcoded
709/2020 - a BT.601-signaled stream (a Linux host's RGB-input NVENC) no longer
renders with a constant hue error. Host-side signaling made honest across
NVENC/VAAPI/openh264/GameStream and the session plan's chroma/bit-depth.
Decoded color-bar fixtures (601/709 x limited/full) pin the math in tests on
both cores.

Same presenter, tvOS HDR: tvOS has no Metal EDR API and a bare PQ colorspace
tag composites UNTONE-MAPPED (the "overblown" Apple TV report), so HDR now
splits on the display's live EDR headroom - PQ passthrough when the
per-session AVDisplayManager mode switch landed (a real HDR10 output
tone-maps itself), else an in-shader PQ->SDR tone-map (203-nit reference
white, extended-Reinhard 1000-nit knee, 2020->709) into the proven SDR layer
config. The 10-bit stream keeps its full decode depth either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:59:01 +02:00
enricobuehler db49904c6d fix(core): un-break win64 clippy — RawSocket is already u64, the cast is same-type
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m1s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m24s
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
CI's Windows clippy (-D warnings) rejects `raw as u64` in the qWAVE flow
guard: std's RawSocket is u64 on Windows, so the cast is a no-op
(clippy::unnecessary_cast). Verified with the CI's exact invocation
(cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings)
on the RTX box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:55:27 +02:00
enricobuehler a011aebef5 feat(host,web): experimental PnP monitor-devnode disable for Exclusive sessions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
windows-host / package (push) Failing after 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m54s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m21s
flatpak / build-publish (push) Failing after 2m26s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Failing after 19m24s
Second experiment against the connected-but-dark-head stutter (field-proven
on the reporter's box: unplugging his standby HDMI TV removes a metronomic
~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP
0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors
from the CCD topology; their PnP devnodes stay live, so every standby wake
(auto input scan, Instant-On HPD cycling) still triggers the full Windows
reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the
suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature).

- New `pnp_disable_monitors` display-policy axis (default off): orthogonal
  to presets like game_session/ddc_power_off, surfaced in GET/PUT
  /display/settings + the enforced list, carried through the layout
  transform.
- windows/monitor_devnode.rs: after the isolate takes, disable exactly the
  deactivated monitors' devnodes — CCD target → monitor device path
  (DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode
  with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that
  persistence is the whole point). Teardown re-enables BEFORE the CCD
  restore (+300 ms re-arrival settle) so restored paths have their monitors
  back. Precise selection — co-installed third-party virtual displays are
  never touched.
- Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json
  before disabling; serve startup re-enables leftovers from a crashed host.
  Worst case is documented in the console help (Device Manager re-enable).
- Web console: second Experimental-badged toggle (the DDC block refactored
  into a shared ExperimentalToggle), EN/DE strings, preset-switch carry.

Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:47:40 +02:00
enricobuehler d1770c3476 refactor(host): shared send-pacing policy for the native and GameStream video planes
Networking-audit deferred plan §5. Both planes spread a frame's wire
packets across a time budget in chunked bursts; the schedule logic,
PUNKTFUNK_VIDEO_DROP loss injection, and percentile helper were duplicated
between punktfunk1::paced_submit and gamestream::stream::spawn_sender. Now
one host-local send_pacing::pace_frame carries the policy; each plane keeps
its exact historical parameterization and its own syscall layer (GSO
Session vs sendmmsg over the RTP socket — policy shared, plumbing not):

  native     burst_bytes = PUNKTFUNK_PACE_BURST_KB (microburst stage),
             fixed 16-packet chunks, budget = 0.9 × time-to-deadline
  gamestream no burst stage, bounded steps (≤ 12, chunk ≥ 16, the old
             pace_layout), fixed budget = 0.75 × frame interval

Deterministic-schedule unit tests pin both parameterizations against
verbatim transcriptions of the legacy math (burst split, chunk layout,
step counts — including pace_layout's historical test anchors) and the
sleep-target formula (GameStream's legacy per_step form agrees to
≤ steps/2 ns; the unified fraction form is used for both). Deliberate
sub-observable normalizations, all on test-knob or ns-scale paths:
PUNKTFUNK_VIDEO_DROP is now parsed once per process and clamped to 1..=90
on the GameStream plane too (was per-stream, unclamped), and the native
sleep floor comparison is now >= (was >, differs only at exactly 500 µs).

Validation:
- 263 host tests green, incl. the end-to-end sender_delivers_batches
  (spawn_sender → pace_frame → sendmmsg, byte-identical delivery)
- PUNKTFUNK_VIDEO_DROP FEC sweep at 5 % and 8 % injected wire loss:
  all 11 punktfunk1 integration tests (full host↔client roundtrips
  through send_loop → paced_submit) recover and pass
- pending: one real Moonlight smoke session against this build (the
  legacy-plane timing gate) — recipe handed to the operator

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:26:41 +02:00
enricobuehler baa04d2d24 style: cargo fmt over the networking-audit changes
rustfmt pass over the files the deferred-plan items touched (pinned
toolchain 1.96.0); no semantic change. cargo fmt --all --check now clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:10:20 +02:00
enricobuehler ddb93c533c fix(core,android): networking-audit small follow-ups — bounds, oversized AUs, probe flag
Networking-audit deferred plan §6:

- 6.1 client reassembler ceiling derived from the negotiated rate:
  Welcome::session_config (client role) now sets max_frame_bytes to
  clamp(4 × bitrate_kbps×125 / refresh_hz, 8 MiB, 64 MiB) instead of the
  blanket 64 MiB p1_defaults bound — the hostile-header memory ceiling was
  ~10× larger than any real access unit. Local only (the host never
  reassembles video; the wire is self-describing); a bitrate-0 (older)
  host keeps the old bound. Unit-tested floor/derived/host/old-host cases.
- 6.2 ProbeState.active is cleared when the host's ProbeResult lands, so
  the pump stops mirroring receive counters once the burst is over.
- 6.3 Android: an AU larger than the codec input buffer is DROPPED with a
  recovery-keyframe request and a counter, on both the sync (feed) and
  async (feed_ready) paths — a truncated AU is corrupt input the decoder
  chews on silently, poisoning the reference chain until the next IDR. The
  async path recycles the never-queued input slot; the sync path returns
  the dequeued slot with zero valid bytes.
- 6.4 bounded uplink channels: mic_tx at 64 (~320 ms of 5 ms frames;
  overflow sheds the fresh frame with a debug log — a tokio mpsc can't
  shed from the head, and past 320 ms of backlog the mic is broken either
  way; the bound is about memory) and ctrl_tx at 32 (sparse requests; a
  full queue means a wedged control task, reported as Closed). input_tx
  stays unbounded per the plan: keyboard/mouse events must never silently
  drop, and gamepad state is snapshot-healed.
- 6.5 (wire version byte says P1 while streaming Gf16): record-only,
  resolves with the P2 packet revision.

include/punktfunk_core.h: cbindgen re-emitted in the new module order
after the quic/ split (item 3) — no semantic change beyond the reorder.

cargo ndk check (arm64-v8a), workspace clippy, core+host tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:07:42 +02:00
enricobuehler 9afcbcd307 feat(transport): Windows DSCP via qWAVE flows — PUNKTFUNK_DSCP now real on the wire there
Networking-audit deferred plan §4 (the qos.rs follow-up). On Windows
set_tos_v4 succeeds but the stack strips the mark without a qWAVE flow, so
PUNKTFUNK_DSCP=1 was a silent wire no-op there. Now (Apollo/Sunshine's
approach): QOSCreateHandle once per process; QOSAddSocketToFlow per
connected media socket — video → QOSTrafficTypeAudioVideo, audio →
QOSTrafficTypeVoice (QOS_NON_ADAPTIVE_FLOW) — then best-effort
QOSSetFlow(QOSSetOutgoingDSCPValue, 40/48) to pin the exact CS5/CS6 the
other platforms mark. The pin lands for elevated processes (the host runs
as the SYSTEM service — exactly where the video egress is) or under the
"allow non-admin DSCP" policy; otherwise the traffic-type default marking
stands (still WMM-useful). Gating + contract unchanged: opt-in via
dscp_enabled(), every step debug-logs and continues.

set_media_qos now returns an RAII QosFlow guard (QOSRemoveSocketFromFlow on
drop) that must outlive the socket's traffic: stored in UdpTransport
(declared before the socket, so drop order removes the flow first) and held
for the stream's scope by the GameStream video/audio senders — whose
tagging moved after connect(), since qWAVE derives the flow's 5-tuple from
the connected socket (behavior-neutral on Linux). Off-Windows the guard is
inert and never constructed.

Validated: cargo check -p punktfunk-core --target x86_64-pc-windows-msvc
green (the full host can't cross-check from Linux — aws-lc-sys needs MSVC
tooling; it builds on-box via deploy-host.ps1). Remaining on the next
Windows pass per plan: deploy to the RTX box and pktmon/Wireshark the
client side — DSCP ≠ 0 on video egress with PUNKTFUNK_DSCP=1, 0 without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:59:23 +02:00
enricobuehler e9b2eacf87 refactor(core): split quic.rs (3.2k lines) into src/quic/ — pure move
Networking-audit deferred plan §3. One file per concern, zero logic edits:

  quic/mod.rs      MAGIC/CTL_MAGIC + re-exports (every crate::quic::X path
                   compiles unchanged across host + all clients)
  quic/msgs.rs     Hello/Welcome/Start, typed control msgs + type bytes,
                   resolve_codec, ColorInfo, window_loss_ppm, pairing msgs
  quic/pake.rs     the SPAKE2 pairing exchange
  quic/datagram.rs 0xC9–0xCF plane codecs (audio/rumble/mic/rich-input/
                   hidout/HdrMeta/HostTiming)
  quic/io.rs       length-prefixed stream IO
  quic/clock.rs    clock_offset_ns estimator, clock_sync, ClockResync
  quic/endpoint.rs quinn config, ALPN, pinning verifiers, keep-alive
  quic/tests.rs    the cross-cutting test module, unchanged

Mechanical deltas only: the nested `pub mod` wrappers became files (one
dedent), submodules import what they previously inherited from the parent
scope, and the three RichInput kind tags are pub(super) for the tests
(same-module before). Verified line-multiset-identical after normalizing
indentation. cargo check --workspace, core tests (quic), clippy, and
cargo ndk check all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:51:15 +02:00
enricobuehler d4467a44e2 feat(core): mid-stream clock re-sync — live offset survives wall-clock steps and drift
Networking-audit deferred plan §2. The host↔client offset was measured once
at connect; an NTP step or slow drift silently corrupted the clock-based
jump-to-live signal, the ABR one-way-delay signal, and every latency stat —
4a3b1ae2's disarm backstop stopped the IDR storm but lost the detector for
the session. Now the client re-estimates mid-stream and recovers it.

- quic: ClockResync — the connect-time 8-round probe/echo estimate as a
  select!-driven state machine (rounds matched by echoed t1, stale batches
  ignored), plus accept_resync (batch min-RTT ≤ max(2 ms, 1.5× connect RTT)
  so a congested window can never bias the offset). No wire change: the
  host has always answered ClockProbe at any time on the control stream.
- client: the offset lives in an Arc<AtomicI64> seeded at connect; the
  control task re-probes every 60 s and immediately after the pump's FIRST
  no-op clock flush (the "clock stepped under me" signal, sent on the next
  report tick). On apply: store, reset stale_frames/noop_clock_flushes,
  re-arm the clock detector if a step had disarmed it. The disarm heuristic
  stays as the final backstop. Public NativeClient::clock_offset_ns keeps
  the connect-time value (ABI untouched); new clock_offset_now_ns() /
  clock_offset_shared() expose the live value.
- consumers migrated to the live offset: pf-client-core session stats, the
  pf-presenter e2e stamp, Windows session/render, Android feeder/drain/
  DisplayTracker (the tracker holds the shared handle, not the client, so
  the leaked render-callback refcount can't pin the session).
- probe: --clock-resync runs a second full handshake mid-connection and
  asserts a sane, consistent estimate. Live against the local canary host:
  offsets 8646/2139 ns, disagreement 6 µs, 8/8 rounds — OK.

Unit tests cover the round collection, stale-echo rejection, batch restart,
min-RTT selection, and the acceptance guard. cargo ndk check green.
Remaining manual validation: `sudo date -s "+2 sec"` on a live streaming
client → expect one no-op flush, a re-sync, re-armed detector, no IDR pulse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:45:12 +02:00
enricobuehler 68a863866a perf(core): packetize straight into the wire pool — zero-alloc host send path
Stage B of the zero-copy host packetize path (networking-audit deferred
plan §1): Packetizer::packetize_each yields (header, shard) pairs in exact
wire order; Session::seal_frame writes seq(8) ‖ header(40) ‖ shard ‖ tag
scratch directly into the pooled wire buffer and seals [8..] in place. The
per-packet intermediate Vec (header ++ body) and its extra memcpy are gone
— with Stage A, every data byte is now copied once (frame → wire) instead
of three times, and the ~2 transient allocs/packet on the send thread are
zero after pool warmup (~180k allocs/s at 1 Gbps rates).

packetize() stays as a thin wrapper over packetize_each — the reference
implementation used by tests and the loss harness.

- wire-equivalence test: pooled path vs wrapper path byte-identical across
  multi-block/partial-tail/exact-multiple/empty frames, fec 0%/50%, both
  schemes, crypto on/off
- loss-harness sweep: recovery rates identical to the pre-item-1 baseline
- bench pipeline (end-to-end incl. client half) vs pre-item-1 baseline,
  stages A+B cumulative: gf16/64K -3.6%, gf16/1M -3.2%; gf8 cases are
  Cauchy-math-bound and unchanged within noise
- cargo ndk check (arm64-v8a) green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:22:53 +02:00
enricobuehler cdbdc078d6 perf(core): ref-based FEC encode — packetize shards reference the frame in place
Stage A of the zero-copy host packetize path (networking-audit deferred
plan §1): ErasureCoder::encode now takes &[&[u8]], so Packetizer::packetize
builds each block's data shards as slices straight into the frame buffer
instead of allocating + copying a Vec per data shard. Only the frame's
final (possibly partial) shard is staged in a reusable zero-padded scratch;
blocks are consecutive shard ranges, so every other shard is a full
payload-sized slice.

- gf8: encode_sep() over the same Cauchy codec — parity byte-identical to
  nanors/Moonlight (nanors_exact_parity_vectors unchanged and green)
- gf16: reed_solomon_simd::encode is already generic over AsRef<[u8]>
- loss-harness sweep: recovery rates identical before/after
- bench pipeline (end-to-end, host+client): gf8/64K -3.0%, gf16/64K -2.2%,
  gf16/1M -3.4%, gf8/1M -0.7%

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:16:07 +02:00
enricobuehler 204577c7ce style(core): dedupe Hello::decode trailing-field offset math
The four trailing single-byte fields (video_caps, audio_channels, video_codecs,
preferred_codec) each recomputed the name/launch offset chain from scratch —
four copies of the same three-line walk, each a chance to diverge when the next
trailing field lands. Compute name_len/launch_off/tail once and index from
there; name/launch decode from the same bindings. Wire behaviour pinned by the
existing roundtrip + back-compat tests (all green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:55:51 +02:00
enricobuehler dd73ae2469 fix(host): fresh random per-session nonce salt instead of the static "pkf1"
Every session sealed with the literal salt b"pkf1", so GCM nonce uniqueness
(nonce = salt || sequence) rested ENTIRELY on the per-session key being fresh —
correct today, but a single key-reuse bug anywhere in the handshake path would
have meant immediate catastrophic nonce reuse instead of merely a wrong key.
Random salt per session keeps the documented second line of defense real. The
salt is negotiated via Welcome, so every deployed client just follows — no wire
or compat change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:55:51 +02:00
enricobuehler 6fbab53d56 feat(audio): libopus packet-loss concealment on the client audio plane
The 0xC9 audio datagrams ride the lossy plane with no FEC, and no client ever
consulted the per-packet sequence: a lost 5 ms Opus packet played out as a hard
gap in the ring — an audible click/pop on every drop, i.e. constantly on the
Wi-Fi links where video loss is already being FEC-absorbed.

Now a shared `AudioGapTracker` (punktfunk-core::audio — pure data, wrap-safe,
unit-tested incl. u32 wraparound / reorder / duplicate cases) tells the decoder
how many packets went missing immediately before each received one, and both
native clients (pf-client-core PipeWire path, Android AAudio path) synthesize
that many frames of libopus packet-loss concealment first: `decode` with empty
input (the opus crate maps it to a NULL data pointer = PLC), sized by the last
real frame's sample count. Interpolated fade instead of a click.

Bounds: a gap is capped at 10 packets (50 ms) — libopus PLC fades to silence
after a few frames anyway, so past the cap the rings' existing underrun/re-prime
path takes over. Reorders and duplicates conceal nothing (the plane has no
reorder buffer; playing a late packet where it lands is the existing behaviour).
In-band Opus FEC (LBRR) is deliberately NOT used: the host sends 5 ms frames
and LBRR needs ≥10 ms frames to carry anything.

The cap is a crate-private const so cbindgen keeps it out of the C ABI header.
Host cargo tests + clippy green; android crate verified via cargo ndk check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:55:37 +02:00
enricobuehler 4a3b1ae2e3 fix(core): jump-to-live survives a mid-session clock step — disarm on no-op flushes
The clock-based jump-to-live detector compares wall-clock receive time against
the CONNECT-TIME skew offset. A wall-clock step on either end (NTP mid-session,
resume-from-sleep correction) shifts every future frame's apparent latency by a
constant: past the 400 ms bound the detector fires forever — one backlog flush +
recovery IDR every 2 s cooldown, and the bitrate controller rides the repeated
"flushed" bad windows down to its floor. A stream that was perfectly live turns
into a periodic quality pulse with no recovery path.

The tell is in the flush itself: a genuine 400 ms backlog is ≥~170 datagrams
even at the 5 Mbps bitrate floor, but a clock-step flush finds nothing to
discard. So: two consecutive clock-triggered flushes that discarded <64
datagrams and zero queued AUs disarm the clock detector for the session (logged).
This also covers upstream router bufferbloat — delay standing in a queue a local
flush can't drain, where the OWD signal to the bitrate controller is the actual
remedy and a 2 s IDR cadence only feeds the congestion. The clock-free
queue-depth detector stays armed either way; it measures the local queue
directly and can't be fooled by a clock.

Rode along: the 11-field `Negotiated` tuple is now a documented struct — the
connect/worker plumbing reads as named fields instead of positional magic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:55:20 +02:00
enricobuehler c7b8007ce7 fix(core): receive path — replay window covers the loss window, zero-alloc open
Two receive-path findings from the networking audit:

1. The anti-replay window (4096 seqs) silently re-tightened the "late ≠ lost"
   fix: at 1 Gbps (~125k pkt/s) it spans only ~33 ms, so a Wi-Fi-retry-delayed
   shard the reassembler's 120 ms loss window would still use was dropped HERE
   first as "older than the window" — recreating the false-loss → recovery-IDR
   churn the time-based loss window was built to kill, exactly on the high-rate
   links punktfunk targets. Widened to 32768 (covers 120 ms up to ~270k pkt/s,
   ≈2 Gbps+); the bitmap costs 4 KiB per session and the replay-hiding bound
   stays finite.

2. Every received datagram still paid one Vec allocation in the AES-GCM open
   (and a to_vec on the plaintext probe path) — ~125k allocs/s of cross-thread
   allocator churn at line rate, the same class of overhead that was the
   documented single-core wall on the macOS receive path. New
   `SessionCrypto::open_in_place` (mirror of seal_in_place; GCM verifies the
   tag BEFORE decrypting, so a forged packet never yields plaintext) lets
   `poll_frame` decrypt inside the recv ring and hand the reassembler a slice.
   Byte-identical semantics, unit-tested against `open` incl. tamper/runt
   cases; criterion entry added next to seal_in_place.

Tests: 94 core unit + loopback/c_abi suites green; clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:55:06 +02:00
enricobuehler cca5008805 feat(host,web): experimental DDC/CI monitor power-off for Exclusive sessions
ci / web (push) Successful in 47s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 19s
apple / swift (push) Successful in 1m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
apple / screenshots (push) Successful in 5m28s
ci / bench (push) Successful in 6m40s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 8m45s
android / android (push) Successful in 12m43s
deb / build-publish (push) Successful in 13m1s
arch / build-publish (push) Successful in 14m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m20s
ci / rust (push) Successful in 22m12s
The sole-virtual-display stutter investigation's active experiment: when the
Exclusive isolate deactivates a physical monitor, the dark-but-connected head
keeps getting serviced (monitor standby auto-input-scan / DP link churn) at a
seconds-scale cadence — the leading suspect for the periodic double-jolt. A
panel commanded off over DDC/CI (the VESA monitor-control channel in the video
cable) believes it has an owner and, on cooperating firmware, stops probing.

- New `ddc_power_off` display-policy axis (default off): orthogonal to presets
  like game_session, stored in display-settings.json, surfaced in GET/PUT
  /display/settings + the enforced list, carried through the layout transform.
- windows/ddc.rs: VCP 0xD6 power-mode control via the dxva2 Physical Monitor
  API. Deliberately DPMS-off (0x04, DDC stays responsive, signal return wakes)
  and never power-button-off (0x05, bricks-until-button on many monitors).
  Probe-before-write; every failure is skip-and-log — monitors without DDC/CI,
  OSD-disabled, or behind docks/KVMs degrade to a logged no-op.
- Manager wiring: panels commanded off immediately BEFORE the Exclusive CCD
  isolate (an HMONITOR — and with it the DDC channel — only exists while the
  display is active); teardown wakes them right after the CCD restore, where
  returning signal alone already wakes most firmware.
- Web console: an Experimental-badged on/off control on the display card,
  applied immediately like the game-session axis and preserved across preset
  switches; EN/DE strings incl. the wake-failure escape hatch (press the
  monitor's power button once, turn the option off).

Diagnostic value on top of the fix: if this kills a reporter's stutter, the
churn is monitor-firmware-initiated; if only topology=primary/extend does, the
driver services dark heads regardless — the two remaining root-cause classes.

Verified: Linux 258 tests + clippy + fmt clean; Windows (RTX box) 220/220 +
clippy clean; web tsc + production build clean; openapi.json regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:32:36 +02:00
enricobuehler f68f6bc590 fix(linux): zerocopy worker survives the host binary being replaced on disk
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m29s
apple / screenshots (push) Successful in 5m39s
windows-host / package (push) Successful in 7m45s
ci / bench (push) Successful in 5m30s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m4s
arch / build-publish (push) Successful in 11m21s
docker / deploy-docs (push) Successful in 11s
android / android (push) Successful in 15m23s
deb / build-publish (push) Successful in 13m39s
ci / rust (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
A pacman canary upgrade under a running host (0.5284→0.5338, 09:24 today)
unlinked /usr/bin/punktfunk-host; current_exe() then readlinked to
"<path> (deleted)", every worker spawn failed ENOENT, and each session
silently fell back to the CPU linear-copy capture — observed as the box
"regressing" to ~90 fps at 5-7 MP until a service restart.

- RemoteImporter::spawn now pins a read fd to /proc/self/exe (once, kept for
  the process lifetime) and execs the worker via /proc/self/fd/<n>. The magic
  link names the running image's inode, not its path, so the spawn survives
  replacement/deletion — and the worker is always byte-for-byte the host's own
  build, so a mid-upgrade spawn can't hit a worker-protocol skew either. If
  the fd draws number 3 (the worker's socket slot — the pre-exec dup2 would
  clobber it) it is re-numbered; if /proc is unavailable the old path-based
  spawn remains as fallback.
- argv[0] is set to "punktfunk-host" and the worker prctl-renames its comm to
  "pf-zerocopy" — exec-by-fd-path would otherwise show a bare fd number in ps
  and top.
- zerocopy-probe now also spawns the worker (handshake + modifier query), so
  the probe catches spawn-level breakage, not just FFI/GPU bring-up.

Verified end-to-end on the dev box: probe with the binary unlinked mid-run
(/proc/self/exe → "(deleted)") still spawns the worker and reports all 13
modifiers. New unit tests cover the pinned spawn and the deleted-file exec;
the latter retries ETXTBSY (fs::copy's write fd leaks into other tests'
forked children until their execs clear it — a copy-then-exec harness
artifact, not a production concern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:34:13 +02:00
enricobuehler 7ab97bb1a3 feat(host): capture-stall watch — DWM-level self-diagnosis for the Exclusive-topology stutter
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m18s
ci / bench (push) Successful in 5m17s
decky / build-publish (push) Successful in 36s
windows-host / package (push) Successful in 8m13s
arch / build-publish (push) Successful in 11m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
apple / screenshots (push) Successful in 5m38s
android / android (push) Successful in 16m12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish (push) Successful in 15m13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13m43s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m46s
ci / rust (push) Successful in 22m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m10s
Field repro (Mounjay, still present on 0.9.0): the ~4 s double-jolt stutter
appears ONLY while the virtual display is the sole active display (Exclusive
topology) and stops the instant Windows switches to Extend — live, both ways.
Cross-project research (Apollo #179/#358/#368/#563/#776, VDD #36, Tom's HW)
points at the display/present path BELOW capture: an inactive-but-connected
DisplayPort head being periodically serviced (standby HPD/AUX/link events),
with a DWM software-vsync clock beat as the secondary (different-signature)
class. Neither ends in anything our recovery-side detector can see unless the
client actually loses data — so give the HOST a direct sensor at the ring:

- StallWatch (idd_push.rs): a >150 ms hole in DWM frame delivery counts as a
  capture stall only when the 8 preceding frames arrived within 400 ms —
  sustained >=20 fps flow, so an idle desktop, a caret blink, or a paused
  video can never trip it. Per-stall debug line; when stalls settle into an
  evenly-spaced multi-second cycle, one rate-limited WARN names the class:
  'capture stalls are METRONOMIC', with the topology=primary/extend and
  refresh-rate leads. Ring-recreate recovery gaps reset the watch (self-
  inflicted, already logged by the recreate path).
- The evenly-spaced-cycle detector moves out of punktfunk1.rs into
  metronome.rs (RecoveryCadence -> Metronome, unchanged logic + tests) so the
  IDR-serve detector and the stall watch share one implementation; the
  recovery WARN now cross-references the capture-stall lines.

Diagnosis map for an Exclusive-mode stutter log: 'slow display-descriptor
poll' = something holds the win32k display lock; 'capture stalls are
METRONOMIC' without it = DWM stopped composing (DP servicing / present
clock, below us); recovery-IDR METRONOMIC alone = frames flowed but clients
lost data. Verified: Linux tests+clippy+fmt clean; Windows (RTX box)
220/220 + clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:21 +02:00
148 changed files with 13003 additions and 5117 deletions
+101
View File
@@ -0,0 +1,101 @@
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
# NEITHER build: it just (re)places the compose files and pulls the already-published
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
# dispatch without triggering those rebuilds.
#
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
#
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
# DEPLOY_HOST/USER/PORT/SSH_KEY (the unom-ci-deploy key) + REGISTRY_TOKEN (docs pull).
name: deploy-services
run-name: ${{ gitea.actor }} deploy docs + flatpak server
on:
workflow_dispatch:
inputs:
deploy_host:
description: "Box IP to deploy to; deploy-all passes the freshly-provisioned target IP. Blank (push) uses the DEPLOY_HOST secret."
required: false
jobs:
docs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync compose file
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
# exfiltration. v0.1.7 = 917f8b8. Bump the SHA + trailing version together.
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "compose.production.yml"
target: "~/punktfunk-docs"
overwrite: true
- name: Pull and start docs
# SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN. v1.2.5 = 0ff4204.
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Token enters via env, never the script text (keeps it out of run logs).
envs: REGISTRY_TOKEN
script: |
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
cd ~/punktfunk-docs
docker compose -f compose.production.yml pull docs
docker compose -f compose.production.yml up -d --no-build docs
flatpak:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync flatpak server compose + Caddyfile
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land both files flat in ~/unom-flatpak/ (drop the packaging/flatpak/server/ prefix).
source: "packaging/flatpak/server/compose.production.yml,packaging/flatpak/server/Caddyfile"
target: "~/unom-flatpak"
strip_components: 3
overwrite: true
- name: Start flatpak static server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./site (the OSTree repo) is NOT shipped by this workflow. Ensure the
# bind-mount source exists so caddy starts; content is restored from the
# unom-1 backup or regenerated by flatpak.yml's next publish.
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
+42 -6
View File
@@ -59,6 +59,23 @@ jobs:
image: fedora:43 image: fedora:43
options: --privileged options: --privileged
steps: steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
# module is nss-resolve (systemd-resolved), which isn't running in a CI
# container. glibc getaddrinfo (git, curl, dnf) mostly falls through to the
# `dns` module -> Docker's embedded 127.0.0.11 -> works. flatpak/ostree's
# resolver does NOT fall through: the absent-daemon socket connect trips
# `[!UNAVAIL=return]` and it reports "[6] Could not resolve hostname". This
# was masked as an intermittent "busy runner drops DNS" and papered over
# with retry.sh — but it's deterministic on a runner where the resolve
# module tips that way (surfaced when jobs began landing on home-runner-2).
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step # fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
# executes via the container shell (no node needed), so install node BEFORE checkout. # executes via the container shell (no node needed), so install node BEFORE checkout.
- name: node for the JS actions - name: node for the JS actions
@@ -72,11 +89,28 @@ jobs:
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step). # gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \ dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients gnupg2 rsync openssh-clients
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions. # Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
# retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve #
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a # ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# single-shot fetch. Same treatment for every network command below. # NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \ # a SHARED, resource-capped fleet (3 replicas, --cpus 5 --memory 7g each, on a 16c/24G
# box) serving punktfunk AND several other orgs' repos (chatwoot, website, cms, data,
# infra, tempblade, played...). A push to punktfunk's main fans out ~8 workflows at
# once, and this runner box's Docker embedded DNS resolver (127.0.0.11) genuinely
# drops UDP lookups under that concurrent load — exactly what retry.sh's own header
# comment already documented. Reproduced: manually dispatching this job when the box
# was idle (1 container running) succeeded immediately, with or without the sed above.
# 5 attempts (~100s total) isn't always enough to outlast a synchronized multi-org
# burst, so bump the bootstrap fetch's budget — never fail the job on a single-shot
# fetch. Same treatment for every network command below.
bash scripts/ci/retry.sh 10 flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
@@ -171,10 +205,12 @@ jobs:
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running # the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap. # after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no FUSE). # --disable-rofiles-fuse is the container-safe path (no FUSE).
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \ # 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \ --install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST" "$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \ bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only \ --download-only \
"$PWD/build-dir" "$MANIFEST" "$PWD/build-dir" "$MANIFEST"
Generated
+14 -14
View File
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.9.1" version = "0.9.2"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2286,7 +2286,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2765,7 +2765,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2787,7 +2787,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2817,7 +2817,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3001,7 +3001,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3033,7 +3033,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3048,7 +3048,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3072,7 +3072,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3103,7 +3103,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3175,7 +3175,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3189,7 +3189,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.9.1" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -35,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.9.1" version = "0.9.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+8
View File
@@ -2517,6 +2517,10 @@
"type": "object", "type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": { "properties": {
"ddc_power_off": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
},
"game_session": { "game_session": {
"$ref": "#/components/schemas/GameSession", "$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -2539,6 +2543,10 @@
"mode_conflict": { "mode_conflict": {
"$ref": "#/components/schemas/ModeConflict" "$ref": "#/components/schemas/ModeConflict"
}, },
"pnp_disable_monitors": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
},
"preset": { "preset": {
"$ref": "#/components/schemas/Preset" "$ref": "#/components/schemas/Preset"
}, },
@@ -35,6 +35,9 @@ class GamepadFeedback(private val handle: Long) {
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03 const val TAG_TRIGGER: Byte = 0x03
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
// A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L
} }
@Volatile private var running = false @Volatile private var running = false
@@ -66,7 +69,17 @@ class GamepadFeedback(private val handle: Long) {
while (running) { while (running) {
val ev = NativeBridge.nativeNextRumble(handle) val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed if (ev < 0L) continue // timeout / closed
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt()) // ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no
// in-band sentinel. No lease (legacy host) → the prior long one-shot.
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
renderRumble(
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
durationMs,
)
} }
}, "pf-rumble").apply { isDaemon = true; start() } }, "pf-rumble").apply { isDaemon = true; start() }
@@ -143,9 +156,14 @@ class GamepadFeedback(private val handle: Long) {
} }
} }
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */ /**
private fun renderRumble(low: Int, high: Int) { * low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return * `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration).
*/
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
val lo = toAmplitude(low) val lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
val m = vm val m = vm
@@ -157,12 +175,12 @@ class GamepadFeedback(private val handle: Long) {
val combo = CombinedVibration.startParallel() val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) { if (amplitudeControlled && vibratorIds.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi)) if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo)) if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
} else { } else {
// Single motor or no amplitude control: blend both into one effect. // Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a)) for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
} }
runCatching { m.vibrate(combo.combine()) } runCatching { m.vibrate(combo.combine()) }
return return
@@ -175,7 +193,10 @@ class GamepadFeedback(private val handle: Long) {
} }
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching { runCatching {
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE)) lv.vibrate(
if (amplitudeControlled) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
} }
} }
@@ -185,8 +206,10 @@ class GamepadFeedback(private val handle: Long) {
return if (v16 != 0 && a == 0) 1 else a return if (v16 != 0 && a == 0) 1 else a
} }
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero. // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp) // self-terminates on a lost stop; cancel on zero.
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp)
// ---- HID output ---- // ---- HID output ----
+28 -3
View File
@@ -355,10 +355,34 @@ fn decode_loop(
}; };
let mut pcm = vec![0f32; pcm_scratch]; let mut pcm = vec![0f32; pcm_scratch];
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
while !shutdown.load(Ordering::Relaxed) { let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
'pump: while !shutdown.load(Ordering::Relaxed) {
match client.next_audio(Duration::from_millis(5)) { match client.next_audio(Duration::from_millis(5)) {
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) { Ok(pkt) => {
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
for _ in 0..gaps.missing_before(pkt.seq) {
let plc = frame_samples * channels;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
}
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
buf.clear();
buf.extend_from_slice(&pcm[..samples * channels]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break 'pump,
}
}
}
match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => { Ok(samples) => {
frame_samples = samples;
let n = samples * channels; let n = samples * channels;
for &s in &pcm[..n] { for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs()); window_peak = window_peak.max(s.abs());
@@ -393,7 +417,8 @@ fn decode_loop(
} }
} }
Err(e) => log::debug!("audio: opus decode: {e}"), Err(e) => log::debug!("audio: opus decode: {e}"),
}, }
}
Err(PunktfunkError::NoFrame) => {} // timeout Err(PunktfunkError::NoFrame) => {} // timeout
Err(_) => break, // session closed Err(_) => break, // session closed
} }
+84 -39
View File
@@ -18,7 +18,7 @@ use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Frame; use punktfunk_core::session::Frame;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{mpsc, Arc, Mutex}; use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -202,6 +202,8 @@ fn run_sync(
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input // The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe // pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
// round-trip) and we only pop the next one once it's queued. // round-trip) and we only pop the next one once it's queued.
@@ -213,12 +215,12 @@ fn run_sync(
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated // Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the // host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
// HUD flags it "(same-host clock)"). // HUD flags it "(same-host clock)").
let clock_offset = client.clock_offset_ns; let clock_offset = client.clock_offset_shared();
// Display stage (spec `display` + the capture→displayed headline): frames released with // Display stage (spec `display` + the capture→displayed headline): frames released with
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with // render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount, // SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below. // reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset); let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let render_cb = install_render_callback(&codec, &tracker); let render_cb = install_render_callback(&codec, &tracker);
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded // HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back // point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
@@ -256,6 +258,7 @@ fn run_sync(
// the output buffer) for the decoded-point pairing in `drain`. // the output buffer) for the decoded-point pairing in `drain`.
if stats.enabled() { if stats.enabled() {
let received_ns = now_realtime_ns(); let received_ns = now_realtime_ns();
let clock_offset = clock_offset.load(Ordering::Relaxed);
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128; let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000) let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
.then_some((lat_ns / 1000) as u64); .then_some((lat_ns / 1000) as u64);
@@ -295,7 +298,13 @@ fn run_sync(
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout. // and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
let work_t0 = Instant::now(); let work_t0 = Instant::now();
if let Some(frame) = pending.take() { if let Some(frame) = pending.take() {
if feed(&codec, &frame.data, frame.pts_ns / 1000) { if feed(
&codec,
&client,
&frame.data,
frame.pts_ns / 1000,
&mut oversized_dropped,
) {
fed += 1; fed += 1;
if fed % 300 == 0 { if fed % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}"); log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
@@ -320,7 +329,7 @@ fn run_sync(
wait, wait,
&stats, &stats,
&mut in_flight, &mut in_flight,
clock_offset, clock_offset.load(Ordering::Relaxed),
&tracker, &tracker,
); );
rendered += r; rendered += r;
@@ -418,8 +427,10 @@ fn now_monotonic_ns() -> i128 {
/// endpoint whenever the platform delivers render callbacks). /// endpoint whenever the platform delivers render callbacks).
struct DisplayTracker { struct DisplayTracker {
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample. /// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample
clock_offset: i64, /// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
/// keeps the leaked render-callback refcount from pinning the whole session alive.
clock_offset: Arc<AtomicI64>,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order, /// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the /// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden. /// callback early-outs) while the overlay is hidden.
@@ -427,7 +438,10 @@ struct DisplayTracker {
} }
impl DisplayTracker { impl DisplayTracker {
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> { fn new(
stats: Arc<crate::stats::VideoStats>,
clock_offset: Arc<AtomicI64>,
) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker { Arc::new(DisplayTracker {
stats, stats,
clock_offset, clock_offset,
@@ -554,7 +568,8 @@ unsafe extern "C" fn on_frame_rendered(
} }
} }
} }
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000; let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64); let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us); t.stats.note_displayed(e2e_us, display_us);
@@ -827,13 +842,13 @@ fn run_async(
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded // pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the // output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible. // HUD is visible.
let clock_offset = client.clock_offset_ns; let clock_offset = client.clock_offset_shared();
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new())); let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is // Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// parked in the tracker at release; the OnFrameRendered callback pairs it with // parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount, // SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below. // reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset); let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let render_cb = install_render_callback(&codec, &tracker); let render_cb = install_render_callback(&codec, &tracker);
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that // Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
@@ -842,19 +857,13 @@ fn run_async(
let client = client.clone(); let client = client.clone();
let stats = stats.clone(); let stats = stats.clone();
let in_flight = in_flight.clone(); let in_flight = in_flight.clone();
let clock_offset = clock_offset.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
let ev_tx = ev_tx.clone(); let ev_tx = ev_tx.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-decode-feed".into()) .name("pf-decode-feed".into())
.spawn(move || { .spawn(move || {
feeder_loop( feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
client,
stats,
in_flight,
clock_offset as i128,
shutdown,
ev_tx,
);
}) })
.ok() .ok()
}; };
@@ -878,6 +887,8 @@ fn run_async(
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
let mut last_dropped = client.frames_dropped(); let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None; let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is // Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
@@ -922,14 +933,21 @@ fn run_async(
if fmt_dirty { if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds); apply_hdr_dataspace(&codec, &window, &mut applied_ds);
} }
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed); feed_ready(
&codec,
&client,
&mut pending_aus,
&mut free_inputs,
&mut fed,
&mut oversized_dropped,
);
let had_output = !ready.is_empty(); let had_output = !ready.is_empty();
present_ready( present_ready(
&codec, &codec,
&mut ready, &mut ready,
&stats, &stats,
&in_flight, &in_flight,
clock_offset, clock_offset.load(Ordering::Relaxed),
&tracker, &tracker,
&mut rendered, &mut rendered,
&mut discarded, &mut discarded,
@@ -999,7 +1017,7 @@ fn feeder_loop(
client: Arc<NativeClient>, client: Arc<NativeClient>,
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>, in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
clock_offset: i128, clock_offset: Arc<AtomicI64>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
ev_tx: mpsc::Sender<DecodeEvent>, ev_tx: mpsc::Sender<DecodeEvent>,
) { ) {
@@ -1010,6 +1028,7 @@ fn feeder_loop(
Ok(frame) => { Ok(frame) => {
if stats.enabled() { if stats.enabled() {
let received_ns = now_realtime_ns(); let received_ns = now_realtime_ns();
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128; let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
let lat_us = let lat_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64); (lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
@@ -1089,12 +1108,15 @@ fn dispatch_event(
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from /// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and /// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
/// submitted; a too-large AU is truncated (logged) rather than dropped. /// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
fn feed_ready( fn feed_ready(
codec: &MediaCodec, codec: &MediaCodec,
client: &NativeClient,
pending_aus: &mut VecDeque<Frame>, pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>, free_inputs: &mut VecDeque<usize>,
fed: &mut u64, fed: &mut u64,
oversized_dropped: &mut u64,
) { ) {
while !pending_aus.is_empty() && !free_inputs.is_empty() { while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap(); let idx = free_inputs.pop_front().unwrap();
@@ -1105,14 +1127,20 @@ fn feed_ready(
continue; continue;
}; };
let au = &frame.data; let au = &frame.data;
let n = au.len().min(dst.len()); if au.len() > dst.len() {
if n < au.len() { // The slot was never queued, so it stays ours — recycle it for the next AU.
free_inputs.push_front(idx);
*oversized_dropped += 1;
log::warn!( log::warn!(
"decode: AU {} > input buffer {}, truncated", "decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
au.len(), au.len(),
dst.len() dst.len(),
*oversized_dropped
); );
let _ = client.request_keyframe();
continue;
} }
let n = au.len();
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid // SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n]. // for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
unsafe { unsafe {
@@ -1298,27 +1326,44 @@ fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bo
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns /// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and /// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and /// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
/// parking it forever would wedge the loop on a broken codec). /// parking it forever would wedge the loop on a broken codec). An AU larger than the input
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool { /// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
/// corrupt input the decoder chews on silently, poisoning the reference chain.
fn feed(
codec: &MediaCodec,
client: &NativeClient,
au: &[u8],
pts_us: u64,
oversized_dropped: &mut u64,
) -> bool {
match codec.dequeue_input_buffer(Duration::ZERO) { match codec.dequeue_input_buffer(Duration::ZERO) {
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => { Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
let n = { let n = {
let dst = buf.buffer_mut(); let dst = buf.buffer_mut();
let n = au.len().min(dst.len()); if au.len() > dst.len() {
if n < au.len() { *oversized_dropped += 1;
log::warn!( log::warn!(
"decode: AU {} > input buffer {}, truncated", "decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
au.len(), au.len(),
dst.len() dst.len(),
*oversized_dropped
);
let _ = client.request_keyframe();
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
} else {
let n = au.len();
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
// the cast write initializes exactly `dst[..n]`.
unsafe {
std::ptr::copy_nonoverlapping(
au.as_ptr(),
dst.as_mut_ptr().cast::<u8>(),
n,
); );
} }
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer), both
// valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so the cast
// write initializes exactly `dst[..n]`.
unsafe {
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
}
n n
}
}; };
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) { if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer: {e}"); log::warn!("decode: queue_input_buffer: {e}");
+18 -6
View File
@@ -24,8 +24,12 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03; const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. /// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed. /// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread. /// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv, _env: JNIEnv,
@@ -37,12 +41,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
if handle == 0 { if handle == 0 {
return -1; return -1;
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll // the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them — unbounded) before nativeClose frees the handle. // threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) { match h.client.next_rumble_ttl(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), Ok((_pad, low, high, ttl)) => {
// The reorder gate already ran in the core, so this update is fresh. Encode the
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim.
let (lease_flag, ttl_bits) = match ttl {
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
None => (0, 0),
};
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
}
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
} }
}) })
@@ -161,6 +161,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
} }
}, },
preferred_codec.clamp(0, u8::MAX as jint) as u8, preferred_codec.clamp(0, u8::MAX as jint) as u8,
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
None,
launch, // a store-qualified library id to boot into a game, or None for the desktop launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous) identity, // owned (cert, key) PEM, or None (anonymous)
@@ -376,7 +376,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -412,7 +412,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -449,7 +449,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -490,7 +490,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -522,7 +522,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -552,7 +552,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -68,25 +68,28 @@ struct ContentView: View {
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar /// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video. /// never covers the video.
@State private var isFullscreen = false @State private var isFullscreen = false
#endif
#if os(macOS) || os(tvOS)
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability /// Shows the start-of-stream shortcut banner (the Windows client's discoverability
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own /// pattern): raised on every transition to `.streaming`, dropped by the banner's own
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with /// 6-second task. Independent of the stats HUD so the keys are discoverable even with
/// statistics off. /// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
/// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false @State private var showShortcutHint = false
#endif #endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
#if os(iOS) || os(macOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for // A connected controller (+ the Settings toggle) swaps the whole home screen for
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI see `home` below. // GamepadHomeView instead of retrofitting HomeView's touch/desktop UI see `home` below.
// On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working;
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive( GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
} }
#endif
var body: some View { var body: some View {
Group { Group {
@@ -108,7 +111,7 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in .onChange(of: model.phase) { _, phase in
switch phase { switch phase {
case .streaming: case .streaming:
#if os(macOS) #if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start showShortcutHint = true // the 6 s shortcut banner, per session start
#endif #endif
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
@@ -278,13 +281,30 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#elseif os(iOS) #else
Group { Group {
if gamepadUIActive { if gamepadUIActive {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0) }, connectDiscovered: connectDiscovered)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
// never double-present against HomeView's routes). Menu closes a cover the same
// way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony.
#if os(tvOS)
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
.onExitCommand { libraryTarget = nil }
}
#endif
} else { } else {
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
@@ -295,14 +315,6 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#else
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
#endif #endif
} }
@@ -362,11 +374,14 @@ struct ContentView: View {
#else #else
.background(Color.black) .background(Color.black)
.ignoresSafeArea() .ignoresSafeArea()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable // SWALLOW Menu/B during a session a game controller's B button ALSO surfaces as this
// disconnect control during play, the controller's buttons flow to the host instead of // UIKit menu press, so the old instant-disconnect here ended the session on every B
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the // press in gameplay. The button still reaches the host via GamepadCapture; the
// host as Start the Siri Remote is the intended disconnect path. // DELIBERATE exits are holding the remote's Back 1 s (SiriRemotePointer) and holding
.onExitCommand { model.disconnect() } // L1+R1+Start+Select 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by
// the start-of-stream banner. The empty handler is what keeps the press from bubbling
// out and suspending the app.
.onExitCommand {}
#endif #endif
} }
@@ -418,17 +433,18 @@ struct ContentView: View {
} }
.animation(.smooth(duration: 0.28), value: statsVerbosity) .animation(.smooth(duration: 0.28), value: statsVerbosity)
} }
#if os(macOS) #if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the full // The start-of-stream shortcut banner (Windows-client parity): the platform's
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of // reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
// every session independent of the stats HUD, so the keys are discoverable // every session independent of the stats HUD, so the keys are discoverable
// even with statistics off. The banner's own task drops it (cancelled cleanly // even with statistics off. The banner's own task drops it (cancelled cleanly
// if the session view goes away first). // if the session view goes away first). On tvOS it carries the ONLY exits
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
// session branch), so the hold gestures must be told to the user.
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint { if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · " Text(Self.shortcutHintText)
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats") .font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -472,6 +488,17 @@ struct ContentView: View {
} }
} }
#if os(macOS)
private static let shortcutHintText =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
private static let shortcutHintFont: CGFloat = 12
#elseif os(tvOS)
private static let shortcutHintText =
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
// MARK: - Connect // MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
@@ -1,13 +1,15 @@
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) the controller counterpart of // The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) the controller counterpart of
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address / // AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings; // port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end // A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the // without touching the screen. Field edits are live (the row shows every keystroke); B closes the
// keyboard first, then cancels the screen the same "back peels one layer" rule as a console UI. // keyboard first, then cancels the screen the same "back peels one layer" rule as a console UI.
// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike
// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View { struct GamepadAddHostView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
isActive: editing == nil isActive: editing == nil
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Add Host") Text("Add Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
if !compact { if !compact {
Text("Hosts on this network appear automatically — add one by address " Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.") + "for everything else.")
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 440) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
} }
} }
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in .onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) } if value.count > 5 { port = String(value.prefix(5)) }
} }
#if os(tvOS)
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
// tray the remote and the pad both drive it natively. Same `editing` state as the
// tray, just a different presentation; done (or Menu, edits-stick) commits and returns.
.fullScreenCover(isPresented: Binding(
get: { editing != nil },
set: { if !$0 { editing = nil } })
) {
if let field = editing {
TVTextEntry(
title: fieldTitle(field),
text: editingBinding(field).wrappedValue,
keyboardType: keyboardType(field)
) { value in
commitEntry(field, value)
editing = nil
}
}
}
#endif
} }
/// The keyboard tray while editing, the controls legend otherwise. /// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the
/// tray `editing` presents the system keyboard cover instead so it's legend-only there.)
@ViewBuilder private var bottomTray: some View { @ViewBuilder private var bottomTray: some View {
#if os(tvOS)
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
])
.frame(maxWidth: .infinity, alignment: .leading)
#else
if let editing { if let editing {
VStack(spacing: 10) { VStack(spacing: 10) {
GamepadKeyboard( GamepadKeyboard(
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
]) ])
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
#endif
} }
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc /// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
@@ -111,14 +142,16 @@ struct GamepadAddHostView: View {
private var closeButton: some View { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel") .accessibilityLabel("Cancel")
} }
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
} }
private func rowView(_ row: Row, focused: Bool) -> some View { private func rowView(_ row: Row, focused: Bool) -> some View {
HStack(spacing: 14) { let m = GamepadFormMetrics.self
return HStack(spacing: 14) {
if row.isAction { if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill") Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
Spacer(minLength: 12) Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value) Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(15, .medium)) .font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing .truncationMode(.head) // keep the end of a long address visible while typing
@@ -162,20 +196,20 @@ struct GamepadAddHostView: View {
// The live-edit caret: this row is what the keyboard tray is typing into. // The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle() Rectangle()
.fill(Color.brand) .fill(Color.brand)
.frame(width: 2, height: 18) .frame(width: 2, height: m.labelFont + 2)
} }
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row // Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
// takes the brand wash, and the edited row keeps its brand caret border. // takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder( .strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1) lineWidth: 1)
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
default: return nil default: return nil
} }
} }
#if os(tvOS)
// MARK: - System keyboard plumbing (see the fullScreenCover on `body`)
private func fieldTitle(_ id: String) -> String {
switch id {
case "name": return "Name (optional)"
case "port": return "Port"
default: return "Address (IP or hostname)"
}
}
/// .URL for the address (dots on the primary layer, no autocapitalize) the closest tvOS
/// keyboard to "hostname or IP".
private func keyboardType(_ id: String) -> UIKeyboardType {
switch id {
case "port": return .numberPad
case "address": return .URL
default: return .default
}
}
/// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke
/// on the other platforms (the system keyboard will type anything).
private func commitEntry(_ id: String, _ value: String) {
switch id {
case "port":
editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5))
case "address":
editingBinding(id).wrappedValue = value
.replacingOccurrences(of: " ", with: "")
default:
editingBinding(id).wrappedValue = value
}
}
#endif
} }
#endif #endif
@@ -1,6 +1,11 @@
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the // The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely // library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
// by a controller (iOS/iPadOS/macOS). // by a controller (iOS/iPadOS/macOS) or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
// focusable Button, so the Siri Remote and a game controller both navigate through the system
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
// carries ONLY the Y/X actions (library/settings) buttons the focus engine has no concept of.
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
// //
// The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` // The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live // snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
@@ -24,7 +29,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable { struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared)
#if os(tvOS)
/// tvOS: the focus engine is the navigation authority `cursor`/`scrolledID` chase this,
/// never the other way around (mirroring the poll's cursor-first discipline).
@FocusState private var focusedID: Item.ID?
#endif
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back /// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving that's the whole desync fix. /// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0 @State private var cursor = 0
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2) let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollViewReader { proxy in
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: spacing) { HStack(spacing: spacing) {
ForEach(items) { item in ForEach(items) { item in
#if os(tvOS)
// A focusable Button per card: the focus engine does the navigating
// (remote swipes and pad dpad alike), select activates. The bare style
// below keeps the tile's own look the `.scrollTransition` center pop
// is the focus treatment, since focus and center track each other.
Button { activate(item) } label: {
card(item)
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item) card(item)
.frame(width: itemWidth) .frame(width: itemWidth)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(item) } .onTapGesture { tap(item) }
#endif
} }
} }
.frame(height: geo.size.height) // fill so shorter cards center vertically .frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout() .scrollTargetLayout()
} }
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
// the poll's cursor design exists to avoid see the header), and on tvOS the
// focus engine's own reveal-scrolls are always in flight, so drops were routine
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
// below instead scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
#if !os(tvOS)
.scrollPosition(id: $scrolledID) .scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden // .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip. // and paints a scroller across the console strip.
.scrollIndicators(.never) .scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds .scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset) .safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset) .offset(x: bumpOffset)
#if os(tvOS)
// Land initial focus on the first card (the launcher's first host / the coverflow's
// first title) instead of wherever the engine guesses.
.defaultFocus($focusedID, items.first?.id)
// Focus moved (remote swipe / pad dpad) chase it: cursor, detail selection,
// controller detent, and an imperative center scroll.
.onChange(of: focusedID) { _, newValue in
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
lastNav = Date()
haptics.move()
selection = newValue
withAnimation(.easeOut(duration: scrollAnim)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
// The list changed under a stable focus (discovered hosts prepend tiles): the
// content shifted but no focus change fires above re-center the focused card.
.onChange(of: items.map(\.id)) { _, _ in
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
}
#endif
}
} }
.sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a // A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
// programmatic scroll is animating (its own intermediate id write-backs would regress the // programmatic scroll is animating (its own intermediate id write-backs would regress the
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag // cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
// which never sets `isScrolling` moves the cursor here. // which never sets `isScrolling` moves the cursor here. Not on tvOS: there is no touch
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
#if !os(tvOS)
.onChange(of: scrolledID) { _, newValue in .onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return } guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return } guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx cursor = idx
selection = newValue selection = newValue
} }
#endif
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the // Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
// current `items` value (a plain array unlike an observed object it would otherwise go // current `items` value (a plain array unlike an observed object it would otherwise go
// stale in the closures stored on `input`). // stale in the closures stored on `input`).
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { private func wire() {
#if os(tvOS)
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
// working on this screen and what routes Menu through the system's back semantics).
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
input.onSecondary = onSecondary
input.onTertiary = onTertiary
#else
input.onMove = { move($0) } input.onMove = { move($0) }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onSecondary = onSecondary input.onSecondary = onSecondary
input.onTertiary = onTertiary input.onTertiary = onTertiary
input.onBack = onBack input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
#endif
} }
private func move(_ direction: GamepadMenuInput.Direction) { private func move(_ direction: GamepadMenuInput.Direction) {
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
private func activate() { private func activate() {
guard cursor >= 0, cursor < items.count else { return } guard cursor >= 0, cursor < items.count else { return }
activate(items[cursor])
}
/// Shared confirm tail the poll activates the cursor's item, a tvOS Button its own.
private func activate(_ item: Item) {
activateTick &+= 1 activateTick &+= 1
haptics.confirm() haptics.confirm()
onActivate(items[cursor]) onActivate(item)
} }
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping /// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
scrolledID = id scrolledID = id
selection = id selection = id
} }
#if os(tvOS)
// Keep real focus on the reconciled item when its old target vanished from the list
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
focusedID = items[cursor].id
}
#endif
} }
private func boundaryBump(forward: Bool) { private func boundaryBump(forward: Bool) {
@@ -2,11 +2,12 @@
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the // GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// controller-glyph hint bar, and the connected-controller status chip. One look across every // controller-glyph hint bar, and the connected-controller status chip. One look across every
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages. // screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead. // iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via /// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#endif #endif
} }
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
/// in-hand compact-aware sizes elsewhere.
func gamepadTitleSize(compact: Bool) -> CGFloat {
#if os(tvOS)
44
#else
compact ? 20 : 30
#endif
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
#endif
}
/// One glyph + label cell in a hint bar. /// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable { struct GamepadHint: Identifiable {
let glyph: String let glyph: String
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
struct GamepadHintBar: View { struct GamepadHintBar: View {
let hints: [GamepadHint] let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View { var body: some View {
HStack(spacing: 18) { HStack(spacing: 18) {
ForEach(hints) { hint in ForEach(hints) { hint in
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: hint.glyph) Image(systemName: hint.glyph)
.font(.system(size: 19)) .font(.system(size: Self.glyphFont))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(hint.text) Text(hint.text)
} }
.fixedSize() // keep glyph + label together; never truncate a hint mid-word .fixedSize() // keep glyph + label together; never truncate a hint mid-word
} }
} }
.font(.geist(14, .semibold, relativeTo: .subheadline)) .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85)) .foregroundStyle(.white.opacity(0.85))
.padding(13) .padding(Self.pad)
.consoleGlass(Capsule()) .consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
} }
@@ -297,30 +354,56 @@ struct GamepadFormBackground: View {
} }
} }
#if os(tvOS)
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
/// interactive-glass feel elsewhere.
struct ConsoleBareButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
}
}
#endif
/// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet /// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes. /// when the pad or its battery state changes.
struct ControllerStatusChip: View { struct ControllerStatusChip: View {
let controller: GamepadManager.DiscoveredController let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
#if os(tvOS)
private static let font: CGFloat = 17
private static let hPad: CGFloat = 16
private static let vPad: CGFloat = 10
#else
private static let font: CGFloat = 12
private static let hPad: CGFloat = 12
private static let vPad: CGFloat = 7
#endif
var body: some View { var body: some View {
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: controller.hasTouchpadAndMotion Image(systemName: controller.hasTouchpadAndMotion
? "playstation.logo" : "gamecontroller.fill") ? "playstation.logo" : "gamecontroller.fill")
.font(.system(size: 12)) .font(.system(size: Self.font))
Text(controller.name) Text(controller.name)
.lineLimit(1) .lineLimit(1)
if let level = controller.batteryLevel { if let level = controller.batteryLevel {
Image(systemName: batterySymbol(level)) Image(systemName: batterySymbol(level))
.font(.system(size: 12)) .font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging .foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
} }
} }
.font(.geist(12, .medium, relativeTo: .caption)) .font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 12) .padding(.horizontal, Self.hPad)
.padding(.vertical, 7) .padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08))) .background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
} }
@@ -1,4 +1,4 @@
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host // The gamepad-driven home screen: a distinct, "10-foot" console-style host
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built // launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is // around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the // required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
@@ -14,11 +14,13 @@
// `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's // `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's
// vertical budget and the card is sized off the remaining height. macOS mounts it too (the // vertical budget and the card is sized off the remaining height. macOS mounts it too (the
// couch Mac-mini case) same screen, with the settings/add-host covers presented as sheets // couch Mac-mini case) same screen, with the settings/add-host covers presented as sheets
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead). // (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
// mirrors X for Settings since the focus engine has no concept of that button.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host /// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
@@ -60,8 +62,9 @@ struct GamepadHomeView: View {
let connect: (StoredHost) -> Void let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses. /// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
#if os(iOS) #if os(iOS)
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits. /// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass @Environment(\.verticalSizeClass) private var vSizeClass
@@ -104,6 +107,12 @@ struct GamepadHomeView: View {
try? await Task.sleep(for: .seconds(10)) try? await Task.sleep(for: .seconds(10))
} }
} }
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
// X, and historically tvOS maps a pad's X to this same press the poll and this command
// double-firing just sets the same Bool twice.
#if os(tvOS)
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive` // The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). iOS presents them full screen the immersive console feel; macOS has no // gate above). iOS presents them full screen the immersive console feel; macOS has no
// fullScreenCover, so they become generously sized sheets over the dimmed launcher. // fullScreenCover, so they become generously sized sheets over the dimmed launcher.
@@ -128,11 +137,17 @@ struct GamepadHomeView: View {
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View { @ViewBuilder private func hero(for size: CGSize) -> some View {
#if os(tvOS)
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
let cardWidth = min(560, size.width * 0.34)
let cardHeight = min(350, max(240, size.height - 260))
#else
let cardWidth = min(340, size.width * 0.84) let cardWidth = min(340, size.width * 0.84)
// 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip // 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
// always fits the region the pinned title / hints safe-area insets leave. (The old detail // always fits the region the pinned title / hints safe-area insets leave. (The old detail
// line below the strip is gone it only re-printed what the centered card already shows.) // line below the strip is gone it only re-printed what the centered card already shows.)
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48)) let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
#endif
VStack(spacing: compact ? 8 : 10) { VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0) Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight) carousel(cardWidth: cardWidth, cardHeight: cardHeight)
@@ -145,7 +160,7 @@ struct GamepadHomeView: View {
private var titleBar: some View { private var titleBar: some View {
Text("Select a Host") Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
@@ -158,6 +173,14 @@ struct GamepadHomeView: View {
} }
} }
private var cardSpacing: CGFloat {
#if os(tvOS)
44
#else
30
#endif
}
// MARK: - Carousel // MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View { private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
@@ -165,7 +188,7 @@ struct GamepadHomeView: View {
items: tiles, items: tiles,
selection: $selection, selection: $selection,
itemWidth: cardWidth, itemWidth: cardWidth,
spacing: 30, spacing: cardSpacing,
onActivate: { $0.activate() }, onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() }, onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true }, onTertiary: { showSettings = true },
@@ -272,6 +295,31 @@ private struct GamepadHostTile: View {
let tile: HomeTile let tile: HomeTile
let size: CGSize let size: CGSize
// 10-foot metrics on tvOS, in-hand metrics elsewhere one tile, two viewing distances.
#if os(tvOS)
private static let titleFont: CGFloat = 33
private static let subtitleFont: CGFloat = 19
private static let statusFont: CGFloat = 15
private static let pipSide: CGFloat = 12
private static let badgeSide: CGFloat = 70
private static let badgeCorner: CGFloat = 19
private static let monogramFont: CGFloat = 34
private static let iconFont: CGFloat = 32
private static let pad: CGFloat = 28
private static let corner: CGFloat = 30
#else
private static let titleFont: CGFloat = 23
private static let subtitleFont: CGFloat = 13
private static let statusFont: CGFloat = 11
private static let pipSide: CGFloat = 9
private static let badgeSide: CGFloat = 52
private static let badgeCorner: CGFloat = 15
private static let monogramFont: CGFloat = 25
private static let iconFont: CGFloat = 24
private static let pad: CGFloat = 20
private static let corner: CGFloat = 26
#endif
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
@@ -282,38 +330,38 @@ private struct GamepadHostTile: View {
HStack(spacing: 7) { HStack(spacing: 7) {
if tile.isPaired { if tile.isPaired {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 11, weight: .semibold)) .font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(.white.opacity(0.5))
} }
if tile.isOnline { if tile.isOnline {
Circle() Circle()
.fill(Color.green) .fill(Color.green)
.frame(width: 9, height: 9) .frame(width: Self.pipSide, height: Self.pipSide)
.shadow(color: .green.opacity(0.7), radius: 5) .shadow(color: .green.opacity(0.7), radius: 5)
} }
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
Text(tile.title) Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
Text(tile.subtitle) Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption)) .font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(1) .lineLimit(1)
.padding(.top, 2) .padding(.top, 2)
} }
.padding(20) .padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading) .frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered / // Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 26, style: .continuous), RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil) tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous) RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder( .strokeBorder(
LinearGradient( LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)], colors: [.white.opacity(0.22), .white.opacity(0.04)],
@@ -324,7 +372,7 @@ private struct GamepadHostTile: View {
} }
private var monogramBadge: some View { private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous) let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
return ZStack { return ZStack {
shape.fill(tile.filled shape.fill(tile.filled
? AnyShapeStyle(LinearGradient( ? AnyShapeStyle(LinearGradient(
@@ -335,15 +383,15 @@ private struct GamepadHostTile: View {
ProgressView().tint(.white) ProgressView().tint(.white)
} else if let icon = tile.icon { } else if let icon = tile.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 24, weight: .semibold)) .font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand) .foregroundStyle(Color.brand)
} else { } else {
Text(monogram(tile.title)) Text(monogram(tile.title))
.font(.geistFixed(25, .bold)) .font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand) .foregroundStyle(tile.filled ? .white : Color.brand)
} }
} }
.frame(width: 52, height: 52) .frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay { .overlay {
if !tile.filled { if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
@@ -1,8 +1,14 @@
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for // The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a // for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs // a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus // backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
// cursor, controller polling, haptics, and keeping the focused row scrolled into view. // focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
//
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu via
// onExitCommand backs out). Left/right value-adjust isn't wired there; select cycles a value
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
// poll-driven behavior is untouched by the tvOS mode.
// //
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the // Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a // cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
@@ -16,7 +22,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable { struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared)
#if os(tvOS)
/// tvOS: the focus engine is the navigation authority for UP/DOWN `cursor` chases this, so
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware keyboard arrows were
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
@FocusState private var focusedID: Item.ID?
#endif
/// Authoritative focus cursor (index into `items`). /// Authoritative focus cursor (index into `items`).
@State private var cursor = 0 @State private var cursor = 0
/// A short vertical recoil when a move is refused at a list end. /// A short vertical recoil when a move is refused at a list end.
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
ScrollView(.vertical) { ScrollView(.vertical) {
LazyVStack(spacing: 6) { LazyVStack(spacing: 6) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
#if os(tvOS)
// A focusable Button per row: the engine moves between them, select
// activates (`tap` keeps the cursor in step before firing). The row's
// own `focused` styling is the focus treatment the bare style adds
// no system chrome on top of it.
Button { tap(idx) } label: {
row(item, focusedID == item.id)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
row(item, idx == cursor && isActive) row(item, idx == cursor && isActive)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(idx) } .onTapGesture { tap(idx) }
.id(item.id) .id(item.id)
#endif
} }
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
} }
} }
} }
#if os(tvOS)
// Focus moved (remote swipe / pad dpad) keep the cursor, the caller's focusID mirror,
// and the controller detent in step. Menu = the list's back action (both tvOS callers
// pass one; the screen behind would otherwise catch the press and peel too far).
.onChange(of: focusedID) { _, newValue in
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
idx != cursor else { return }
cursor = idx
focusID = id
haptics.move()
}
.defaultFocus($focusedID, items.first?.id)
.onExitCommand { onBack?() }
#endif
.sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.selection, trigger: adjustTick) .sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { private func wire() {
#if os(tvOS)
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) the
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
// are exactly the adjust feel the other platforms have, and where the focus engine has
// nothing to move to in a vertical list. Vertical poll directions are deliberately
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
// never reaches this poll no extended profile so remote users cycle values with
// select instead, which `activate` already does.)
input.onMove = { direction in
switch direction {
case .left: adjust(by: -1)
case .right: adjust(by: 1)
case .up, .down: break
}
}
#else
input.onMove = { direction in input.onMove = { direction in
switch direction { switch direction {
case .up: step(by: -1) case .up: step(by: -1)
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
} }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onBack = onBack input.onBack = onBack
#endif
} }
private func step(by delta: Int) { private func step(by delta: Int) {
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
haptics.move() haptics.move()
} }
private func adjust(by delta: Int) { private func adjust(by delta: Int) {
guard let onAdjust, cursor >= 0, cursor < items.count else { return } guard let onAdjust, cursor >= 0, cursor < items.count else { return }
if onAdjust(items[cursor], delta) { if onAdjust(items[cursor], delta) {
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
cursor = min(max(cursor, 0), items.count - 1) cursor = min(max(cursor, 0), items.count - 1)
focusID = items[cursor].id focusID = items[cursor].id
} }
#if os(tvOS)
// Keep real focus on the reconciled row when its old target vanished from the list.
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
focusedID = items[cursor].id
}
#endif
} }
private func boundaryBump(forward: Bool) { private func boundaryBump(forward: Bool) {
@@ -29,8 +29,9 @@ struct HomeView: View {
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online /// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker. /// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void let wake: (StoredHost) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action. /// Game-library browser (default ON; the Settings toggle opts out) the host-card
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet. /// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost? @State private var editTarget: StoredHost?
@@ -48,6 +49,13 @@ struct HomeView: View {
} }
} }
.padding() .padding()
// Mirror of the action row's focusSection below: an UPWARD move from
// the centered buttons must land back in the grid even when no card
// sits in the buttons' columns (a lone top-left card, say). The grid
// spans the row, so the section catches every upward ray.
#if os(tvOS)
.focusSection()
#endif
} }
if !discoveredUnsaved.isEmpty { if !discoveredUnsaved.isEmpty {
discoveredSection discoveredSection
@@ -67,6 +75,14 @@ struct HomeView: View {
} }
} }
.padding(.top, 24) .padding(.top, 24)
// One FULL-WIDTH focus target for any downward move out of the grid.
// focusSection alone is not enough: the engine tests the section's
// FRAME, and a content-hugging centered HStack only overlaps the middle
// columns a swipe down from an outer card dead-ends and the actions
// are unreachable by remote. Stretching the section across the row means
// every column's downward ray hits it.
.frame(maxWidth: .infinity)
.focusSection()
#endif #endif
} }
} }
@@ -198,6 +214,10 @@ struct HomeView: View {
} }
.padding([.horizontal, .bottom]) .padding([.horizontal, .bottom])
.padding(.top, store.hosts.isEmpty ? 0 : 8) .padding(.top, store.hosts.isEmpty ? 0 : 8)
// Same reachability contract as the saved grid above see its focusSection comment.
#if os(tvOS)
.focusSection()
#endif
} }
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` shared with the gamepad /// Discovered hosts not already saved (see `HostDiscovery.unsaved` shared with the gamepad
@@ -259,7 +279,9 @@ struct HomeView: View {
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)] [GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#elseif os(tvOS) #elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)] // Tracks CardMetrics' 10-foot sizes at the 30pt name a 320pt column truncates
// every hostname longer than ~10 characters.
[GridItem(.adaptive(minimum: 460), spacing: 48)]
#else #else
[GridItem(.adaptive(minimum: 280), spacing: 16)] [GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif #endif
@@ -22,8 +22,9 @@ private struct CardMetrics {
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11, CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12) padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS) #elseif os(tvOS)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14, // 10-foot sizes the 24pt-name tier read like a phone card from the couch.
padding: 18, spacing: 18, radius: 14) CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
padding: 24, spacing: 22, radius: 18)
#else #else
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5, CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
padding: 13, spacing: 12, radius: 10) padding: 13, spacing: 12, radius: 10)
@@ -1,4 +1,4 @@
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS see LibraryView's // The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS see LibraryView's
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the // `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card // scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and // (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
@@ -15,7 +15,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct LibraryCoverflowView: View { struct LibraryCoverflowView: View {
@@ -21,9 +21,9 @@ struct LibraryView: View {
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; /// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session. /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession? @State private var imageSession: URLSession?
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing (iOS/iPadOS/macOS) see ContentView's identical gate. tvOS keeps // Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// its existing plain-grid presentation of this same view unchanged. // setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
@@ -69,7 +69,6 @@ struct LibraryView: View {
} else if games.isEmpty { } else if games.isEmpty {
emptyState emptyState
} else { } else {
#if os(iOS) || os(macOS)
if gamepadUIActive { if gamepadUIActive {
LibraryCoverflowView( LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch, games: games, imageSession: imageSession, onLaunch: onLaunch,
@@ -77,9 +76,6 @@ struct LibraryView: View {
} else { } else {
grid grid
} }
#else
grid
#endif
} }
} }
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
ContentView() ContentView()
#endif #endif
} }
// NOT on tvOS: under the tvOS 26 glass button style a tinted UNFOCUSED control fills
// AND labels itself in the tint every plain Button/TextField renders as a blank
// brand-violet pill until focused. Untinted, tvOS keeps the system glass look
// (visible labels, white focus lift); brand color stays on explicit Color.brand uses.
#if !os(tvOS)
.tint(.brand) .tint(.brand)
#endif
// Geist Sans is the app's typeface. This sets the default for unstyled text and the // Geist Sans is the app's typeface. This sets the default for unstyled text and the
// form row labels; views that pick an explicit size/weight use `.geist()` directly. // form row labels; views that pick an explicit size/weight use `.geist()` directly.
// tvOS reads from across the room: its system body is 29pt, so pinning the phone's
// 17pt there shrank every unstyled control (rows, fields, buttons) to postage size.
#if os(tvOS)
.font(.geist(29, relativeTo: .body))
#else
.font(.geist(17, relativeTo: .body)) .font(.geist(17, relativeTo: .body))
#endif
} }
// The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S // The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S
// the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard // the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard
@@ -2,6 +2,7 @@
// handshake phase, and the pump-thread main-actor stats relay. // handshake phase, and the pump-thread main-actor stats relay.
import Foundation import Foundation
import os
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -10,6 +11,15 @@ import SwiftUI
#elseif canImport(UIKit) #elseif canImport(UIKit)
import UIKit import UIKit
#endif #endif
#if os(tvOS)
import AVFoundation // AVPlayer.eligibleForHDRPlayback the TV-capability HDR gate
#endif
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published /// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread. /// values. NSLock instead of an actor the writer is the (non-async) pump thread.
@@ -119,6 +129,12 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio? private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture? private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback? private var gamepadFeedback: GamepadFeedback?
#if os(tvOS)
/// Siri Remote host pointer while streaming (touch surface moves, press = left click,
/// Play/Pause = right click) + the remote's deliberate exit (hold Back 1 s). See
/// SiriRemotePointer same trust gate/lifecycle as the gamepad capture above.
private var remotePointer: SiriRemotePointer?
#endif
var isBusy: Bool { phase != .idle } var isBusy: Bool { phase != .idle }
@@ -163,13 +179,25 @@ final class SessionModel: ObservableObject {
let displayHDR: Bool = { let displayHDR: Bool = {
#if os(macOS) #if os(macOS)
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0 return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
#elseif os(tvOS)
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
// Apple's recommended setup runs an SDR home screen with Match Content an
// HDR-capable TV would read 1.0 at connect time and never be advertised. The
// session switches the display to HDR10 itself once streaming (AVDisplayManager
// see StreamViewIOS), so gate on the TV's mode-independent capability; if the
// switch never lands, the presenter's in-shader tone-map keeps PQ safe anyway.
return AVPlayer.eligibleForHDRPlayback
#else #else
return UIScreen.main.potentialEDRHeadroom > 1.0 return UIScreen.main.potentialEDRHeadroom > 1.0
#endif #endif
}() }()
let hdrCapable = hdrEnabled && displayHDR let hdrCapable = hdrEnabled && displayHDR
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate. // 4:4:4 opt-IN (default off): full chroma is a per-client choice a clear win for
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true // desktop/text work, but at a fixed bitrate it spends bits on chroma that game content
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
// hardware-decode probe below still gates what can actually be advertised.
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? false
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main // PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired // actor. The persistent identity is presented on every connect so a paired
@@ -300,6 +328,10 @@ final class SessionModel: ObservableObject {
// connection is still up); the feedback drain joins off-main like audio. // connection is still up); the feedback drain joins off-main like audio.
gamepadCapture?.stop() gamepadCapture?.stop()
gamepadCapture = nil gamepadCapture = nil
#if os(tvOS)
remotePointer?.stop() // releases any held click while the connection is still up
remotePointer = nil
#endif
let feedback = gamepadFeedback let feedback = gamepadFeedback
gamepadFeedback = nil gamepadFeedback = nil
if let conn = connection { if let conn = connection {
@@ -363,11 +395,20 @@ final class SessionModel: ObservableObject {
// session's virtual pad is a DualSense). Same trust gate as audio nothing is // session's virtual pad is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt. // forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared) let capture = GamepadCapture(connection: conn, manager: .shared)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
capture.start() capture.start()
gamepadCapture = capture gamepadCapture = capture
let feedback = GamepadFeedback(connection: conn, manager: .shared) let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start() feedback.start()
gamepadFeedback = feedback gamepadFeedback = feedback
#if os(tvOS)
let pointer = SiriRemotePointer(connection: conn)
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
pointer.start()
remotePointer = pointer
#endif
} }
private func startStatsTimer() { private func startStatsTimer() {
@@ -429,12 +470,32 @@ final class SessionModel: ObservableObject {
} else { } else {
self.decodeValid = false self.decodeValid = false
} }
if let d = self.displayStage.drain() { let displayWindow = self.displayStage.drain()
if let d = displayWindow {
self.displayP50Ms = d.p50Ms self.displayP50Ms = d.p50Ms
self.displayValid = true self.displayValid = true
} else { } else {
self.displayValid = false self.displayValid = false
} }
// Mirror the window to the unified log (see statsLog) one line per second,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count)
// a presentsfps gap is the presenter dropping/serializing, an fps deficit is
// upstream (host capture/encode or the network).
if frames > 0 {
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
self.endToEndValid ? self.endToEndP95Ms : -1,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
lost)
statsLog.info("\(line, privacy: .public)")
}
} }
} }
// .common so the HUD keeps updating during window drags / menu tracking. // .common so the HUD keeps updating during window drags / menu tracking.
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
// this card its frame (and, on iOS, its clamped corner) animate to the new size rather // this card its frame (and, on iOS, its clamped corner) animate to the new size rather
// than cross-fading a whole new card in. Only the inner content switches per tier. // than cross-fading a whole new card in. Only the inner content switches per tier.
tierContent tierContent
.padding(10) .padding(cardPadding)
.glassBackground(cardShape) .glassBackground(cardShape)
.padding(edgeInset) .padding(edgeInset)
} }
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif #endif
#if os(tvOS)
// No focusable control during play: a focusable button steals the controller's
// A press (the focus engine consumes it before the host sees it). Disconnect is
// the Siri Remote's Menu button (.onExitCommand on the stream) just hint it.
Text("Press Menu to disconnect")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
#else
// D lives on the app's Stream menu (so it still works when the HUD is hidden) // D lives on the app's Stream menu (so it still works when the HUD is hidden)
// and in InputCapture's monitor while captured; this button is the in-overlay, // and in InputCapture's monitor while captured; this button is the in-overlay,
// click-to-disconnect affordance. // click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
// focusable control would steal the controller's A press from the host) NOR a hint
// line: the exits are the hold gestures the start-of-stream banner teaches (hold
// the remote's Back; hold L1+R1+Start+Select on a pad).
#if os(macOS) #if os(macOS)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() } Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#else #elseif os(iOS)
Button("Disconnect") { model.disconnect() } Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#endif #endif
#endif
} }
} }
// MARK: - Card metrics // MARK: - Card metrics
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.) /// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine. private var cardPadding: CGFloat {
#if os(tvOS)
return 16
#else
return 10
#endif
}
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
/// keep the classic 10.
private var edgeInset: CGFloat { private var edgeInset: CGFloat {
#if os(iOS) #if os(iOS)
return 14 return 14
#elseif os(tvOS)
return 24
#else #else
return 10 return 10
#endif #endif
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
private var cardCornerRadius: CGFloat { private var cardCornerRadius: CGFloat {
#if os(iOS) #if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset) return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else #else
return 10 return 10
#endif #endif
@@ -1,13 +1,25 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the /// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS. /// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
struct AcknowledgementsView: View { struct AcknowledgementsView: View {
private var version: String? { private var version: String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
} }
// TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license
// walls use relative system styles, which already scale per platform.)
#if os(tvOS)
private static let titleFont: CGFloat = 36
private static let headlineFont: CGFloat = 26
private static let captionFont: CGFloat = 20
#else
private static let titleFont: CGFloat = 22
private static let headlineFont: CGFloat = 17
private static let captionFont: CGFloat = 12
#endif
var body: some View { var body: some View {
ScrollView { ScrollView {
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks, // Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
@@ -16,42 +28,40 @@ struct AcknowledgementsView: View {
// notice chunks visually continuous; the header block carries its own spacing + bottom pad. // notice chunks visually continuous; the header block carries its own spacing + bottom pad.
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Text("punktfunk") Text("Punktfunk")
.font(.geist(22, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
if let version { if let version {
Text("Version \(version)") Text("Version \(version)")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(Licenses.appLicense) LicenseWall(text: Licenses.appLicense)
.font(.caption.monospaced()) .font(.caption.monospaced())
.modifier(SelectableText())
Divider() Divider()
Text("Bundled font") Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), " Text("Punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font " + "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.") + "License 1.1.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty { if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense) LicenseWall(text: Licenses.fontLicense)
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.modifier(SelectableText())
} }
Divider() Divider()
Text("Third-party software") Text("Third-party software")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text( Text(
"punktfunk uses the open-source components below, each under its own license. " "Punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ " + "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)." + "(dynamically linked, replaceable)."
) )
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText()) .modifier(SelectableText())
.modifier(TVFocusable())
} }
} }
.frame(maxWidth: 900, alignment: .leading) .frame(maxWidth: 900, alignment: .leading)
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
#endif #endif
} }
} }
/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall
/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route
/// through it suspended the whole app instead of popping. Plain (non-interactive) focusability
/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen
/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL
/// focus stops all the way down one tall focusable block would strand focus at its top and the
/// next stop could sit past the LazyVStack's instantiation window.
private struct TVFocusable: ViewModifier {
func body(content: Content) -> some View {
#if os(tvOS)
content.focusable()
#else
content
#endif
}
}
/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized
/// chunks (see TVFocusable). The caller's `.font` cascades into either form.
private struct LicenseWall: View {
let text: String
var body: some View {
#if os(tvOS)
let chunks = Licenses.chunked(text)
ForEach(chunks.indices, id: \.self) { i in
Text(chunks[i])
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(TVFocusable())
}
#else
Text(text)
.modifier(SelectableText())
#endif
}
}
@@ -1,4 +1,4 @@
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView, // The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): the couch-relevant subset of SettingsView,
// restyled as a console settings page and fully navigable with a controller up/down moves the // restyled as a console settings page and fully navigable with a controller up/down moves the
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the // focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom // gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
@@ -13,7 +13,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct GamepadSettingsView: View { struct GamepadSettingsView: View {
@@ -26,7 +26,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) private var enable444 = true @AppStorage(DefaultsKey.enable444) private var enable444 = false
@AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the // The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
@@ -34,8 +34,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS) #if os(iOS)
@@ -47,6 +48,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously private let compact = false // no size classes on macOS; the sheet is sized generously
#endif #endif
@State private var focusID: String? @State private var focusID: String?
/// 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
var body: some View { var body: some View {
GamepadMenuList( GamepadMenuList(
@@ -57,13 +61,13 @@ struct GamepadSettingsView: View {
onBack: { dismiss() } onBack: { dismiss() }
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
Text("Settings") Text("Settings")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8) .padding(.bottom, compact ? 4 : 8)
@@ -74,7 +78,7 @@ struct GamepadSettingsView: View {
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail) Text(focusedDetail)
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true) .lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID) .animation(.smooth(duration: 0.2), value: focusID)
@@ -107,61 +111,78 @@ struct GamepadSettingsView: View {
private var closeButton: some View { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Close settings") .accessibilityLabel("Close settings")
} }
// MARK: - Row rendering // MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View { private func rowView(_ row: Row, focused: Bool) -> some View {
VStack(alignment: .leading, spacing: 6) { let m = GamepadFormMetrics.self
return VStack(alignment: .leading, spacing: 6) {
if let header = row.header { if let header = row.header {
Text(header) Text(header)
.font(.geist(12, .semibold, relativeTo: .caption)) .font(.geist(m.headerFont, .semibold, relativeTo: .caption))
.tracking(1.4) .tracking(1.4)
.foregroundStyle(.white.opacity(0.45)) .foregroundStyle(.white.opacity(0.45))
.padding(.leading, 16) .padding(.leading, m.rowHPad)
.padding(.top, 14) .padding(.top, 14)
} }
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: row.icon) Image(systemName: row.icon)
.font(.system(size: 17)) .font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) .foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.frame(width: 28) .frame(width: m.iconWidth)
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 12) Spacer(minLength: 12)
HStack(spacing: 9) { HStack(spacing: 9) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 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.
// Deliberately not `.push`: that travels the whole container width, loud
// and visibly outside the row. The ZStack is the stable home the
// removed/inserted texts transition within.
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
ZStack {
Text(row.value) Text(row.value)
.font(.geist(15, .medium, relativeTo: .callout)) .font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6)) .foregroundStyle(focused ? .white : .white.opacity(0.6))
.lineLimit(1) .lineLimit(1)
.id(row.value)
.transition(.asymmetric(
insertion: .offset(x: slide).combined(with: .opacity),
removal: .offset(x: -slide).combined(with: .opacity)))
}
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0))
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil, tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
} }
.scaleEffect(focused ? 1.0 : 0.98) .scaleEffect(focused ? 1.0 : 0.98)
@@ -193,10 +214,12 @@ struct GamepadSettingsView: View {
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
/// (never on state captured at wire time). /// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool { private func adjust(id: String, by delta: Int) -> Bool {
rows.first { $0.id == id }?.adjust(delta) ?? false lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
} }
private func activate(id: String) { private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate() rows.first { $0.id == id }?.activate()
} }
@@ -252,6 +275,12 @@ struct GamepadSettingsView: View {
detail: "Sharper text and UI at more bandwidth — needs host opt-in and " detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
+ "hardware decode.", + "hardware decode.",
value: $enable444), value: $enable444),
choiceRow(
id: "presenter", icon: "rectangle.stack", label: "Presenter",
detail: "Stage 3 paces presents to the display — lowest display latency. "
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
options: SettingsOptions.presenters, current: presenter
) { presenter = $0 },
choiceRow( choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -287,8 +316,7 @@ struct GamepadSettingsView: View {
) { hudPlacement = $0 }, ) { hudPlacement = $0 },
toggleRow( toggleRow(
id: "library", icon: "square.grid.2x2", label: "Game library", id: "library", icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) " detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
+ "(experimental).",
value: $libraryEnabled), value: $libraryEnabled),
toggleRow( toggleRow(
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
@@ -37,6 +37,30 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] = static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS runs
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
/// an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(tvOS)
"stage3"
#else
"stage2"
#endif
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value. /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] = static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) } StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
@@ -105,8 +129,8 @@ enum SettingsOptions {
return options return options
} }
#if os(iOS) || os(macOS) // MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list) // touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`. /// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
@@ -124,8 +148,8 @@ enum SettingsOptions {
@MainActor @MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] { static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = [] var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) #if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device", native = [("This device",
Int(max(bounds.width, bounds.height)), Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))] Int(min(bounds.width, bounds.height)))]
@@ -145,7 +169,7 @@ enum SettingsOptions {
/// the screen can't show), plus any stored custom value so it stays selectable. /// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor @MainActor
static func refreshRates(including current: Int) -> [Int] { static func refreshRates(including current: Int) -> [Int] {
#if os(iOS) #if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond let maxHz = UIScreen.main.maximumFramesPerSecond
#else #else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60 let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
@@ -155,5 +179,4 @@ enum SettingsOptions {
if !rates.contains(current) { rates.append(current) } if !rates.contains(current) { rates.append(current) }
return rates.sorted() return rates.sorted()
} }
#endif
} }
@@ -380,8 +380,9 @@ extension SettingsView {
Text("Codec is a preference; the host falls back if it can't encode your choice. " Text("Codec is a preference; the host falls back if it can't encode your choice. "
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when " + "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
+ "both this device and the host support it — otherwise the stream stays 8-bit " + "both this device and the host support it — otherwise the stream stays 8-bit "
+ "4:2:0 SDR. 4:4:4 sharpens text and UI for extra bandwidth. Applies from the " + "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
+ "next session.") + "work; for games the bits are better spent at 4:2:0. Applies from the next "
+ "session.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -24,7 +24,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2" @AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false @AppStorage(DefaultsKey.vsync) var vsync = false
#endif #endif
@@ -32,8 +32,8 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true @AppStorage(DefaultsKey.allowVRR) var allowVRR = true
#endif #endif
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) var enable444 = true @AppStorage(DefaultsKey.enable444) var enable444 = false
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@@ -43,9 +43,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared @ObservedObject var gamepads = GamepadManager.shared
#if !os(tvOS)
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State var showControllerTest = false @State var showControllerTest = false
#endif #endif
@@ -284,19 +282,6 @@ struct SettingsView: View {
("4K @ 60", "3840x2160x60"), ("4K @ 60", "3840x2160x60"),
] ]
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
private static var presenterOptions: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2 (default)", "stage2"),
("Stage 3 (experimental)", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
private var modeTag: Binding<String> { private var modeTag: Binding<String> {
Binding( Binding(
get: { "\(width)x\(height)x\(hz)" }, get: { "\(width)x\(height)x\(hz)" },
@@ -313,6 +298,12 @@ struct SettingsView: View {
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
} }
/// The gamepad-UI switch as an on/off row (same shape as HDR above) the escape hatch back
/// to this focus-engine home for someone who prefers it with a controller connected.
private var gamepadUIEnabledTag: Binding<String> {
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
}
private var tvBody: some View { private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)" let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds let bounds = UIScreen.main.nativeBounds
@@ -338,7 +329,7 @@ struct SettingsView: View {
selection: $audioChannels) selection: $audioChannels)
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange) .foregroundStyle(.orange)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -347,7 +338,7 @@ struct SettingsView: View {
selection: $compositor) selection: $compositor)
TVSelectionRow( TVSelectionRow(
title: "Presenter", title: "Presenter",
options: Self.presenterOptions, options: SettingsOptions.presenters,
selection: $presenter) selection: $presenter)
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
@@ -355,7 +346,7 @@ struct SettingsView: View {
Text("The host creates a virtual output at exactly this mode — native " Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.") + "is honored only if available on the host.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -375,8 +366,11 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Controller type", options: SettingsOptions.padTypes, title: "Controller type", options: SettingsOptions.padTypes,
selection: $gamepadType) selection: $gamepadType)
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
Text(Self.controllersFooter) Text(Self.controllersFooter)
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
var interactive = false var interactive = false
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) { #if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape) content.glassEffect(glass, in: shape)
} else { } else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
} }
#endif
} }
@available(iOS 26, macOS 26, tvOS 26, *) #if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass { private var glass: Glass {
var g: Glass = .regular var g: Glass = .regular
if let tint { g = g.tint(tint) } if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() } if interactive { g = g.interactive() }
return g return g
} }
#endif
} }
extension View { extension View {
@@ -48,7 +48,7 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison " + "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.") + "needed.")
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
TVFieldRow( TVFieldRow(
@@ -59,7 +59,7 @@ struct PairSheet: View {
) { editing = .clientName } ) { editing = .clientName }
if let errorText { if let errorText {
Text(errorText) Text(errorText)
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
} }
HStack(spacing: 32) { HStack(spacing: 32) {
@@ -263,19 +263,24 @@ public final class SessionAudio {
defer { drainDone.signal() } defer { drainDone.signal() }
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is // Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order. // stereo-only and is handed back as interleaved f32 PCM in wire channel order.
while !flag.isStopped { // Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
var alive = true
while alive, !flag.isStopped {
alive = autoreleasepool { () -> Bool in
let pcm: PunktfunkConnection.AudioPCM? let pcm: PunktfunkConnection.AudioPCM?
do { do {
pcm = try connection.nextAudioPcm(timeoutMs: 100) pcm = try connection.nextAudioPcm(timeoutMs: 100)
} catch { } catch {
break // session closed return false // session closed
} }
guard let pcm, pcm.frameCount > 0 else { continue } guard let pcm, pcm.frameCount > 0 else { return true }
pcm.samples.withUnsafeBufferPointer { p in pcm.samples.withUnsafeBufferPointer { p in
if let base = p.baseAddress { if let base = p.baseAddress {
ring.write(base, count: pcm.frameCount * pcm.channels) ring.write(base, count: pcm.frameCount * pcm.channels)
} }
} }
return true
}
} }
} }
thread.name = "punktfunk-audio" thread.name = "punktfunk-audio"
@@ -570,7 +570,8 @@ public final class PunktfunkConnection {
/// Pull the next force-feedback update for the GCController haptics engine: /// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop. /// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. /// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
/// self-termination TTL use `nextRumble2` to honor the host lease.
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? { public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
feedbackLock.lock() feedbackLock.lock()
defer { feedbackLock.unlock() } defer { feedbackLock.unlock() }
@@ -590,6 +591,33 @@ public final class PunktfunkConnection {
} }
} }
/// Pull the next force-feedback update *including its self-termination TTL* (v2 envelopes):
/// `(pad, low, high, ttlMs)`. `ttlMs` is how long to render this level before silencing unless
/// the host renews it; `RumbleTuning.noTTL` (`UInt32.max`) means "no lease" a legacy host, so
/// fall back to a client-side staleness timeout. The reorder gate (seq) already ran in the
/// core, so a stale/reordered envelope never surfaces here. Drain from the (single) feedback
/// thread, alongside `nextHidOutput`.
public func nextRumble2(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, ttl: UInt32 = .max
let rc = punktfunk_connection_next_rumble2(h, &pad, &low, &high, &ttl, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, ttl)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// One DualSense feedback event a game wrote to the host's virtual pad replay it on /// One DualSense feedback event a game wrote to the host's virtual pad replay it on
/// the real controller (GCDeviceLight, GCControllerPlayerIndex, /// the real controller (GCDeviceLight, GCControllerPlayerIndex,
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these. /// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
@@ -48,6 +48,23 @@ public final class GamepadCapture {
/// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate). /// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate).
private static let motionIntervalNs: UInt64 = 4_000_000 private static let motionIntervalNs: UInt64 = 4_000_000
/// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`):
/// L1+R1+Start+Select held together four simultaneous buttons no game uses, so normal
/// play can't trip it. Held for `disconnectHold` it ends the session via
/// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is
/// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release
/// capture) has no Apple equivalent worth wiring macOS has Q/D, touch has the HUD.
private static let escapeChord: UInt32 =
GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back
/// pf-client-core's `DISCONNECT_HOLD` the same 1.5 s on every client.
private static let disconnectHold: TimeInterval = 1.5
private var chordTimer: Timer?
/// Fired ON MAIN once the escape chord has been held `disconnectHold` the session owner
/// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a
/// stream with a controller: B/Menu presses are deliberately swallowed during a session so
/// gameplay can't end it (see ContentView's tvOS session branch).
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection, manager: GamepadManager) { public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection self.connection = connection
self.manager = manager self.manager = manager
@@ -165,6 +182,7 @@ public final class GamepadCapture {
private func sync(_ g: GCExtendedGamepad) { private func sync(_ g: GCExtendedGamepad) {
guard !suspended else { return } guard !suspended else { return }
let newButtons = Self.buttonMask(g) let newButtons = Self.buttonMask(g)
updateEscapeChord(newButtons)
let changed = newButtons ^ buttons let changed = newButtons ^ buttons
if changed != 0 { if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 {
@@ -297,7 +315,26 @@ public final class GamepadCapture {
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The /// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
/// host's virtual pad returns to rest instead of running with the last state. /// host's virtual pad returns to rest instead of running with the last state.
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
/// releases. Events only arrive on state CHANGES, so a held chord needs the timer the
/// handler won't fire again until something moves.
private func updateEscapeChord(_ newButtons: UInt32) {
let held = newButtons & Self.escapeChord == Self.escapeChord
if held, chordTimer == nil {
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
Task { @MainActor in self?.onDisconnectRequest?() }
}
RunLoop.main.add(timer, forMode: .common)
chordTimer = timer
} else if !held, chordTimer != nil {
chordTimer?.invalidate()
chordTimer = nil
}
}
private func releaseAll() { private func releaseAll() {
chordTimer?.invalidate()
chordTimer = nil
for bit in GamepadWire.allButtons where buttons & bit != 0 { for bit in GamepadWire.allButtons where buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: 0)) connection.send(.gamepadButton(bit, down: false, pad: 0))
} }
@@ -74,7 +74,11 @@ public final class GamepadFeedback {
// session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and // session a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking. // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
let thread = Thread { [connection, flag, drainDone, weak self] in let thread = Thread { [connection, flag, drainDone, weak self] in
while !flag.isStopped { // Per-iteration autorelease pool: no runloop on this thread, and the haptics/HID
// rendering below autoreleases ObjC temporaries. `false` = session over.
var alive = true
while alive, !flag.isStopped {
alive = autoreleasepool { () -> Bool in
do { do {
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds // Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
// the connection's shared feedback lock for its whole wait; the video pump drains // the connection's shared feedback lock for its whole wait; the video pump drains
@@ -88,15 +92,15 @@ public final class GamepadFeedback {
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue, // drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states // and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh. // queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)? var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
var rumbleBurst = 0 var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped, while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) { let r = try connection.nextRumble2(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) } if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
rumbleBurst += 1 rumbleBurst += 1
} }
if let n = newest { if let n = newest {
self?.rumble.apply(low: n.low, high: n.high) self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
} }
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle. // per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -106,12 +110,14 @@ public final class GamepadFeedback {
self?.render(ev) self?.render(ev)
burst += 1 burst += 1
} }
return true
} catch { } catch {
break // .closed (or fatal) the session is over return false // .closed (or fatal) the session is over
}
} }
// ~8 ms poll cadence (125 Hz), slept OUTSIDE the feedback lock low rumble/HID // ~8 ms poll cadence (125 Hz), slept OUTSIDE the feedback lock low rumble/HID
// latency without holding the lock the HDR-meta drain needs. // latency without holding the lock the HDR-meta drain needs.
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) } if alive, !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
} }
drainDone.signal() drainDone.signal()
} }
@@ -23,10 +23,23 @@ enum RumbleTuning {
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens; /// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled. /// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025 static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The /// Session watchdog: silence the motors when no wire command arrived for this long. This is
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only /// the **legacy-host fallback only** an old host sends no self-termination lease, so its
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible. /// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
static let sessionStaleSeconds: TimeInterval = 1.6 static let sessionStaleSeconds: TimeInterval = 1.6
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
static let noTTL: UInt32 = .max
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
/// ([`noTTL`]) the renderer falls back to [`sessionStaleSeconds`] else the self-termination
/// lease in seconds (render the level for at most this long unless the host renews it).
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
}
/// Levels closer than this (0.4 % of full scale) are the same level an identical host /// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player. /// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0 static let levelEpsilon: Float = 1.0 / 256.0
@@ -139,6 +152,10 @@ final class RumbleRenderer: @unchecked Sendable {
/// Wire-truth target (raw wire units) and when it was last confirmed by any command. /// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0) private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0) private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease the
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
private var envelopeDeadline: DispatchTime?
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm, /// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent, /// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic. /// so an idle controller costs no timer wakeups and no radio traffic.
@@ -212,13 +229,23 @@ final class RumbleRenderer: @unchecked Sendable {
} }
} }
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes /// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise /// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// free (invariant 2). /// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
func apply(low lowAmp: UInt16, high highAmp: UInt16) { /// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
queue.async { queue.async {
self.lastCommand = .now() self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0 let active = lowAmp != 0 || highAmp != 0
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
self.envelopeDeadline = .now() + lease
} else {
self.envelopeDeadline = nil
}
if active != self.wasActive { if active != self.wasActive {
self.wasActive = active self.wasActive = active
log.debug( log.debug(
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
self.ticker?.cancel() self.ticker?.cancel()
self.ticker = nil self.ticker = nil
self.target = (0, 0) self.target = (0, 0)
self.envelopeDeadline = nil
self.wasActive = false self.wasActive = false
self.teardown() self.teardown()
self.closeHID() self.closeHID()
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
/// Watchdog + housekeeping heartbeat while audible. /// Watchdog + housekeeping heartbeat while audible.
private func tick() { private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after { if let deadline = envelopeDeadline {
// The host refreshes rumble state every 500 ms; this much silence means the channel // v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
// (or host) died while a motor was on. A direct-connected pad would have been // observable signature of a host that stopped renewing (a dropped stop, or a dead host)
// the whole point of the envelope model: the motor can't outlive the host's intent.
if target != (0, 0), DispatchTime.now() >= deadline {
log.warning("rumble: envelope expired unrenewed — silencing")
target = (0, 0)
envelopeDeadline = nil
}
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
// channel (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome. // stopped by its game long ago force the same outcome.
log.warning( log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing") "rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
@@ -0,0 +1,173 @@
// The Siri Remote as a pointing device during a tvOS streaming session the remote's touch
// surface drives the HOST cursor (relative deltas, like a laptop trackpad), a surface press
// clicks (left button), and Play/Pause right-clicks. It also owns the remote's DELIBERATE
// session exit: hold Back/Menu `disconnectHold`. A short Back press does nothing the
// UIKit menu press it also generates is swallowed by ContentView's session branch, so neither
// a trackpad fumble nor a game-controller B press can end the session (the pad's exit is the
// L1+R1+Start+Select chord in GamepadCapture).
//
// The remote is read through GameController as a GCMicroGamepad with
// `reportsAbsoluteDpadValues = true`: the dpad axes then report the finger's ABSOLUTE position
// on the surface (±1, +y up) while touched, and snap to exactly (0, 0) on lift. Successive
// positions are differenced into relative mouse deltas; the exact-zero snap is treated as a
// lift (a real touch at the mathematical centre is measure-zero, and one dropped delta there
// is imperceptible). Handlers (not a poll) the same in-session delivery GamepadCapture
// relies on.
//
// Lifecycle mirrors GamepadCapture: started by SessionModel when streaming begins (never
// during the trust prompt), stopped on disconnect; held buttons are released on stop so the
// host never keeps a stuck click.
#if os(tvOS)
import Foundation
import GameController
import UIKit
@MainActor
public final class SiriRemotePointer {
private let connection: PunktfunkConnection
private var observers: [NSObjectProtocol] = []
private var bound: GCController?
/// Finger position (±1 axes) at the last dpad callback while touched; nil = lifted.
private var lastTouch: (x: Float, y: Float)?
/// Wire buttons currently held (1 = left, 3 = right) released on stop/unbind.
private var heldButtons: Set<UInt32> = []
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
private var menuDownAt: Date?
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
/// controller chord's 1.5 s the remote has no way to trip this during gameplay.
private static let disconnectHold: TimeInterval = 1.0
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
/// small; two comfortable swipes should cross a 1080p desktop.
private static let pointerScale: Float = 1100
/// Largest single-callback finger travel accepted as real motion (surface units; the axes
/// span ±1, so 0.4 a fifth of the pad). On RELEASE the hardware slides the reported
/// position back to (0, 0) through intermediate callbacks naive differencing turns that
/// tail into reverse deltas that RETRACE the whole swipe, so the cursor springs back to its
/// anchor and the pointer feels absolute. Real finger motion arrives as many small steps
/// (even a fast flick stays well under this per callback); the release tail arrives as one
/// or two huge jumps discard those (the anchor still follows, so nothing accumulates).
private static let maxStep: Float = 0.4
/// Fired ON MAIN after Back/Menu was held `disconnectHold` and released.
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection) {
self.connection = connection
}
public func start() {
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.rebind() }
})
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidDisconnect, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.rebind() }
})
rebind()
}
public func stop() {
observers.forEach(NotificationCenter.default.removeObserver(_:))
observers.removeAll()
bind(nil)
}
/// The Siri Remote is the non-extended controller carrying a microGamepad a full gamepad
/// (which also EXPOSES a microGamepad view of itself) must never be captured here, its
/// buttons belong to GamepadCapture.
private func rebind() {
let remote = GCController.controllers().first {
$0.extendedGamepad == nil && $0.microGamepad != nil
}
bind(remote)
}
private func bind(_ controller: GCController?) {
guard controller !== bound else { return }
if let old = bound?.microGamepad {
old.dpad.valueChangedHandler = nil
old.buttonA.pressedChangedHandler = nil
old.buttonX.pressedChangedHandler = nil
old.buttonMenu.pressedChangedHandler = nil
}
releaseHeld()
lastTouch = nil
menuDownAt = nil
bound = controller
guard let micro = controller?.microGamepad else { return }
// Absolute finger position instead of the emulated dpad the raw surface is what a
// trackpad needs. Rotation stays off: the remote's natural grip is the coordinate frame.
micro.reportsAbsoluteDpadValues = true
micro.allowsRotation = false
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
}
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
}
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
}
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
}
}
private func touchMoved(x: Float, y: Float) {
// Exact (0, 0) is the lift snap drop the anchor so the next touch starts a fresh
// gesture instead of a jump-delta from the old position.
guard x != 0 || y != 0 else {
lastTouch = nil
return
}
defer { lastTouch = (x, y) }
guard let last = lastTouch else { return } // first contact anchors, moves nothing
let stepX = x - last.x
let stepY = y - last.y
// The release tail (and any tracking glitch) shows up as a single impossible jump
// see `maxStep`. Skip the emission; the deferred anchor update above still follows the
// reported position, so the gesture cleanly re-anchors instead of retracing.
guard abs(stepX) < Self.maxStep, abs(stepY) < Self.maxStep else { return }
let dx = stepX * Self.pointerScale / 2 // axes span ±1 full swipe = 2.0
let dy = -stepY * Self.pointerScale / 2 // GC +y is up; mouse +y is down
let ix = Int32(dx.rounded())
let iy = Int32(dy.rounded())
guard ix != 0 || iy != 0 else { return }
connection.send(.mouseMove(dx: ix, dy: iy))
}
private func setButton(_ button: UInt32, down: Bool) {
if down { heldButtons.insert(button) } else { heldButtons.remove(button) }
connection.send(.mouseButton(button, down: down))
}
private func menuChanged(pressed: Bool) {
if pressed {
menuDownAt = Date()
return
}
let heldFor = menuDownAt.map { Date().timeIntervalSince($0) } ?? 0
menuDownAt = nil
if heldFor >= Self.disconnectHold {
onDisconnectRequest?()
}
// A short press is deliberately nothing: the accompanying UIKit menu press is swallowed
// in ContentView, and forwarding it as a host key would make trackpad fumbles type.
}
private func releaseHeld() {
for button in heldButtons {
connection.send(.mouseButton(button, down: false))
}
heldButtons.removeAll()
}
}
#endif
@@ -13,14 +13,14 @@ public enum Licenses {
return text return text
} }
/// punktfunk's own license MIT OR Apache-2.0, at your option. /// Punktfunk's own license MIT OR Apache-2.0, at your option.
public static var appLicense: String { public static var appLicense: String {
let mit = resource("LICENSE-MIT") let mit = resource("LICENSE-MIT")
let apache = resource("LICENSE-APACHE") let apache = resource("LICENSE-APACHE")
if mit.isEmpty && apache.isEmpty { if mit.isEmpty && apache.isEmpty {
return "punktfunk is licensed under MIT OR Apache-2.0, at your option." return "Punktfunk is licensed under MIT OR Apache-2.0, at your option."
} }
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n" return "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
+ "================================ MIT ================================\n\n" + "================================ MIT ================================\n\n"
+ mit + mit
+ "\n\n============================== Apache-2.0 ==============================\n\n" + "\n\n============================== Apache-2.0 ==============================\n\n"
@@ -51,11 +51,27 @@ public enum Licenses {
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay /// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n"; /// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once. /// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
public static let thirdPartyNoticesChunks: [String] = { public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
let chunkSize = 200 /// Lines per chunk: tvOS reads much smaller chunks focus is how tvOS scrolls, and each
return stride(from: 0, to: lines.count, by: chunkSize).map { start in /// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n") /// itself per step; ~24 lines two thirds of a screen reads like a page turn. Elsewhere the
/// only constraint is the text-render height limit, so chunks stay big.
private static var chunkLines: Int {
#if os(tvOS)
24
#else
200
#endif
}
/// `text` split at line boundaries into render/focus-sized chunks (joined with "\n"; the
/// inter-chunk break is the caller's stack-row boundary, so no text is lost). tvOS pages
/// focus through these every license wall on the Acknowledgements screen renders this way.
public static func chunked(_ text: String) -> [String] {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return stride(from: 0, to: lines.count, by: chunkLines).map { start in
lines[start..<min(start + chunkLines, lines.count)].joined(separator: "\n")
}
} }
}()
} }
@@ -0,0 +1,126 @@
// The YCbCrRGB conversion as three shader rows, ported from pf-client-core's `csc_rows`
// (crates/pf-client-core/src/video.rs) the ONE coefficient implementation every punktfunk
// presenter derives its CSC from. Keep the two in LOCKSTEP: both carry the same unit tests
// (CscRowsTests.swift the Rust `csc_rows` tests), and a coefficient change lands in both or
// neither.
//
// Why this exists: the stage-2 Metal shaders used to hardcode BT.709 (SDR) / BT.2020 (HDR)
// matrices, silently ignoring the stream's signaled matrix. A Linux host's RGB-input NVENC paths
// signal BT.601 limited (NVENC's fixed internal RGBYUV conversion; ffmpeg force-writes that
// VUI), so those streams rendered with the wrong coefficients a constant hue error. The rows
// are now computed per frame from the decoded buffer's actual signaling (VideoToolbox propagates
// the HEVC VUI / AV1 colour config onto the CVPixelBuffer's attachments) and handed to the
// fragment shaders as bytes.
import CoreVideo
import simd
/// The fragment shaders' CSC constant block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`.
/// Layout matches the Metal-side `struct CscUniform { float4 r0; float4 r1; float4 r2; }`
/// (three 16-byte-aligned float4s, stride 48) passed via `setFragmentBytes`.
public struct CscUniform: Equatable, Sendable {
public var r0: SIMD4<Float>
public var r1: SIMD4<Float>
public var r2: SIMD4<Float>
}
public enum CscRows {
/// A decoded frame's YCbCr signaling: the H.273 matrix code (1 = BT.709, 5/6 = BT.601,
/// 9/10 = BT.2020; 2 = unspecified the BT.709 SDR default, mirroring `ColorDesc`) and
/// whether the samples are full range.
public struct Signal: Equatable, Sendable {
public var matrix: UInt8
public var fullRange: Bool
public init(matrix: UInt8, fullRange: Bool) {
self.matrix = matrix
self.fullRange = fullRange
}
}
/// Read a decoded buffer's signaling: the matrix from the `CVImageBuffer` attachment
/// (VideoToolbox propagates the bitstream's colour description there), the range from the
/// pixel format itself (the video- vs full-range biplanar siblings), so a full-range stream
/// expands correctly no matter which sibling VideoToolbox delivered.
public static func signal(of buffer: CVPixelBuffer) -> Signal {
var matrix: UInt8 = 2 // unspecified BT.709 default in rows()
if let att = CVBufferCopyAttachment(buffer, kCVImageBufferYCbCrMatrixKey, nil),
CFGetTypeID(att) == CFStringGetTypeID() {
let s = unsafeDowncast(att, to: CFString.self)
if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_709_2) {
matrix = 1
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_601_4) {
matrix = 5
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_SMPTE_240M_1995) {
matrix = 7
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_2020) {
matrix = 9
} else {
// CICP codes CoreMedia has no named constant for arrive as the literal string
// "YCbCrMatrix#<code>" the suffix IS the H.273 code. BT.470BG (5) takes this
// form (proven by the 601 golden fixture), and BT.470BG is exactly what a Linux
// host's RGB-input NVENC signals, so missing it re-creates the hue bug the
// per-frame signaling exists to fix.
let str = s as String
if str.hasPrefix("YCbCrMatrix#"), let code = UInt8(str.dropFirst(12)) {
matrix = code
}
}
}
let pf = CVPixelBufferGetPixelFormatType(buffer)
let fullRange = pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
return Signal(matrix: matrix, fullRange: fullRange)
}
/// Compute the three rows bit-depth exact. `depth` picks the limited-range code points
/// (8-bit: 16/235/240 over 255; 10-bit: 64/940/960 over 1023 NOT the same normalized
/// values, the difference is ~half a code). `msbPacked` folds in the P010/x444 packing
/// factor: 10 significant bits live in the MSBs of 16, so an `.r16Unorm` sample reads
/// `code·64/65535` multiplying by `65535/65472` recovers exact `code/1023` (this replaces
/// the shaders' old documented ~0.1% approximation).
public static func rows(_ signal: Signal, depth: Int, msbPacked: Bool) -> CscUniform {
// BT.601 (5/6), BT.2020 (9/10); everything else incl. unspecified is the host's
// BT.709 SDR default (mirrors the Rust side's dispatch).
let (kr, kb): (Double, Double)
switch signal.matrix {
case 5, 6: (kr, kb) = (0.299, 0.114)
case 9, 10: (kr, kb) = (0.2627, 0.0593)
default: (kr, kb) = (0.2126, 0.0722)
}
let kg = 1.0 - kr - kb
let max = Double((1 << depth) - 1) // 255 / 1023
let step = Double(1 << (depth - 8)) // code points per 8-bit step: 1 / 4
let pack = msbPacked ? 65535.0 / 65472.0 : 1.0
let (sy, oy, sc): (Double, Double, Double)
if signal.fullRange {
(sy, oy, sc) = (pack, 0.0, pack)
} else {
(sy, oy, sc) = (
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step)
)
}
// rgb = M * (yuv + off) = M*yuv + M*off rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack]
let m: [[Double]] = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
]
func row(_ r: Int) -> SIMD4<Float> {
let w = (0..<3).reduce(0.0) { $0 + m[r][$1] * off[$1] }
return SIMD4(Float(m[r][0]), Float(m[r][1]), Float(m[r][2]), Float(w))
}
return CscUniform(r0: row(0), r1: row(1), r2: row(2))
}
}
@@ -28,17 +28,39 @@ private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "pre
/// dimmer. Matches the host's standard PQ reference white. /// dimmer. Matches the host's standard PQ reference white.
private let hdrReferenceWhiteNits: Float = 203.0 private let hdrReferenceWhiteNits: Float = 203.0
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR /// PUNKTFUNK_SDR_COLORSPACE=srgb A/B hatch for the SDR layer's colour tag. Today the SDR layer
/// and BT.2020-PQ HDR YCbCrRGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin /// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded RGB as-is the /// stream is displayed with the panel's native primaries mild oversaturation on every P3 Mac.
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples /// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission). /// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
private let sdrColorspaceOverride: CGColorSpace? = {
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
return nil
}
return CGColorSpace(name: CGColorSpace.sRGB)
}()
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and YCbCrRGB
/// fragment shaders whose conversion arrives as three constant rows computed per frame on the CPU
/// (`CscRows` the Swift port of pf-client-core's `csc_rows`, from the decoded buffer's actual
/// signaling). One set of coefficients honors BT.601/709/2020 × full/limited × 8/10-bit instead of
/// the old hardcoded BT.709/BT.2020 matrices a BT.601-signaled stream (a Linux host's RGB-input
/// NVENC) used to render with BT.709 coefficients, a constant hue error. uv.y is flipped (1 - p.y)
/// so the top-left-origin texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded
/// RGB as-is the CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system
/// compositor the samples are PQ and how to tone-map them (no EOTF here, matching the host's
/// BT.2020 PQ emission).
private let shaderSource = """ private let shaderSource = """
#include <metal_stdlib> #include <metal_stdlib>
using namespace metal; using namespace metal;
struct VOut { float4 pos [[position]]; float2 uv; }; struct VOut { float4 pos [[position]]; float2 uv; };
// The CPU-computed CSC rows (CscRows.swift, layout-matched): rgb[i] = dot(ri.xyz, yuv) + ri.w.
// Range expansion, the matrix, and the 10-bit MSB-packing factor are all folded in.
struct CscUniform { float4 r0; float4 r1; float4 r2; };
vertex VOut pf_vtx(uint vid [[vertex_id]]) { vertex VOut pf_vtx(uint vid [[vertex_id]]) {
float2 p = float2(float((vid << 1) & 2), float(vid & 2)); float2 p = float2(float((vid << 1) & 2), float(vid & 2));
VOut o; VOut o;
@@ -94,43 +116,80 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
return uv; return uv;
} }
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) full-range RGB. Chroma is sampled at the // The shared sample + row-multiply: YCbCr (bicubic luma, siting-corrected bilinear chroma)
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0. // RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
fragment float4 pf_frag(VOut in [[stage_in]], // offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
texture2d<float> lumaTex [[texture(0)]], // gamma-encoded RGB, an HDR frame's rows yield PQ-encoded RGB the fragment variants below
texture2d<float> chromaTex [[texture(1)]]) { // differ only in what they do next.
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
constant CscUniform& csc) {
constexpr sampler s(filter::linear, address::clamp_to_edge); constexpr sampler s(filter::linear, address::clamp_to_edge);
float y = catmullRomLuma(lumaTex, s, in.uv); float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
// BT.709, 8-bit limited (video) range full-range RGB. return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
y = (y - 16.0/255.0) * (255.0/219.0); dot(csc.r1.xyz, yuv) + csc.r1.w,
float u = (c.x - 128.0/255.0) * (255.0/224.0); dot(csc.r2.xyz, yuv) + csc.r2.w));
float v = (c.y - 128.0/255.0) * (255.0/224.0);
float r = y + 1.5748 * v;
float g = y - 0.1873 * u - 0.4681 * v;
float b = y + 1.8556 * u;
return float4(saturate(float3(r, g, b)), 1.0);
} }
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), YCbCr that is PQ-encoded. We apply the BT.2020 // SDR: 8-bit NV12 / 4:4:4 full-range RGB, transfer left baked (shown as-is, the proven SDR
// matrix to get PQ-encoded RGB and output it as-is the CAMetalLayer's itur_2100_PQ colour space // layer config).
// + edrMetadata tell the compositor the samples are PQ, so it does the PQdisplay tone-map. No EOTF fragment float4 pf_frag(VOut in [[stage_in]],
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample texture2d<float> lumaTex [[texture(0)]],
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%). texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
}
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded YCbCr) full-range PQ RGB, output as-is
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
// PQ, so it does the PQdisplay tone-map. No EOTF here. The rows fold in the exact 10-bit
// MSB-packing factor (the old hardcoded shader carried a documented ~0.1% /1024-vs-/1023 error).
fragment float4 pf_frag_hdr(VOut in [[stage_in]], fragment float4 pf_frag_hdr(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]], texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) { texture2d<float> chromaTex [[texture(1)]],
constexpr sampler s(filter::linear, address::clamp_to_edge); constant CscUniform& csc [[buffer(0)]]) {
float y = catmullRomLuma(lumaTex, s, in.uv); return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; }
// BT.2020 10-bit limited (video) range full-range PQ RGB.
y = (y - 64.0/1023.0) * (1023.0/876.0); // HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
float u = (c.x - 512.0/1023.0) * (1023.0/896.0); // disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
float v = (c.y - 512.0/1023.0) * (1023.0/896.0); // wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
float r = y + 1.4746 * v; // composites UNtone-mapped the CAMetalLayer header says so outright which showed as a badly
float g = y - 0.16455 * u - 0.57135 * v; // overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF linear
float b = y + 1.8814 * u; // light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
return float4(saturate(float3(r, g, b)), 1.0); // rolloff with a 1000-nit knee, BT.2020BT.709 primaries, BT.709 OETF into the proven SDR layer
// config. The 10-bit BT.2020 stream keeps its full decode depth; only the final presentation is
// display-referred SDR. (When the display IS in an HDR mode requested per session via
// AVDisplayManager, see StreamViewIOS tvOS presents pf_frag_hdr's PQ passthrough instead:
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
// YCbCr full-range PQ RGB via the per-frame rows (as pf_frag_hdr).
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
// ST 2084 EOTF: PQ code value linear light, 1.0 = 10,000 nits.
const float m1 = 2610.0/16384.0;
const float m2 = 78.84375;
const float c1 = 3424.0/4096.0;
const float c2 = 18.8515625;
const float c3 = 18.6875;
float3 p = pow(pq, 1.0/m2);
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
float3 t = lin * (10000.0/203.0);
// BT.2020 BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
float3 t709 = float3(
dot(t, float3( 1.6605, -0.5876, -0.0728)),
dot(t, float3(-0.1246, 1.1329, -0.0083)),
dot(t, float3(-0.0182, -0.1006, 1.1187)));
t709 = max(t709, 0.0);
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
const float w = 1000.0/203.0;
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
// BT.709 OETF the same encoding the SDR stream arrives in, so both paths present alike.
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
return float4(e, 1.0);
} }
""" """
@@ -144,12 +203,19 @@ public final class MetalVideoPresenter {
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle). /// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
private let pipelineSDR: MTLRenderPipelineState private let pipelineSDR: MTLRenderPipelineState
private let pipelineHDR: MTLRenderPipelineState private let pipelineHDR: MTLRenderPipelineState
/// tvOS only: the in-shader PQSDR tone-map fallback (pf_frag_hdr_tv bgra8), used whenever
/// the display is composited without HDR headroom see `setDisplayHeadroom`. nil elsewhere.
private let pipelineHDRToneMap: MTLRenderPipelineState?
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
/// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs. /// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs.
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread /// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
/// `configure` call is ordered before the thread starts, so it doesn't race). /// `configure` call is ordered before the thread starts, so it doesn't race).
private var hdrActive = false private var hdrActive = false
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
/// its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
/// derived from the staged display headroom at the top of every `render`.
private var hdrPassthroughActive = false
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session /// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
/// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the /// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write /// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
@@ -163,6 +229,11 @@ public final class MetalVideoPresenter {
private let stagingLock = NSLock() private let stagingLock = NSLock()
private var pendingHdrMeta: PunktfunkConnection.HdrMeta? private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
private var drawableTarget: CGSize = .zero private var drawableTarget: CGSize = .zero
/// tvOS: the display's current EDR headroom (UIScreen.currentEDRHeadroom), pushed from the
/// main thread (SessionPresenter.layout + the mode-switch observers). > 1 the display is
/// composited with HDR headroom, so HDR frames present as PQ passthrough; otherwise the
/// in-shader tone-map keeps the picture from blowing out. 1 (the default) is the safe start.
private var stagedDisplayHeadroom: CGFloat = 1.0
#if DEBUG #if DEBUG
/// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change. /// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change.
@@ -177,6 +248,7 @@ public final class MetalVideoPresenter {
else { return nil } else { return nil }
let pipelineSDR: MTLRenderPipelineState let pipelineSDR: MTLRenderPipelineState
let pipelineHDR: MTLRenderPipelineState let pipelineHDR: MTLRenderPipelineState
let pipelineHDRToneMap: MTLRenderPipelineState?
do { do {
let library = try device.makeLibrary(source: shaderSource, options: nil) let library = try device.makeLibrary(source: shaderSource, options: nil)
let vtx = library.makeFunction(name: "pf_vtx") let vtx = library.makeFunction(name: "pf_vtx")
@@ -188,8 +260,20 @@ public final class MetalVideoPresenter {
let hdr = MTLRenderPipelineDescriptor() let hdr = MTLRenderPipelineDescriptor()
hdr.vertexFunction = vtx hdr.vertexFunction = vtx
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr") hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
hdr.colorAttachments[0].pixelFormat = .rgba16Float // EDR-capable hdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr) pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr)
#if os(tvOS)
// tvOS carries BOTH HDR pipelines: PQ passthrough when the display is composited
// with HDR headroom, the in-shader tone-map ( the 8-bit SDR config) when it isn't.
// See setDisplayHeadroom / configureColor.
let tm = MTLRenderPipelineDescriptor()
tm.vertexFunction = vtx
tm.fragmentFunction = library.makeFunction(name: "pf_frag_hdr_tv")
tm.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineHDRToneMap = try device.makeRenderPipelineState(descriptor: tm)
#else
pipelineHDRToneMap = nil
#endif
} catch { } catch {
return nil return nil
} }
@@ -229,17 +313,19 @@ public final class MetalVideoPresenter {
return MetalVideoPresenter( return MetalVideoPresenter(
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR, device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
textureCache: textureCache, layer: layer) pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
} }
private init( private init(
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState, device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
textureCache: CVMetalTextureCache, layer: CAMetalLayer
) { ) {
self.device = device self.device = device
self.queue = queue self.queue = queue
self.pipelineSDR = pipelineSDR self.pipelineSDR = pipelineSDR
self.pipelineHDR = pipelineHDR self.pipelineHDR = pipelineHDR
self.pipelineHDRToneMap = pipelineHDRToneMap
self.textureCache = textureCache self.textureCache = textureCache
self.layer = layer self.layer = layer
} }
@@ -251,30 +337,68 @@ public final class MetalVideoPresenter {
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor; /// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
/// SDR uses the plain 8-bit sRGB path. /// SDR uses the plain 8-bit sRGB path.
public func configure(hdr: Bool) { public func configure(hdr: Bool) {
#if os(tvOS)
// Reconfigure on an HDR flip AND on a passthroughtone-map flip: the display's headroom
// changes when the AVDisplayManager mode switch (requested at session start) completes
// typically a second or two into the session.
stagingLock.lock()
let passthrough = stagedDisplayHeadroom > 1.0
stagingLock.unlock()
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
hdrActive = hdr
hdrPassthroughActive = passthrough
#else
guard hdr != hdrActive else { return } guard hdr != hdrActive else { return }
hdrActive = hdr hdrActive = hdr
#endif
configureColor(hdr: hdr) configureColor(hdr: hdr)
} }
/// tvOS: park the display's current EDR headroom (a MAIN-thread `UIScreen` read pushed by
/// SessionPresenter.layout and the stream view's mode-switch observers). > 1 flips HDR frames
/// to PQ passthrough (the display's own tone-map applies); 1 keeps the in-shader tone-map.
/// Applied by the render thread on the next frame, like every other staged value here.
public func setDisplayHeadroom(_ headroom: CGFloat) {
stagingLock.lock()
stagedDisplayHeadroom = headroom
stagingLock.unlock()
}
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested /// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API /// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so /// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there)
/// it gets the PQ pixel format + colour space only the tvOS compositor tone-maps from those. /// and a bare PQ colour-space tag composites UNtone-mapped (the "overblown HDR" Apple TV report),
/// so tvOS instead tone-maps PQSDR in the shader (pf_frag_hdr_tv) and keeps the SDR layer config.
private func configureColor(hdr: Bool) { private func configureColor(hdr: Bool) {
if hdr { if hdr {
#if os(tvOS)
if hdrPassthroughActive {
// Display composited WITH HDR headroom (the session's AVDisplayManager request
// landed): emit PQ passthrough in a real HDR10 output that's the correct
// emission, and the TV applies its own tone-map.
layer.pixelFormat = .rgba16Float
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
} else {
// SDR-composited display: PQ would render untone-mapped (blown out) the
// pf_frag_hdr_tv shader tone-maps to SDR instead.
layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil
}
#else
layer.pixelFormat = .rgba16Float layer.pixelFormat = .rgba16Float
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ) layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = true layer.wantsExtendedDynamicRangeContent = true
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the // Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright. // flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
layer.edrMetadata = makeEDR(lastHdrMeta) layer.edrMetadata = makeEDR(lastHdrMeta)
#endif #endif
} else { } else {
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB // SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
// (the proven SDR path never showed the "too bright" issue, which was HDR-only). // colour matching on macOS (the panel's native primaries the long-proven look,
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
// for correct colour matching instead (A/B pending see sdrColorspaceOverride).
layer.pixelFormat = .bgra8Unorm layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil layer.colorspace = sdrColorspaceOverride
#if !os(tvOS) #if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil layer.edrMetadata = nil
@@ -360,6 +484,11 @@ public final class MetalVideoPresenter {
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange || pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange || pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange || pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
// The frame's YCbCrRGB rows, from its ACTUAL signaling (buffer attachments + pixel
// format) a BT.601-signaled stream gets 601 coefficients, full-range gets full-range
// expansion; recomputed per frame because the host can flip colour in-band (SDRHDR).
var csc = CscRows.rows(
CscRows.signal(of: pixelBuffer), depth: tenBit ? 10 : 8, msbPacked: tenBit)
guard let textureCache, guard let textureCache,
let luma = makeTexture( let luma = makeTexture(
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache), pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
@@ -395,9 +524,17 @@ public final class MetalVideoPresenter {
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
return false return false
} }
#if os(tvOS)
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
#else
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR) encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
#endif
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0) encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1) encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding() encoder.endEncoding()
if let onPresented { if let onPresented {
@@ -10,6 +10,9 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import QuartzCore import QuartzCore
#if os(tvOS)
import UIKit
#endif
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or /// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
/// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a /// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first, /// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var) /// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG /// falls back to the platform default. `allowStage1` is false in release builds, where a
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback. /// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice { static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw { switch raw {
case "stage1": return allowStage1 ? .stage1 : .stage2 case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2
case "stage3": return .stage3 case "stage3": return .stage3
default: return .stage2 default: return platformDefault
} }
} }
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
static var platformDefault: PresenterChoice {
#if os(tvOS)
.stage3
#else
.stage2
#endif
}
} }
final class SessionPresenter { final class SessionPresenter {
@@ -179,6 +197,13 @@ final class SessionPresenter {
stage2?.setDrawableTarget(CGSize( stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(), width: (fit.width * contentsScale).rounded(),
height: (fit.height * contentsScale).rounded())) height: (fit.height * contentsScale).rounded()))
#if os(tvOS)
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
// HDR mode (the session's AVDisplayManager request landed see StreamViewIOS), and HDR
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
// mode notifications, so a mid-session switch reaches here without a bounds change.
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
#endif
} }
/// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the /// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
@@ -358,7 +358,12 @@ public final class Stage2Pipeline {
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a // decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
// resolution-ceiling miss). End cleanly instead of looping on a black screen. // resolution-ceiling miss). End cleanly instead of looping on a black screen.
var decodeFailRun = 0 var decodeFailRun = 0
while !token.isStopped { // Every iteration drains its own autorelease pool: this thread has no runloop, so
// autoreleased VT/CM temporaries would otherwise accumulate until session end.
// `false` = session over exit the loop (the closure can't `break` across itself).
var alive = true
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do { do {
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
// decoder conceals the reference-missing deltas often WITHOUT an error callback // decoder conceals the reference-missing deltas often WITHOUT an error callback
@@ -378,13 +383,13 @@ public final class Stage2Pipeline {
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) { if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
presenter.setHdrMeta(meta) presenter.setHdrMeta(meta)
} }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue } guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
onFrame?(au) onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
} }
guard let f = format, !token.isStopped else { continue } guard let f = format, !token.isStopped else { return true }
if decoder.decode(au: au, format: f) { if decoder.decode(au: au, format: f) {
decodeFailRun = 0 decodeFailRun = 0
} else { } else {
@@ -397,12 +402,14 @@ public final class Stage2Pipeline {
// recovers within a GOP) 4:4:4 isn't decodable here; end the session. // recovers within a GOP) 4:4:4 isn't decodable here; end the session.
if connection.isChroma444, decodeFailRun >= 180 { if connection.isChroma444, decodeFailRun >= 180 {
if !token.isStopped { onSessionEnd?() } if !token.isStopped { onSessionEnd?() }
break return false
} }
} }
return true
} catch { } catch {
if !token.isStopped { onSessionEnd?() } if !token.isStopped { onSessionEnd?() }
break // session closed return false // session closed
}
} }
} }
} }
@@ -435,10 +442,14 @@ public final class Stage2Pipeline {
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
let renderThread = Thread { let renderThread = Thread {
defer { renderStopped.signal() } defer { renderStopped.signal() }
while !token.isStopped { // Every iteration drains its own autorelease pool (`return` = the old `continue`):
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable
// without a per-iteration pool every presented frame's drawable object (plus its
// texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end.
while !token.isStopped { autoreleasepool {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut { if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
// Stage-3: while a present is in flight, don't take from the ring at all frames // Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented // keep coalescing there (newest wins, the intended drop point) and the presented
@@ -447,13 +458,13 @@ public final class Stage2Pipeline {
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) { if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake() debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
guard !token.isStopped, let frame = ring.take() else { guard !token.isStopped, let frame = ring.take() else {
gate?.release() // armed but nothing to render don't hold the gate stale gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake() debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link // V-Sync ON: flip on the next predicted vsync (< one period out, stale link
// immediate see VsyncClock). OFF: flip as soon as the GPU finishes. // immediate see VsyncClock). OFF: flip as soon as the GPU finishes.
@@ -488,7 +499,7 @@ public final class Stage2Pipeline {
ring.putBack(frame) ring.putBack(frame)
} }
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
} } }
} }
renderThread.name = "punktfunk-stage2-render" renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive renderThread.qualityOfService = .userInteractive
@@ -512,6 +523,13 @@ public final class Stage2Pipeline {
presenter.setDrawableTarget(size) presenter.setDrawableTarget(size)
} }
/// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen`
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
public func setDisplayHeadroom(_ headroom: CGFloat) {
presenter.setDisplayHeadroom(headroom)
}
/// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN /// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline /// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent). /// (the stop is permanent).
@@ -47,7 +47,12 @@ final class StreamPump {
var awaitingIDR = false var awaitingIDR = false
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
var wasFailed = false var wasFailed = false
while !token.isStopped { // Every iteration drains its own autorelease pool: this thread has no runloop, so
// autoreleased CM/layer temporaries would otherwise accumulate until session end.
// `false` = session over exit the loop (the closure can't `break` across itself).
var alive = true
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do { do {
// Loss recovery (the primary path). Under the host's infinite GOP the only // Loss recovery (the primary path). Under the host's infinite GOP the only
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs // recovery keyframe is one we request. The reassembler drops unrecoverable AUs
@@ -69,7 +74,7 @@ final class StreamPump {
} }
if awaitingIDR { recovery.request() } if awaitingIDR { recovery.request() }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue } guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
onFrame?(au) onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat { if let f = idrFormat {
@@ -97,13 +102,15 @@ final class StreamPump {
guard let f = format, guard let f = format,
let sample = connection.videoCodec.sampleBuffer(au: au, format: f), let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
!token.isStopped // don't enqueue a stale frame after a restart !token.isStopped // don't enqueue a stale frame after a restart
else { continue } else { return true }
layer.enqueue(sample) layer.enqueue(sample)
return true
} catch { } catch {
if !token.isStopped { if !token.isStopped {
onSessionEnd?() onSessionEnd?()
} }
break // session closed return false // session closed
}
} }
} }
} }
@@ -36,6 +36,9 @@ import PunktfunkCore
import SwiftUI import SwiftUI
import UIKit import UIKit
import os import os
#if os(tvOS)
import AVKit // AVDisplayManager the per-session display-mode (HDR10/refresh) request
#endif
/// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the /// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the
/// resolved pointer-lock state each time capture engages, so the user can see whether the /// resolved pointer-lock state each time capture engages, so the user can see whether the
@@ -108,7 +111,20 @@ public struct StreamView: UIViewControllerRepresentable {
} }
} }
public final class StreamViewController: UIViewController { #if os(tvOS)
/// tvOS: a GCEventViewController with `controllerUserInteractionEnabled = false` routes game-
/// controller (and Siri Remote) input EXCLUSIVELY to the GameController framework while the
/// stream is up. Without it a pad's B/Menu press doubles as a UIKit menu press which ended
/// the session (or suspended the whole app) from ordinary gameplay; a SwiftUI
/// `.onExitCommand {}` swallow proved unreliable with nothing focusable on screen. Every
/// in-session exit is GC-level by design: the pad's escape chord (GamepadCapture) and the
/// remote's hold-Back (SiriRemotePointer).
public typealias StreamViewControllerBase = GCEventViewController
#else
public typealias StreamViewControllerBase = UIViewController
#endif
public final class StreamViewController: StreamViewControllerBase {
public private(set) var connection: PunktfunkConnection? public private(set) var connection: PunktfunkConnection?
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2 /// Record the unified latency stages (end-to-end / decode / display) when the stage-2
@@ -119,6 +135,11 @@ public final class StreamViewController: UIViewController {
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
#if os(tvOS)
/// The window's display manager the session's mode request was set on held weakly so
/// stop() can clear the request even after the view has left the window.
private weak var sessionDisplayManager: AVDisplayManager?
#endif
#if os(iOS) #if os(iOS)
private var inputCapture: InputCapture? private var inputCapture: InputCapture?
fileprivate var captured = false fileprivate var captured = false
@@ -157,6 +178,12 @@ public final class StreamViewController: UIViewController {
public override func loadView() { public override func loadView() {
view = StreamLayerUIView() view = StreamLayerUIView()
#if os(tvOS)
// Kill the pad/remote UIKit press path at the source for the whole session (see the
// GCEventViewController typealias above). GC delivery is untouched: GamepadCapture
// forwards the pad, SiriRemotePointer drives the pointer and owns the remote exit.
controllerUserInteractionEnabled = false
#endif
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g. // Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
// moving to an external display at a different scale) the iOS analogue of macOS's // moving to an external display at a different scale) the iOS analogue of macOS's
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it // viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
@@ -230,6 +257,18 @@ public final class StreamViewController: UIViewController {
} }
#endif #endif
#if os(tvOS)
// The GCEventViewController's interaction flag applies to the deepest such controller
// CONTAINING THE FIRST RESPONDER inside SwiftUI's hosting-controller sandwich that is not
// guaranteed to be us unless we anchor the responder chain here explicitly.
public override var canBecomeFirstResponder: Bool { true }
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
#endif
func start( func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -342,6 +381,19 @@ public final class StreamViewController: UIViewController {
setCaptured(true) // entering a session is the deliberate "capture me" moment setCaptured(true) // entering a session is the deliberate "capture me" moment
} }
#endif #endif
#if os(tvOS)
// The TV's mode switch (requested in applyDisplayCriteriaIfNeeded) completes
// asynchronously, and a dynamic-range-only switch doesn't re-layout by itself
// re-layout on the switch/mode notifications so the presenter sees the new EDR
// headroom immediately (layout pushes UIScreen.currentEDRHeadroom down).
observers.append(NotificationCenter.default.addObserver(
forName: .AVDisplayManagerModeSwitchEnd, object: nil, queue: .main
) { [weak self] _ in self?.layoutMetalLayer() })
observers.append(NotificationCenter.default.addObserver(
forName: UIScreen.modeDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in self?.layoutMetalLayer() })
#endif
} }
func stop() { func stop() {
@@ -360,6 +412,12 @@ public final class StreamViewController: UIViewController {
streamView.onScroll = nil streamView.onScroll = nil
streamView.currentHostMode = nil streamView.currentHostMode = nil
#endif #endif
#if os(tvOS)
// Return the TV to the user's preferred mode the home screen must not stay in the
// session's HDR10/refresh mode.
sessionDisplayManager?.preferredDisplayCriteria = nil
sessionDisplayManager = nil
#endif
presenter.stop() presenter.stop()
connection = nil connection = nil
} }
@@ -367,8 +425,50 @@ public final class StreamViewController: UIViewController {
public override func viewDidLayoutSubviews() { public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
layoutMetalLayer() layoutMetalLayer()
#if os(tvOS)
applyDisplayCriteriaIfNeeded()
#endif
} }
#if os(tvOS)
/// Ask the TV for a display mode matching the session HDR10 at the stream's refresh rate
/// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation
/// playback layers do this implicitly). Honored only when the user allows matching (tvOS
/// Settings Video and Audio Match Content); the presenter reads the RESULT off UIScreen's
/// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
/// stop() teardown clears it.
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
// A synthetic HDR10-HEVC format description carrying the negotiated mode what the
// stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the
// display to it (tvOS 17+, our deployment floor).
let ext: [CFString: Any] = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
var desc: CMFormatDescription?
CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC,
width: Int32(mode.width), height: Int32(mode.height),
extensions: ext as CFDictionary, formatDescriptionOut: &desc)
guard let desc else { return }
manager.preferredDisplayCriteria = AVDisplayCriteria(
refreshRate: Float(mode.refreshHz), formatDescription: desc)
sessionDisplayManager = manager
}
#endif
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the /// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
/// canonical render scale and is reliable once the controller is in the hierarchy; /// canonical render scale and is reliable once the controller is in the hierarchy;
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
@@ -0,0 +1,112 @@
import CoreMedia
import CoreVideo
import VideoToolbox
import XCTest
import simd
@testable import PunktfunkKit
/// Golden end-to-end colour tests: decode the known-signaling bar fixtures through a real
/// `VTDecompressionSession`, read the buffer's propagated signaling via `CscRows.signal(of:)`,
/// convert sampled YCbCr through `CscRows.rows` the exact math the Metal shaders run and
/// require the ORIGINAL RGB bars back. This is the proof of the two assumptions the stage-2
/// colour fix rests on: (1) VideoToolbox propagates the bitstream's matrix onto the decoded
/// CVPixelBuffer's attachments, and (2) signal+rows renders it correctly for BT.601/709 ×
/// limited/full. A hardcoded-709 regression fails the 601 fixture by tens of code points.
final class ColorBarDecodeTests: XCTestCase {
private static let bars: [(r: Float, g: Float, b: Float)] = [
(255, 255, 255), (255, 255, 0), (0, 255, 255), (0, 255, 0),
(255, 0, 255), (255, 0, 0), (0, 0, 255), (0, 0, 0),
]
/// Decode one fixture AU to a biplanar 4:2:0 buffer of the given range sibling.
private func decode(_ au: [UInt8], pixelFormat: OSType) throws -> CVPixelBuffer {
let data = Data(au)
guard let format = AnnexB.formatDescription(fromIDR: data, codec: .hevc) else {
throw XCTSkip("could not build a format description from the fixture")
}
let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: pixelFormat]
var session: VTDecompressionSession?
let created = VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: format,
decoderSpecification: nil, imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
guard created == noErr, let session else {
throw XCTSkip("VTDecompressionSessionCreate failed (\(created))")
}
defer { VTDecompressionSessionInvalidate(session) }
let unit = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
guard let sample = AnnexB.sampleBuffer(au: unit, format: format, codec: .hevc) else {
throw XCTSkip("could not build a sample buffer")
}
var produced: CVPixelBuffer?
let status = VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in
if status == noErr { produced = imageBuffer }
}
XCTAssertEqual(status, noErr, "decode submit")
VTDecompressionSessionWaitForAsynchronousFrames(session)
return try XCTUnwrap(produced, "no decoded frame")
}
private func assertBars(
_ name: String, au: [UInt8], pixelFormat: OSType,
expected: CscRows.Signal
) throws {
let buffer = try decode(au, pixelFormat: pixelFormat)
let signal = CscRows.signal(of: buffer)
XCTAssertEqual(signal, expected, "\(name): VT must propagate the bitstream signaling")
let rows = CscRows.rows(signal, depth: 8, msbPacked: false)
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
let yBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 0))
.assumingMemoryBound(to: UInt8.self)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
let cBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 1))
.assumingMemoryBound(to: UInt8.self)
let cStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
for (i, bar) in Self.bars.enumerated() {
let (cx, cy) = (i * 32 + 16, 32)
let y = Float(yBase[cy * yStride + cx]) / 255.0
let cb = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2]) / 255.0
let cr = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2 + 1]) / 255.0
let yuv = SIMD3<Float>(y, cb, cr)
let rgb = SIMD3<Float>(
simd_dot(SIMD3(rows.r0.x, rows.r0.y, rows.r0.z), yuv) + rows.r0.w,
simd_dot(SIMD3(rows.r1.x, rows.r1.y, rows.r1.z), yuv) + rows.r1.w,
simd_dot(SIMD3(rows.r2.x, rows.r2.y, rows.r2.z), yuv) + rows.r2.w)
XCTAssertEqual(rgb.x * 255, bar.r, accuracy: 3, "\(name) bar \(i) R")
XCTAssertEqual(rgb.y * 255, bar.g, accuracy: 3, "\(name) bar \(i) G")
XCTAssertEqual(rgb.z * 255, bar.b, accuracy: 3, "\(name) bar \(i) B")
}
}
/// BT.601 (BT.470BG) limited what a Linux host's RGB-input NVENC signals. The fixture that
/// catches a hardcoded-BT.709 shader.
func testGolden601LimitedBars() throws {
try assertBars(
"601-limited", au: ColorBarFixtures.bars601Limited,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
expected: .init(matrix: 5, fullRange: false))
}
/// BT.709 limited the hosts' explicit SDR signaling.
func testGolden709LimitedBars() throws {
try assertBars(
"709-limited", au: ColorBarFixtures.bars709Limited,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
expected: .init(matrix: 1, fullRange: false))
}
/// BT.709 full range the PUNKTFUNK_444_FULLRANGE experiment's signaling (requesting the
/// full-range sibling keeps VT from range-converting, so the full-range rows are exercised).
func testGolden709FullBars() throws {
try assertBars(
"709-full", au: ColorBarFixtures.bars709Full,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
expected: .init(matrix: 1, fullRange: true))
}
}
@@ -0,0 +1,864 @@
// Golden colour-bar fixtures the SAME bytes as crates/pf-client-core/tests/bars-*.h265
// (one 256×64 LOSSLESS x265 IDR of 8 saturated bars per signaling variant; generated
// offline with ffmpeg/libx265, RGBYUV matched to the declared VUI so the original RGB
// is recoverable ±1 code). Regenerate both together the Rust and Swift golden tests
// must chew identical streams. Test-target only; nothing here ships.
enum ColorBarFixtures {
static let bars601Limited: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x0a,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x35, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0xb5, 0xb6, 0x5d, 0x34, 0x79, 0xe7, 0x9e, 0x7f, 0x7d, 0xf7,
0xdf, 0x7d, 0xf8, 0x18, 0x53, 0xff, 0xfe, 0xc3, 0xa1, 0x01, 0xf1, 0xbc, 0xa9, 0x03, 0x52, 0x4f,
0xc2, 0x91, 0xef, 0xff, 0xf9, 0x13, 0xb5, 0xff, 0xff, 0xe0, 0xd3, 0xc0, 0xe8, 0xf7, 0xbe, 0xff,
0x98, 0x39, 0x83, 0x98, 0x39, 0x83, 0x99, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59,
0xb5, 0x88, 0xe7, 0xff, 0xb1, 0xee, 0x80, 0x1f, 0xaf, 0xd3, 0xc3, 0x0c, 0x30, 0xc3, 0x0c, 0x71,
0xc7, 0x1c, 0x71, 0xc7, 0x16, 0x7a, 0x50, 0x01, 0xc9, 0xff, 0xf9, 0x43, 0x06, 0x41, 0x75, 0x47,
0xb1, 0xe7, 0x71, 0x8e, 0x4e, 0x9d, 0x1f, 0xff, 0xce, 0x91, 0x28, 0x4d, 0xc5, 0x7a, 0x68, 0x84,
0xdc, 0x76, 0x6c, 0xb4, 0xb3, 0x45, 0xc9, 0xef, 0xff, 0xcb, 0x9f, 0x2b, 0x6b, 0x50, 0x63, 0xb0,
0x1f, 0xe0, 0x49, 0xe5, 0x74, 0x7f, 0xff, 0x3a, 0x44, 0xa1, 0x37, 0x15, 0xe9, 0xa2, 0x13, 0x71,
0xd9, 0xaa, 0x79, 0xa3, 0xe3, 0xff, 0xf5, 0x23, 0x05, 0x46, 0x2f, 0x61, 0x97, 0x0a, 0x0e, 0x16,
0x36, 0x94, 0x4f, 0xff, 0xb3, 0x7f, 0x1f, 0xe6, 0x13, 0x25, 0x0f, 0x78, 0x49, 0x00, 0x63, 0xf9,
0x58, 0x64, 0x83, 0x85, 0x00, 0x07, 0x98, 0xce, 0xcb, 0x42, 0xb2, 0x35, 0x1d, 0xfa, 0xbf, 0xc6,
0x85, 0xe6, 0x00, 0x48, 0xff, 0xfe, 0xed, 0x0e, 0xf8, 0x8b, 0xfc, 0x55, 0xed, 0x8d, 0xa0, 0xbd,
0xf6, 0xdf, 0xff, 0xc7, 0xf9, 0xd8, 0x58, 0x24, 0x13, 0x43, 0x41, 0x3e, 0x1f, 0xc8, 0x40, 0x1c,
0x5f, 0xff, 0x95, 0x21, 0x6f, 0x83, 0x90, 0x1c, 0x23, 0xc9, 0xe6, 0x5e, 0xdb, 0xd7, 0xff, 0xde,
0xb1, 0x0b, 0xeb, 0x42, 0x9f, 0xe3, 0xae, 0xdb, 0x41, 0x26, 0x3b, 0x04, 0x56, 0x7f, 0xfe, 0xc9,
0xf5, 0x58, 0x41, 0xd2, 0xa9, 0xeb, 0x7f, 0xd7, 0x3b, 0xa2, 0xef, 0xff, 0xde, 0x56, 0xa7, 0xa2,
0xac, 0x43, 0x81, 0xaf, 0xce, 0x33, 0x5c, 0x41, 0xbf, 0x1f, 0xff, 0x5d, 0xee, 0xcb, 0x39, 0xae,
0x1e, 0xa3, 0xd4, 0x47, 0xc7, 0xe9, 0x47, 0xff, 0xeb, 0x2a, 0xe3, 0x45, 0x58, 0xb5, 0x6a, 0x37,
0xfd, 0xd2, 0xcf, 0xfa, 0x80, 0xa2, 0x8c, 0xff, 0xff, 0xf0, 0x0f, 0x7e, 0x56, 0xf1, 0x4e, 0x9d,
0x3a, 0x74, 0xf2, 0x64, 0xc9, 0x93, 0x26, 0x4c, 0x98, 0x43, 0xff, 0xff, 0x77, 0x88, 0xb7, 0xf6,
0x16, 0xba, 0x3e, 0xa1, 0xc4, 0xf8, 0x98, 0xc5, 0xc5, 0x67, 0xf6, 0xa6, 0x20, 0x0b, 0x3d, 0x62,
0x9e, 0xaf, 0xe6, 0xaa, 0x45, 0x3e, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xfd, 0x2f, 0xd2,
0xfd, 0x2f, 0xd2, 0xfd, 0x2f, 0xd2, 0xfd, 0x2d, 0x3d, 0xfe, 0x6b, 0xf7, 0xff, 0xff, 0x04, 0x5e,
0x01, 0x17, 0x8a, 0x34, 0xae, 0x56, 0xe5, 0x6e, 0x56, 0xe5, 0x6e, 0x66, 0x26, 0x62, 0x66, 0x26,
0x62, 0x66, 0x26, 0x62, 0x66, 0x25, 0xe8, 0x81, 0xb7, 0xff, 0xfc, 0x8c, 0xfc, 0x2d, 0xdd, 0xfc,
0x78, 0xe0, 0x8f, 0x36, 0xc1, 0x09, 0xb5, 0x10, 0xff, 0xff, 0x05, 0x24, 0xbb, 0x93, 0x6d, 0x8b,
0x11, 0x3f, 0xf5, 0x5f, 0x91, 0x4d, 0xe8, 0x34, 0x32, 0x13, 0xff, 0xf2, 0xd0, 0x8e, 0xa4, 0x10,
0x3b, 0x17, 0x3d, 0x0d, 0xf0, 0x64, 0x88, 0x64, 0xd4, 0xff, 0xfd, 0xe4, 0x28, 0x23, 0x05, 0xf8,
0x06, 0x68, 0xb4, 0xa5, 0x8c, 0x20, 0x1e, 0x54, 0x22, 0xf3, 0xff, 0xf6, 0x32, 0x77, 0x6d, 0x34,
0xce, 0xe5, 0x7e, 0xdf, 0xcc, 0x43, 0x72, 0x2c, 0xdf, 0x7f, 0xfe, 0x7f, 0xb0, 0x15, 0x20, 0x52,
0xd4, 0x06, 0xf8, 0x83, 0x2d, 0x08, 0x97, 0x2f, 0x08, 0x3d, 0x3d, 0x5f, 0xff, 0xe6, 0x0a, 0xa2,
0x05, 0xf9, 0x24, 0x92, 0x4b, 0x18, 0x61, 0x86, 0x18, 0x61, 0x88, 0x6f, 0xff, 0xd4, 0xec, 0x35,
0x68, 0x57, 0xf9, 0x4c, 0x73, 0xc7, 0x6e, 0x6d, 0x63, 0x89, 0x93, 0xff, 0xff, 0x6e, 0x4e, 0xd9,
0x23, 0x2c, 0xf5, 0xed, 0xef, 0xde, 0xfd, 0xef, 0xde, 0xfd, 0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x6c,
0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x01, 0x1b, 0x12, 0xc0, 0x18, 0x22, 0xee, 0x4d, 0x92, 0x49, 0x24,
0x92, 0x7a, 0xaa, 0xaa, 0xaa, 0xaa, 0xa7, 0xd0, 0x1a, 0x5f, 0xff, 0x9c, 0xa9, 0xca, 0xe5, 0x6d,
0x24, 0x4e, 0x60, 0x52, 0x2f, 0xa6, 0x7f, 0xff, 0xb0, 0xe8, 0x40, 0x7c, 0x6f, 0x2a, 0x40, 0xd4,
0x93, 0xf0, 0xa6, 0xa6, 0x14, 0x55, 0xff, 0xf3, 0x76, 0x99, 0x75, 0xaa, 0xeb, 0x16, 0xc8, 0x7d,
0x93, 0x68, 0x60, 0xff, 0xfd, 0x65, 0x5c, 0x68, 0xab, 0x16, 0xad, 0x46, 0xff, 0xba, 0x5a, 0x12,
0x2c, 0x9f, 0xff, 0xe5, 0x39, 0x42, 0xf7, 0xb8, 0x97, 0xfb, 0xe6, 0xf7, 0xd3, 0x1e, 0x27, 0xff,
0xf9, 0xc3, 0x14, 0x52, 0xf4, 0xf5, 0x3c, 0xbb, 0x6e, 0xb7, 0x2b, 0xbb, 0x80, 0x5e, 0xbf, 0xff,
0x41, 0xd9, 0xcb, 0x9f, 0x5d, 0xc9, 0x28, 0x36, 0x1c, 0xcf, 0x18, 0xa2, 0xd9, 0xdf, 0xff, 0xa7,
0xf3, 0x09, 0x84, 0xe6, 0x31, 0x94, 0xa5, 0x8d, 0xad, 0x93, 0x83, 0x8c, 0x01, 0x58, 0xff, 0xfd,
0x4b, 0xa2, 0xd2, 0x82, 0x6b, 0xc7, 0x81, 0x87, 0x90, 0xc0, 0x16, 0x28, 0x4c, 0x7f, 0xfe, 0x95,
0xc3, 0x60, 0xc7, 0x92, 0xea, 0x9a, 0xf4, 0xee, 0x9b, 0x68, 0x59, 0x04, 0x74, 0xec, 0x80, 0x0f,
0x41, 0x5c, 0xee, 0x30, 0xf1, 0xc6, 0xe8, 0x40, 0x12, 0xed, 0xb5, 0x55, 0xff, 0xfc, 0x11, 0xc8,
0x03, 0x4d, 0x33, 0x0f, 0x22, 0xec, 0x22, 0xdf, 0x56, 0x44, 0xb6, 0x7f, 0xff, 0x12, 0x30, 0x22,
0x76, 0x41, 0xf5, 0x7f, 0xf8, 0x06, 0x4f, 0x55, 0x86, 0x31, 0x58, 0xa3, 0xff, 0xf4, 0xf1, 0xc2,
0x65, 0x14, 0x4c, 0x4d, 0x40, 0x92, 0x12, 0x88, 0x96, 0xdb, 0xc4, 0xff, 0xfd, 0x33, 0x8d, 0xc5,
0xcb, 0xf7, 0x51, 0xc8, 0xd1, 0x4a, 0x19, 0x22, 0x0f, 0x81, 0xee, 0x95, 0xff, 0xff, 0x9c, 0x31,
0x45, 0x2f, 0x4f, 0x53, 0xcb, 0xb6, 0xeb, 0x72, 0xca, 0xaf, 0xff, 0x9b, 0x7c, 0x7c, 0x9e, 0x22,
0x9b, 0x07, 0xb8, 0xd1, 0x57, 0x20, 0x17, 0x9d, 0xff, 0xf8, 0x24, 0xc8, 0xb0, 0xee, 0x78, 0x7b,
0x95, 0x1d, 0x8d, 0x56, 0x89, 0xff, 0xf8, 0x15, 0xb7, 0xfa, 0x2a, 0xbe, 0x6b, 0x3f, 0xf0, 0xb5,
0xb3, 0x63, 0x74, 0x55, 0xff, 0xfc, 0xcc, 0xbd, 0x0e, 0x72, 0xf9, 0x76, 0x87, 0x1d, 0x0e, 0xd9,
0x12, 0x9f, 0xff, 0x32, 0x43, 0xc0, 0x1f, 0x1b, 0x10, 0xf4, 0xd3, 0x9f, 0x4b, 0xd2, 0x6b, 0x2f,
0xff, 0xef, 0xcb, 0x14, 0x52, 0x91, 0x69, 0x76, 0xc8, 0x6d, 0x94, 0x00, 0x34, 0xff, 0xfb, 0xec,
0xba, 0x8f, 0x8d, 0xeb, 0x14, 0xf5, 0x9e, 0xab, 0x96, 0xf0, 0xd9, 0x4f, 0x39, 0x0f, 0xff, 0xf8,
0x50, 0x81, 0x7f, 0x73, 0xb2, 0xe5, 0xcb, 0x97, 0x32, 0x6c, 0xd9, 0xb3, 0x66, 0xcd, 0x9b, 0x61,
0x7f, 0xff, 0x74, 0x86, 0x0d, 0xaf, 0x45, 0xff, 0x85, 0x5a, 0xa4, 0x21, 0x8d, 0xfd, 0x18, 0x3a,
0x85, 0x00, 0x5d, 0x6a, 0x15, 0xf4, 0xff, 0x44, 0xd0, 0x78, 0xe5, 0xaf, 0x7a, 0xf7, 0xaf, 0x7a,
0xf7, 0xb0, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf0, 0xf8, 0x37, 0xae, 0xc0,
0x38, 0xb4, 0xea, 0x7a, 0x6e, 0x22, 0x51, 0xbc, 0x31, 0xdd, 0x3b, 0x74, 0xed, 0xd3, 0xb7, 0x4e,
0xdd, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x5d, 0x0d, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x56, 0xc8, 0x91,
0x7f, 0xff, 0xa1, 0xad, 0x88, 0x55, 0x61, 0x10, 0x40, 0x65, 0xd0, 0x9b, 0x4b, 0xc3, 0x5b, 0x8f,
0xff, 0xd0, 0xb6, 0xa8, 0x17, 0xbd, 0x41, 0xad, 0xe6, 0xc9, 0x9a, 0x1c, 0x1c, 0x1b, 0x5d, 0xa6,
0x97, 0xff, 0xf4, 0x29, 0x51, 0x3f, 0xff, 0xfa, 0xae, 0x19, 0xa1, 0x7e, 0x73, 0xcd, 0xc7, 0xfa,
0xff, 0xfd, 0x07, 0x66, 0xff, 0x1d, 0x6b, 0x4c, 0x94, 0xe8, 0xc3, 0x30, 0x89, 0x10, 0xd4, 0x81,
0x17, 0xff, 0xe4, 0x00, 0x31, 0xf0, 0xc2, 0xff, 0x9b, 0xc2, 0xb6, 0xd5, 0xbc, 0x8d, 0x4a, 0xbf,
0xff, 0xf8, 0xf9, 0xdb, 0xda, 0xc8, 0x96, 0xff, 0x59, 0x48, 0xb9, 0xdc, 0xca, 0x74, 0x56, 0x89,
0x79, 0x8c, 0xff, 0xff, 0x11, 0xf1, 0x1f, 0x0b, 0x12, 0x49, 0x24, 0xa4, 0x30, 0xc3, 0x0c, 0x30,
0xc4, 0x34, 0xff, 0xfd, 0xec, 0xf2, 0xd5, 0x8c, 0xa1, 0x40, 0x45, 0x93, 0x4f, 0x05, 0x8a, 0xdb,
0xbf, 0xff, 0xfc, 0x63, 0xf8, 0xb1, 0xe1, 0x66, 0xf5, 0x2c, 0x92, 0xc9, 0x2c, 0x92, 0xc9, 0x2c,
0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xc9, 0x46, 0xf9, 0x00, 0x24, 0xe2,
0xac, 0x81, 0x0c, 0x30, 0xc3, 0x0c, 0x7c, 0x71, 0xc7, 0x1c, 0x71, 0xc3, 0x33, 0xbe, 0x7f, 0xff,
0x09, 0x4e, 0x4f, 0x3a, 0xf9, 0x34, 0x8d, 0x41, 0xb5, 0x0d, 0x42, 0xbf, 0xfe, 0xf5, 0x88, 0x5f,
0x5a, 0x14, 0xff, 0x1d, 0x76, 0xda, 0x09, 0x36, 0xa4, 0x5d, 0xaf, 0xff, 0xbd, 0x87, 0x69, 0x9c,
0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e, 0x17, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1,
0xd7, 0x6d, 0xa0, 0x93, 0x60, 0x15, 0xbf, 0xfe, 0xf6, 0x1d, 0xa6, 0x73, 0x67, 0xdc, 0x31, 0x22,
0x62, 0x78, 0x5d, 0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x82, 0x4d,
0xcc, 0x83, 0xff, 0xff, 0xf1, 0xd3, 0x9b, 0xa2, 0x9d, 0xe7, 0x8c, 0x66, 0x72, 0xc0, 0x2f, 0xcf,
0x65, 0x9b, 0xff, 0xf7, 0xc0, 0xca, 0x22, 0x04, 0xa8, 0x24, 0xf3, 0x1d, 0x63, 0x5d, 0x84, 0x8f,
0xff, 0x44, 0x5f, 0xff, 0x9f, 0xeb, 0xff, 0x4c, 0x13, 0x36, 0x00, 0x3c, 0x22, 0xc9, 0xc2, 0xf5,
0xf9, 0xef, 0xff, 0xcf, 0xf5, 0xff, 0xa6, 0x09, 0x9b, 0x00, 0x1e, 0x11, 0x64, 0xe1, 0x7a, 0xf0,
0x2a, 0x80, 0xe0, 0x04, 0x5a, 0x21, 0x7a, 0xa9, 0x51, 0x5c, 0x9d, 0x9c, 0x8a, 0x61, 0xc3, 0xd2,
0xff, 0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf,
0xff, 0x72, 0x03, 0xdc, 0x34, 0x44, 0x7e, 0x97, 0x68, 0x3e, 0x1a, 0xca, 0x8d, 0xf6, 0xb9, 0xc9,
0xff, 0xf9, 0x98, 0x62, 0x79, 0xa6, 0x97, 0x88, 0xdb, 0x12, 0xa0, 0xdb, 0x18, 0x4a, 0x52, 0x7f,
0xfe, 0x65, 0x99, 0x2d, 0xca, 0xa3, 0x9b, 0x23, 0x17, 0x99, 0x47, 0x1b, 0x57, 0x6b, 0x24, 0xda,
0x4f, 0xff, 0x9f, 0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfc, 0x3e, 0xcf, 0xff, 0x9f,
0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfb, 0xed, 0x2b, 0xed, 0xff, 0xf5, 0xca, 0x2c,
0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x14, 0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65,
0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x2d, 0x3c, 0xe4, 0xff, 0xf9, 0xfe, 0xf1, 0xf6, 0xb4, 0x7a, 0xc8,
0x10, 0x48, 0x21, 0xb8, 0xfb, 0x3f, 0xfe, 0x7e, 0xe4, 0xc0, 0x14, 0xbb, 0xbe, 0x7e, 0xb5, 0xb7,
0xef, 0xb4, 0xfa, 0x17, 0xff, 0xd7, 0x3f, 0x91, 0xb5, 0xa5, 0x4a, 0x1d, 0xb1, 0x7b, 0x6a, 0xd4,
0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x28, 0xe4, 0xd2,
0xe3, 0x5f, 0xff, 0xde, 0xd4, 0xf2, 0x31, 0x27, 0x12, 0xa5, 0x4a, 0x95, 0x3c, 0x28, 0x50, 0xa1,
0x42, 0x85, 0x06, 0xda, 0xff, 0xfd, 0xc8, 0x0f, 0x94, 0x31, 0x1a, 0xd2, 0x66, 0x8a, 0xa8, 0x74,
0x03, 0x52, 0xcf, 0x12, 0x00, 0x52, 0x6c, 0xd3, 0x36, 0x5f, 0x46, 0x35, 0xfb, 0xc6, 0xbf, 0x4b,
0xf4, 0xbf, 0x4b, 0xf4, 0xc0, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x95, 0xf5,
0xab, 0x2f, 0xff, 0xfe, 0xd2, 0xdd, 0x9c, 0x95, 0xe0, 0x25, 0xdd, 0x39, 0xd3, 0x9d, 0x39, 0xd3,
0x9d, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x13, 0xab, 0xb0, 0x0b, 0xff,
0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x52, 0xf5, 0xff, 0xfb,
0x90, 0x1f, 0x28, 0x62, 0x35, 0xa4, 0xcd, 0x15, 0x50, 0xe8, 0x06, 0xa5, 0xb2, 0xce, 0xbf, 0x7f,
0xff, 0x14, 0x7b, 0x0e, 0x08, 0x20, 0x18, 0x32, 0x3e, 0xeb, 0x9a, 0xc6, 0xee, 0x81, 0xdf, 0xff,
0xc5, 0x0c, 0x78, 0xe1, 0x5d, 0x95, 0x29, 0x2c, 0x78, 0x61, 0xfa, 0x77, 0x12, 0x37, 0x16, 0xff,
0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf, 0xff,
0x72, 0x03, 0xe5, 0x0c, 0x46, 0xb4, 0x99, 0xa2, 0xaa, 0x1d, 0x00, 0xd4, 0xb6, 0xe7, 0x39, 0xf1,
0xaf, 0xff, 0xe2, 0x23, 0x20, 0x60, 0x5e, 0x18, 0x61, 0x88, 0x57, 0x5d, 0x75, 0xd7, 0x5d, 0x5b,
0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x81, 0xde, 0x79, 0xff, 0xff,
0xf5, 0x01, 0xe9, 0xc0, 0xa2, 0xd0, 0xd3, 0x64, 0xd6, 0x4d, 0x64, 0xd6, 0x4d, 0x65, 0x5c, 0x55,
0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x4d, 0xaa, 0x10, 0x05, 0xd9, 0x33, 0xd5, 0xc0,
0x00, 0x00, 0x03, 0x00, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xfd, 0xc6, 0x42, 0xf7, 0xff, 0xd7, 0x28,
0xb0, 0x89, 0x65, 0x97, 0xb9, 0x18, 0x6f, 0x70, 0x53, 0x17, 0xff, 0xd7, 0x28, 0xb0, 0x89, 0x65,
0x97, 0xb9, 0x18, 0x6f, 0x70, 0x30, 0x56, 0xfd, 0x5b, 0xff, 0xf2, 0x33, 0x67, 0x90, 0x81, 0x60,
0x4a, 0x3d, 0x34, 0x7d, 0x12, 0xe6, 0xff, 0xfc, 0x8a, 0xea, 0x0e, 0x95, 0x0c, 0xc0, 0x73, 0xf5,
0x83, 0xaf, 0xfa, 0x2f, 0x2f, 0xff, 0xbd, 0x87, 0x69, 0x9c, 0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e,
0x07, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1, 0xd7, 0x6d, 0xa0, 0x78, 0x35, 0x4a,
0x6d, 0xff, 0xfc, 0x50, 0xc7, 0x8e, 0x15, 0xd9, 0x52, 0x92, 0xc7, 0x86, 0x1f, 0xa7, 0x74, 0x0e,
0xff, 0xfe, 0x28, 0x63, 0xc7, 0x0a, 0xec, 0xa9, 0x49, 0x63, 0xc3, 0x0f, 0xd3, 0xb8, 0x92, 0x9f,
0x57, 0xff, 0xee, 0x40, 0x7c, 0xa1, 0x88, 0xd6, 0x93, 0x34, 0x55, 0x43, 0xa0, 0x1a, 0xa5, 0xeb,
0xff, 0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x4b, 0x13, 0x9a,
0xfc, 0x01, 0x0a, 0xb8, 0xdc, 0xe9, 0xaa, 0x51, 0xa6, 0x2f, 0x33, 0x38, 0x70, 0x9f, 0x3f, 0xff,
0x81, 0x38, 0x6f, 0x96, 0x59, 0x2c, 0x9d, 0xc5, 0x46, 0x2d, 0xbb, 0xb2, 0x86, 0x2f, 0xff, 0xde,
0x02, 0x4a, 0x0e, 0x78, 0xf4, 0x81, 0xf4, 0x0e, 0xf1, 0xaf, 0x76, 0xc4, 0x68, 0x3a, 0x7f, 0xfe,
0x7a, 0x23, 0xee, 0xeb, 0xae, 0x0b, 0xba, 0xa9, 0x83, 0xd2, 0x73, 0xc7, 0xd0, 0x9f, 0xff, 0x9e,
0x6a, 0xb4, 0x7c, 0xad, 0x6d, 0xa6, 0x32, 0xbc, 0x60, 0xd2, 0xe3, 0x9c, 0x90, 0x95, 0xe3, 0xff,
0xe9, 0x1b, 0x9a, 0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0xac, 0xb3, 0xff, 0xe9, 0x1b, 0x9a,
0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0x99, 0x5e, 0x95, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9,
0xc1, 0x16, 0xba, 0xb0, 0xf7, 0x82, 0xfd, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9, 0xc1, 0x16, 0xba,
0xb0, 0xf7, 0x81, 0x46, 0x41, 0x3d, 0xbf, 0xff, 0x01, 0x63, 0xdb, 0x18, 0x93, 0x66, 0x4d, 0xce,
0x9b, 0xce, 0x5f, 0xaf, 0xff, 0xc0, 0x35, 0x3a, 0x1b, 0x38, 0x22, 0xd7, 0x56, 0x1e, 0xf0, 0x28,
0x50, 0x74, 0xff, 0xfa, 0x4a, 0x1b, 0x55, 0xac, 0x47, 0xb6, 0x24, 0xc4, 0x4a, 0xa2, 0xcb, 0x3f,
0xfe, 0x91, 0xb9, 0xa4, 0x89, 0x64, 0x52, 0xe9, 0x2d, 0xc5, 0x79, 0x98, 0x82, 0x80,
]
static let bars709Limited: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x02,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x7b, 0xb8, 0x0b, 0x9c, 0xe7, 0x3a, 0x29, 0x4a, 0x52, 0x94,
0xa5, 0xc2, 0x9f, 0xff, 0xe8, 0xfd, 0xf0, 0x63, 0x71, 0xfc, 0x63, 0xec, 0x8f, 0x38, 0xf7, 0xff,
0xfc, 0x89, 0xda, 0xff, 0xff, 0xf0, 0x69, 0xe0, 0x74, 0x7b, 0xdf, 0x7f, 0xcc, 0x1c, 0xc1, 0xcc,
0x1c, 0xc1, 0xcc, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xc4, 0x73, 0xff,
0xd8, 0xf7, 0x40, 0x0f, 0x9f, 0x18, 0xc6, 0x31, 0x8c, 0x67, 0x39, 0xce, 0x73, 0x9c, 0xb3, 0xd2,
0x80, 0x0e, 0x4f, 0xff, 0x82, 0x8d, 0x24, 0xc8, 0xe1, 0x4d, 0xd7, 0xae, 0x61, 0xc6, 0x8f, 0xff,
0xc6, 0x7a, 0xd8, 0xf8, 0x9c, 0xf6, 0xf9, 0xcf, 0x66, 0x3d, 0xa5, 0x9a, 0x2e, 0x4f, 0x7f, 0xfc,
0x55, 0x8a, 0xa8, 0xed, 0xa7, 0x75, 0xbb, 0xe9, 0x49, 0x47, 0xff, 0xe3, 0x3d, 0x6c, 0x7c, 0x4e,
0x7b, 0x7c, 0xe7, 0xb3, 0x16, 0x79, 0xa3, 0xe3, 0xff, 0xe7, 0xab, 0xaa, 0x9f, 0x21, 0x38, 0x65,
0xc4, 0xb8, 0x18, 0x9f, 0xfe, 0x9b, 0xec, 0x4b, 0x58, 0x5d, 0xf8, 0xe5, 0xdf, 0x53, 0x6f, 0x2b,
0x0c, 0x90, 0x70, 0xa0, 0x00, 0xee, 0xdd, 0x34, 0xd4, 0x1e, 0xb0, 0x10, 0x19, 0xe6, 0x85, 0xe6,
0x00, 0x48, 0xff, 0xfd, 0x80, 0x28, 0x35, 0x78, 0x31, 0x21, 0xb6, 0x44, 0xdc, 0xed, 0xff, 0xf7,
0xd9, 0xef, 0xcd, 0xe3, 0x09, 0x63, 0x63, 0xc8, 0x0d, 0x40, 0x1c, 0x5f, 0xff, 0x07, 0x4b, 0x3e,
0xdd, 0x9f, 0x5f, 0x13, 0x8f, 0x5e, 0x4d, 0x7f, 0xfb, 0x27, 0xc1, 0xaa, 0x45, 0xe8, 0x00, 0x78,
0x18, 0xd2, 0x7b, 0x04, 0x56, 0x7f, 0xfd, 0x49, 0x9f, 0xfe, 0xb6, 0xbb, 0x0b, 0x58, 0x86, 0x8b,
0xdf, 0xff, 0x63, 0x53, 0x7d, 0x4f, 0xd7, 0x6d, 0x83, 0xdd, 0x24, 0x58, 0x37, 0xe3, 0xff, 0xd2,
0xa0, 0xff, 0xea, 0x7c, 0x38, 0xbd, 0xc8, 0xcb, 0x41, 0xff, 0xf3, 0xe1, 0x33, 0x34, 0x62, 0xe7,
0x3d, 0xc8, 0x0b, 0x72, 0xea, 0x02, 0x8a, 0x33, 0xff, 0xff, 0xa6, 0xb4, 0x4c, 0x89, 0x44, 0x92,
0x49, 0x25, 0xa6, 0x9a, 0x69, 0xa6, 0x9a, 0x60, 0x3f, 0xff, 0xf3, 0x35, 0x41, 0x04, 0x41, 0xef,
0xf0, 0x95, 0xfa, 0x4c, 0x21, 0x48, 0x56, 0x7f, 0x6a, 0x62, 0x00, 0xb3, 0xd4, 0x0d, 0xe9, 0xf0,
0x69, 0x96, 0x4e, 0x10, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x38, 0x43, 0x84, 0x38, 0x43, 0x84,
0x38, 0x43, 0x84, 0x38, 0x42, 0xdf, 0x7f, 0x9a, 0xfd, 0xff, 0xff, 0xb9, 0x77, 0x71, 0x05, 0xa5,
0x8b, 0x43, 0x77, 0x37, 0x73, 0x77, 0x37, 0x73, 0x7b, 0x07, 0xb0, 0x7b, 0x07, 0xb0, 0x7b, 0x07,
0xb0, 0x7b, 0x07, 0x92, 0x20, 0x6d, 0xff, 0xfe, 0xb4, 0xe9, 0xde, 0x13, 0xd7, 0xc9, 0xbc, 0x56,
0xf3, 0xee, 0xa8, 0xe4, 0x3f, 0xff, 0xa1, 0xf0, 0xfc, 0x2e, 0x5d, 0x4d, 0xac, 0xc7, 0x3f, 0x7b,
0x12, 0xf6, 0x0d, 0x0c, 0x84, 0xff, 0xfb, 0x0e, 0x4f, 0x86, 0x62, 0xe4, 0x29, 0xcd, 0x16, 0x0d,
0x1c, 0xd5, 0xf4, 0xff, 0xfc, 0xd6, 0x57, 0xd6, 0xef, 0x5c, 0x7f, 0xf4, 0x23, 0xc4, 0x3b, 0xff,
0x44, 0x22, 0xf3, 0xff, 0xf1, 0x4c, 0x00, 0x90, 0x41, 0x74, 0x0e, 0xdb, 0x83, 0xdb, 0x98, 0x09,
0xfd, 0xff, 0xf6, 0xfe, 0x6f, 0x2f, 0xb4, 0x11, 0xde, 0x8f, 0x3c, 0xed, 0x20, 0xdb, 0x5c, 0x20,
0xf4, 0xf5, 0x7f, 0xff, 0x37, 0xb3, 0x9d, 0x94, 0xa5, 0x29, 0xc9, 0x4a, 0x52, 0x94, 0xa4, 0x4d,
0xff, 0xf3, 0x75, 0xe0, 0xaa, 0xe3, 0x22, 0xfb, 0x9e, 0x89, 0xbf, 0x1c, 0x4c, 0x9f, 0xff, 0xfb,
0x72, 0x76, 0xc9, 0x19, 0x67, 0xaf, 0x6f, 0x7e, 0xf7, 0xef, 0x7e, 0xf7, 0xef, 0xb6, 0x7b, 0x67,
0xb6, 0x7b, 0x67, 0xb6, 0x7b, 0x67, 0xb6, 0x78, 0x08, 0xd8, 0x96, 0x00, 0xbe, 0x62, 0xfd, 0xef,
0x7b, 0xdf, 0x2a, 0x10, 0x84, 0x21, 0x06, 0xa8, 0x0d, 0x2f, 0xff, 0x8c, 0x55, 0xb0, 0xd2, 0xf0,
0x2a, 0xe6, 0xfa, 0xe9, 0xef, 0xff, 0xe8, 0xfa, 0x01, 0x39, 0xc2, 0x9c, 0x8f, 0x09, 0xe8, 0xf5,
0x30, 0xa2, 0xaf, 0xff, 0x1f, 0x61, 0x54, 0xea, 0xd1, 0xd6, 0xde, 0xc5, 0x29, 0x4f, 0xff, 0x9e,
0xf9, 0x05, 0x2a, 0x46, 0x6f, 0x36, 0x3c, 0x0c, 0xa8, 0xb2, 0x7f, 0xff, 0x10, 0x1f, 0xff, 0x8e,
0x40, 0x9d, 0x84, 0xfb, 0x4f, 0x3f, 0xff, 0x8b, 0xc9, 0x5b, 0x85, 0x13, 0x47, 0x02, 0xc4, 0x4e,
0xf7, 0x00, 0xbd, 0x7f, 0xfd, 0xc5, 0xa0, 0xda, 0x81, 0x9a, 0x24, 0xd8, 0x3d, 0x5c, 0x15, 0x53,
0xfb, 0xbf, 0xfe, 0xf7, 0xe2, 0x20, 0x48, 0xd8, 0xf0, 0xa3, 0xa0, 0x20, 0xd8, 0x91, 0x6c, 0x02,
0xb1, 0xff, 0xf7, 0xe3, 0x2f, 0x59, 0xc2, 0x49, 0x94, 0x57, 0x95, 0xcb, 0x93, 0x3a, 0xf8, 0xff,
0xfb, 0xc1, 0x6f, 0x38, 0x3c, 0x77, 0x60, 0x88, 0x59, 0xdf, 0x60, 0xb2, 0x38, 0xe9, 0xd9, 0x00,
0x1d, 0x78, 0xe9, 0xc8, 0x3a, 0x32, 0x69, 0x9c, 0x9d, 0xb6, 0xaa, 0xbf, 0xff, 0x43, 0x59, 0x62,
0x04, 0x19, 0x30, 0xcb, 0xb7, 0xbb, 0xb8, 0xbf, 0x13, 0x3f, 0xff, 0x4d, 0xaa, 0x30, 0x0e, 0x2d,
0xd4, 0xce, 0xc1, 0x74, 0x43, 0x75, 0xd4, 0xac, 0x51, 0xff, 0xf7, 0xb5, 0x7d, 0x74, 0x83, 0xc8,
0xce, 0x4e, 0xcc, 0xce, 0xd8, 0x34, 0xc9, 0xff, 0xf7, 0x9a, 0xf2, 0xaa, 0xcb, 0x45, 0x75, 0xe2,
0x6c, 0x41, 0xf5, 0xd9, 0xe3, 0xdd, 0x2b, 0xff, 0xfe, 0x2f, 0x25, 0x6e, 0x14, 0x4d, 0x1c, 0x0b,
0x11, 0x43, 0x57, 0xff, 0x8a, 0xf6, 0xdb, 0x90, 0x47, 0xf1, 0x2c, 0x72, 0x5f, 0x12, 0xf3, 0xbf,
0xfd, 0xb6, 0x8c, 0x1f, 0x2a, 0x79, 0x43, 0xec, 0x20, 0x9c, 0xcf, 0xff, 0x6c, 0x8a, 0x5d, 0x2e,
0xe4, 0x89, 0x32, 0x9a, 0xe5, 0xa6, 0xe8, 0xab, 0xff, 0xf1, 0xaa, 0x55, 0x4e, 0x89, 0x31, 0xd5,
0x8f, 0x95, 0xfa, 0x7f, 0xf8, 0x83, 0x93, 0x10, 0xaf, 0x77, 0xdb, 0x76, 0x14, 0x41, 0x6b, 0x2f,
0xff, 0xdb, 0xb3, 0xff, 0xee, 0xc9, 0xc6, 0xfe, 0x3a, 0x41, 0x29, 0xff, 0xed, 0x04, 0xf6, 0x57,
0xff, 0xad, 0x42, 0x1c, 0xbd, 0xb4, 0x6c, 0xa7, 0x9c, 0x87, 0xff, 0xfe, 0x68, 0xd4, 0xc9, 0x92,
0xa5, 0x0d, 0x13, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0xad, 0x6b, 0x5a, 0xd6, 0xb5, 0xad, 0x6b, 0x5a,
0xd6, 0xa0, 0xbf, 0xff, 0xe3, 0xc2, 0xd8, 0x6d, 0xe4, 0xc2, 0xf4, 0x25, 0xcb, 0x59, 0x2d, 0x72,
0x69, 0x07, 0x74, 0x08, 0x72, 0x03, 0xa8, 0x50, 0x05, 0xd6, 0xc4, 0x6f, 0x61, 0x7c, 0x5e, 0x8f,
0xb1, 0x6c, 0x68, 0x31, 0xa0, 0xc6, 0x83, 0x1a, 0x0c, 0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9b, 0x82,
0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9a, 0x13, 0x7a, 0xec, 0x03, 0x8b, 0x5c, 0xd7, 0xad, 0xfa, 0x2c,
0x33, 0xd1, 0x4e, 0x0c, 0x78, 0x31, 0xe0, 0xc7, 0x83, 0x1e, 0x10, 0x04, 0x40, 0x11, 0x00, 0x44,
0x01, 0x10, 0x04, 0x40, 0x11, 0x00, 0x43, 0x9c, 0x89, 0x17, 0xff, 0xfd, 0x9c, 0xe7, 0x03, 0xc3,
0xae, 0xde, 0x4e, 0xb4, 0xa4, 0x8b, 0xab, 0x48, 0x6a, 0xfd, 0x60, 0x7c, 0x57, 0x1f, 0xff, 0xd9,
0xb4, 0x6f, 0x2c, 0x0c, 0xc9, 0xc2, 0xcd, 0xf5, 0x60, 0x12, 0x59, 0xc5, 0xae, 0xf1, 0x27, 0x19,
0x97, 0x69, 0xa5, 0xff, 0xfe, 0xcc, 0xd6, 0x37, 0x17, 0x92, 0xb3, 0x66, 0x3f, 0xdb, 0x68, 0x44,
0x9e, 0x21, 0x12, 0x8a, 0x48, 0x6e, 0xbf, 0xff, 0xb3, 0x00, 0xda, 0x17, 0x61, 0x02, 0xfd, 0x26,
0x97, 0x1d, 0x83, 0x48, 0x87, 0x5a, 0x4e, 0xcb, 0x8f, 0x24, 0x08, 0xbf, 0xff, 0xa5, 0x31, 0x1b,
0x76, 0x35, 0x05, 0x4a, 0x6b, 0x42, 0x63, 0x65, 0x8e, 0x3d, 0x96, 0x3e, 0x36, 0xef, 0xff, 0xff,
0xe9, 0x3f, 0x11, 0xd7, 0xa0, 0x62, 0x26, 0x55, 0xb4, 0x6c, 0xb0, 0x97, 0x1f, 0x37, 0xbb, 0x6c,
0x1c, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0x34, 0xc7, 0x82, 0x42, 0x10, 0x85, 0x44, 0x21, 0x08,
0x42, 0x17, 0x27, 0xff, 0xd9, 0x5a, 0x82, 0x0e, 0xab, 0x3d, 0xa6, 0xf5, 0x32, 0x85, 0xb7, 0x7f,
0xff, 0xf8, 0xc7, 0xf1, 0x63, 0xc2, 0xcd, 0xea, 0x59, 0x25, 0x92, 0x59, 0x25, 0x92, 0x59, 0x7d,
0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x92, 0x8d, 0xf2, 0x00, 0x48, 0xbc, 0xcb,
0x5a, 0xd6, 0xb5, 0xc0, 0xa5, 0x29, 0x4a, 0x52, 0x06, 0x77, 0xcf, 0xff, 0xb8, 0x30, 0x59, 0x27,
0xf7, 0x62, 0x38, 0x0c, 0xa1, 0x35, 0xff, 0xec, 0x9b, 0xb6, 0x5d, 0x9a, 0x02, 0x56, 0xf3, 0xc6,
0x71, 0x48, 0xbb, 0x5f, 0xfe, 0xd6, 0x42, 0xaa, 0x1d, 0x7a, 0xde, 0x77, 0x06, 0xab, 0xaf, 0xff,
0x64, 0xdd, 0xb2, 0xec, 0xd0, 0x12, 0xb7, 0x9e, 0x33, 0x80, 0x15, 0xbf, 0xfd, 0xac, 0x85, 0x54,
0x3a, 0xf5, 0xbc, 0xee, 0x0d, 0x57, 0x5f, 0xfe, 0xc9, 0xbb, 0x65, 0xd9, 0xa0, 0x25, 0x6f, 0x3c,
0x67, 0x26, 0x41, 0xff, 0xff, 0xfd, 0x21, 0x61, 0xf6, 0xe8, 0x83, 0x3c, 0x43, 0x61, 0x53, 0x6b,
0xfc, 0x33, 0xa6, 0xbe, 0x35, 0x59, 0x89, 0xbf, 0xff, 0xca, 0x91, 0xd0, 0xc1, 0x31, 0xc1, 0xe7,
0xbb, 0x89, 0xf6, 0x15, 0x87, 0x70, 0x29, 0xba, 0x64, 0xba, 0x7e, 0x88, 0xbf, 0xff, 0xb2, 0x30,
0xd1, 0x95, 0xef, 0xe1, 0xec, 0x3b, 0xef, 0xd8, 0x40, 0x72, 0x7f, 0x53, 0x27, 0xc6, 0x4c, 0xfb,
0xff, 0xfb, 0x23, 0x0d, 0x19, 0x5e, 0xfe, 0x1e, 0xc3, 0xbe, 0xfd, 0x84, 0x07, 0x27, 0xf5, 0x32,
0x7c, 0x64, 0x69, 0x54, 0x07, 0x00, 0x21, 0xa1, 0xb8, 0x31, 0xe7, 0x92, 0x4d, 0x21, 0x98, 0xe1,
0xe9, 0x7f, 0xff, 0x8d, 0x04, 0x56, 0x50, 0xf3, 0x87, 0x8e, 0xe9, 0x2c, 0x8b, 0x9a, 0x5d, 0x0e,
0x69, 0x7a, 0xdf, 0xb3, 0x5f, 0xff, 0xe3, 0x40, 0xd3, 0x1c, 0xfe, 0x0e, 0x49, 0x93, 0x22, 0xcd,
0xe3, 0xac, 0xa4, 0x02, 0xfb, 0xa5, 0x1b, 0xab, 0x9c, 0x9f, 0xff, 0xd6, 0x74, 0xad, 0x35, 0x38,
0x54, 0x07, 0xfc, 0xf4, 0x47, 0x3e, 0xc8, 0x84, 0xfb, 0x24, 0x7a, 0xe0, 0x27, 0xff, 0xf5, 0x9d,
0x13, 0x49, 0x92, 0x11, 0x5f, 0xc8, 0xd6, 0x12, 0xc1, 0xc0, 0x69, 0x64, 0x95, 0x44, 0x79, 0x86,
0x49, 0xb4, 0x9f, 0xfe, 0x3f, 0x97, 0x6d, 0x34, 0x2f, 0xd0, 0xc9, 0x77, 0xb6, 0x27, 0xff, 0x8f,
0xe5, 0xdb, 0x4d, 0x0b, 0xf4, 0x32, 0x5d, 0xe3, 0x55, 0x7d, 0xbf, 0xfd, 0x07, 0x21, 0x7d, 0xd6,
0x5c, 0x66, 0xa7, 0xd9, 0x86, 0xaf, 0xff, 0x41, 0xc8, 0x5f, 0x75, 0x97, 0x19, 0xa9, 0xf6, 0x50,
0x7a, 0x79, 0xc9, 0xff, 0xe5, 0x1b, 0x2a, 0x9d, 0xe9, 0xe9, 0x3d, 0x4b, 0x9a, 0x64, 0xff, 0xf1,
0xfc, 0xbb, 0x69, 0xa1, 0x7e, 0x86, 0x4b, 0xbc, 0x6a, 0xfa, 0x17, 0xff, 0xa4, 0xbb, 0x55, 0x2a,
0x0d, 0xeb, 0x17, 0x5e, 0x75, 0x2b, 0xff, 0xd0, 0x72, 0x17, 0xdd, 0x65, 0xc6, 0x6a, 0x7d, 0x94,
0x1c, 0x72, 0x69, 0x71, 0xaf, 0xff, 0xe8, 0xc5, 0xf4, 0xd9, 0x5a, 0x12, 0x49, 0x24, 0xda, 0x28,
0xa2, 0x8a, 0x28, 0x9d, 0x85, 0xff, 0xf9, 0x58, 0x68, 0xe1, 0xbe, 0x89, 0x87, 0x0a, 0x41, 0xbd,
0x40, 0x5a, 0xbe, 0x24, 0x00, 0xa4, 0xd7, 0xb7, 0x6b, 0xc7, 0x0b, 0x74, 0x72, 0x39, 0x7e, 0x97,
0xe9, 0x7e, 0x97, 0xe9, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe7, 0xf1,
0x59, 0x7f, 0xff, 0xf5, 0x72, 0xea, 0x9c, 0xa5, 0xe0, 0xe5, 0xe5, 0x3e, 0x53, 0xe5, 0x3e, 0x53,
0xe5, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x54, 0x5d, 0x80, 0x5f, 0xff,
0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98, 0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xe9, 0xaf, 0xff, 0xca, 0xc3,
0x47, 0x0d, 0xf4, 0x4c, 0x38, 0x52, 0x0d, 0xea, 0x02, 0xd6, 0x96, 0x75, 0xfb, 0xff, 0xf4, 0xf5,
0xfe, 0xc9, 0x8b, 0xed, 0xa0, 0xd4, 0x3f, 0xd4, 0x4e, 0xf3, 0xbb, 0xff, 0xf4, 0xf2, 0xb7, 0x3b,
0x35, 0x34, 0x01, 0xbd, 0xcf, 0xc7, 0x6b, 0xc8, 0x86, 0xe2, 0xdf, 0xff, 0x95, 0xa6, 0x26, 0x6d,
0x66, 0xef, 0x24, 0x4f, 0x9c, 0x50, 0x2e, 0x0b, 0x5f, 0xff, 0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98,
0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xad, 0x73, 0x9c, 0xf8, 0xd7, 0xff, 0xe5, 0x69, 0x89, 0x9b, 0x5a,
0xd6, 0xc7, 0xef, 0x7b, 0xde, 0xf7, 0x16, 0xbf, 0xfd, 0x93, 0xe0, 0xd5, 0x22, 0xf4, 0x00, 0x3c,
0x0c, 0x4d, 0xbe, 0x7f, 0xff, 0xfd, 0x40, 0x7a, 0x70, 0x28, 0xb4, 0x34, 0xd9, 0x35, 0x93, 0x59,
0x35, 0x93, 0x59, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x13, 0x6a, 0x84,
0x01, 0x71, 0x0e, 0xa9, 0x4a, 0x52, 0x95, 0x06, 0xb5, 0xad, 0x6b, 0x51, 0x99, 0x0b, 0xdf, 0xfe,
0x83, 0xd1, 0xc1, 0x15, 0xe1, 0x93, 0x97, 0xb2, 0x8b, 0xd5, 0xff, 0xe8, 0x3d, 0x1c, 0x11, 0x5e,
0x19, 0x39, 0x7b, 0x28, 0x32, 0x5b, 0xf5, 0x6f, 0xff, 0x84, 0x61, 0x55, 0x1a, 0xb6, 0xa2, 0x05,
0x17, 0xea, 0x77, 0xff, 0xbf, 0xa1, 0xda, 0x6b, 0x8b, 0x14, 0x60, 0xb1, 0x38, 0x81, 0x17, 0x97,
0xff, 0xb5, 0x90, 0xaa, 0x87, 0x5e, 0xb7, 0x9d, 0xc1, 0x9a, 0xeb, 0xff, 0xd9, 0x3e, 0x0d, 0x52,
0x2f, 0x40, 0x03, 0xc0, 0xc4, 0xe5, 0x54, 0xa6, 0xdf, 0xff, 0xa7, 0x95, 0xb9, 0xd9, 0xa9, 0xa0,
0x0d, 0xee, 0x7e, 0x3b, 0x5e, 0x72, 0xef, 0xff, 0xd3, 0xca, 0xdc, 0xec, 0xd4, 0xd0, 0x06, 0xf7,
0x3f, 0x1d, 0xaf, 0x22, 0x29, 0xf5, 0x7f, 0xfe, 0x56, 0x1a, 0x38, 0x6f, 0xa2, 0x61, 0xc2, 0x90,
0x6f, 0x50, 0x17, 0xa6, 0xbf, 0xff, 0x2b, 0x0d, 0x1c, 0x37, 0xd1, 0x30, 0xe1, 0x48, 0x37, 0xa8,
0x0b, 0x55, 0x39, 0xaf, 0xc0, 0x10, 0x61, 0x2d, 0x46, 0x41, 0x11, 0x62, 0x85, 0x8e, 0x38, 0x4f,
0x9f, 0xff, 0xa0, 0xec, 0x48, 0x58, 0xc2, 0x3d, 0xd1, 0xc3, 0x19, 0xc3, 0x9c, 0x10, 0x5f, 0xff,
0x9a, 0x0a, 0x59, 0x0b, 0x58, 0xda, 0x57, 0xf6, 0xb4, 0x6d, 0xdb, 0xd2, 0xd0, 0x74, 0xff, 0xfb,
0x6e, 0x83, 0xdb, 0x08, 0x11, 0x73, 0xd9, 0x78, 0x19, 0x7e, 0x5b, 0xa4, 0xff, 0xfb, 0x6d, 0x28,
0x6c, 0x1c, 0x47, 0xe7, 0xaa, 0x54, 0x14, 0x29, 0x10, 0x54, 0x84, 0xaf, 0x1f, 0xfe, 0x55, 0xcd,
0xa1, 0xdc, 0xc5, 0x8c, 0x5f, 0x8b, 0xa9, 0x49, 0xff, 0xe5, 0x5c, 0xda, 0x1d, 0xcc, 0x58, 0xc5,
0xf8, 0xb9, 0xf9, 0xf4, 0xab, 0xff, 0xda, 0xe2, 0xc5, 0x4b, 0x14, 0xd2, 0x8e, 0xe6, 0xbb, 0x98,
0xbf, 0xfd, 0xae, 0x2c, 0x54, 0xb1, 0x4d, 0x28, 0xee, 0x6b, 0xab, 0xd2, 0x09, 0xed, 0xff, 0xee,
0x2e, 0x2a, 0xa2, 0x37, 0x77, 0x7d, 0xbd, 0x0c, 0x42, 0xff, 0xf6, 0xb8, 0xb1, 0x52, 0xc5, 0x34,
0xa3, 0xb9, 0xae, 0xae, 0xd0, 0x74, 0xff, 0xf3, 0x35, 0xea, 0xa4, 0x9a, 0x1c, 0x9b, 0xe5, 0xac,
0xa2, 0x7f, 0xf9, 0x57, 0x36, 0x87, 0x73, 0x16, 0x31, 0x7e, 0x2e, 0x7e, 0xd0, 0x50,
]
static let bars709Full: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6e, 0x02, 0x02, 0x02,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x31,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xca, 0xe6,
0x94, 0x42, 0x38, 0x60, 0x38, 0x52, 0x45, 0x24, 0x52, 0x45, 0x24, 0x55, 0x59, 0x55, 0x95, 0x59,
0x55, 0x95, 0x59, 0x55, 0x95, 0x59, 0x56, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x91, 0xc3, 0x2c, 0x30, 0xc3, 0x0c, 0x31, 0x1c, 0x71, 0xc7,
0x1c, 0x71, 0xc7, 0x85, 0x3f, 0xff, 0xda, 0x86, 0xda, 0x58, 0x75, 0x4a, 0x3b, 0xc1, 0xa2, 0x82,
0x3d, 0xff, 0xff, 0x22, 0x76, 0xbf, 0xff, 0xfc, 0xd8, 0x79, 0x99, 0x24, 0xe8, 0x0f, 0x75, 0xff,
0x5f, 0xf5, 0xff, 0x5f, 0xf6, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe0,
0x9c, 0xff, 0xf6, 0x3d, 0xd0, 0x03, 0xeb, 0xce, 0x73, 0x9c, 0xe7, 0x3a, 0xd6, 0xb5, 0xad, 0x6b,
0x4c, 0xf4, 0xa0, 0x03, 0x93, 0xff, 0xe6, 0x75, 0xd0, 0x3d, 0xcf, 0xf0, 0x4e, 0xd2, 0x72, 0x8d,
0x74, 0x7f, 0xfe, 0x89, 0x44, 0x87, 0x74, 0x94, 0xe6, 0x55, 0x90, 0x59, 0x45, 0xa5, 0x9a, 0x2e,
0x4f, 0x7f, 0xfc, 0xeb, 0x9a, 0x21, 0xb2, 0xc8, 0x3c, 0x30, 0xf4, 0x24, 0x7a, 0x3f, 0xff, 0x44,
0xa2, 0x43, 0xba, 0x4a, 0x73, 0x2a, 0xc8, 0x2c, 0x9a, 0x79, 0xa3, 0xe3, 0xff, 0xeb, 0x8f, 0x95,
0x52, 0x72, 0x52, 0x34, 0xc8, 0xe9, 0x8b, 0x44, 0xff, 0xf6, 0xf0, 0x3a, 0x83, 0x7c, 0x5c, 0xce,
0xd4, 0xba, 0x89, 0x3f, 0x2b, 0x0c, 0x90, 0x70, 0xa0, 0x00, 0xf0, 0x2c, 0x88, 0xe9, 0x8e, 0x1f,
0xe0, 0x06, 0x79, 0xa1, 0x79, 0x80, 0x12, 0x3f, 0xff, 0x7d, 0xbc, 0x64, 0xb9, 0x97, 0xb8, 0xe8,
0x1b, 0x03, 0xeb, 0x6f, 0xff, 0xca, 0xe9, 0xb5, 0x52, 0xa6, 0x29, 0x29, 0xfc, 0xfb, 0x8c, 0x40,
0x1c, 0x5f, 0xff, 0x35, 0x77, 0x52, 0xf6, 0x79, 0xec, 0xa4, 0x7e, 0x2a, 0xdf, 0xaf, 0xff, 0x81,
0xc0, 0x3b, 0x0b, 0x8b, 0x27, 0xcf, 0x6a, 0xd7, 0x4c, 0xec, 0x11, 0x59, 0xff, 0xf6, 0xe2, 0x9f,
0xfe, 0xb6, 0x70, 0xc4, 0x83, 0x1c, 0x05, 0xbb, 0xff, 0xf0, 0x0d, 0x33, 0xc8, 0xb1, 0xeb, 0x05,
0x9f, 0x5c, 0xc7, 0x20, 0xdf, 0x8f, 0xff, 0x67, 0x88, 0xff, 0xea, 0x77, 0x5e, 0x4b, 0x79, 0xd4,
0xd2, 0x3f, 0xfe, 0xc4, 0x27, 0x79, 0x7b, 0xdc, 0x96, 0x40, 0xcd, 0xc7, 0x6f, 0xa8, 0x0a, 0x28,
0xcf, 0xff, 0xfe, 0xef, 0xdb, 0xd4, 0xd0, 0x3c, 0x78, 0xf1, 0xe3, 0xd0, 0x20, 0x40, 0x81, 0x02,
0x04, 0x07, 0x43, 0xff, 0xff, 0x55, 0x80, 0x26, 0x9f, 0xe0, 0xb3, 0x64, 0xa3, 0x87, 0x88, 0x9c,
0xee, 0x95, 0x9f, 0xda, 0x98, 0x80, 0x2c, 0xf5, 0xd9, 0x3a, 0xe6, 0xfa, 0xd0, 0x75, 0x46, 0xd0,
0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1,
0x03, 0xb7, 0xf9, 0xaf, 0xdf, 0xff, 0xfc, 0x4a, 0xae, 0x23, 0x6e, 0x1b, 0xcb, 0xd4, 0x2e, 0x02,
0xe0, 0x2e, 0x02, 0xe0, 0x2e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xe7,
0xc8, 0x1b, 0x7f, 0xff, 0xbb, 0x0c, 0x4b, 0xdb, 0x02, 0x66, 0xaa, 0x94, 0xf4, 0xed, 0xd6, 0x0d,
0xd4, 0x3f, 0xff, 0xb1, 0xa5, 0x3e, 0xed, 0x3c, 0xe9, 0x07, 0xa9, 0x44, 0xdf, 0xb9, 0x10, 0x00,
0x34, 0x32, 0x13, 0xff, 0xef, 0x84, 0xe7, 0xf8, 0x61, 0x80, 0x24, 0xdf, 0xcb, 0x21, 0xe5, 0xb1,
0x52, 0x09, 0xff, 0xfa, 0xbb, 0x0c, 0x7c, 0xe1, 0xdb, 0x30, 0xf6, 0x95, 0x55, 0xf5, 0x15, 0xdf,
0x61, 0x17, 0x9f, 0xff, 0x9d, 0xf9, 0xe9, 0x79, 0xe7, 0x7b, 0x14, 0x7a, 0x36, 0x0d, 0x1b, 0xc7,
0x1c, 0xf7, 0xff, 0xe1, 0xfd, 0x7f, 0x44, 0xd8, 0x9b, 0xcf, 0x7a, 0x6e, 0x17, 0x38, 0xcc, 0x8c,
0xc2, 0x0f, 0x4f, 0x57, 0xff, 0xf7, 0x55, 0x2b, 0x2a, 0xaa, 0xaa, 0xaa, 0xb2, 0x79, 0xe7, 0x9e,
0x79, 0xe7, 0x59, 0xbf, 0xfe, 0xe7, 0x49, 0x8d, 0x06, 0x01, 0xfc, 0x41, 0x0b, 0xa7, 0x35, 0xc7,
0x13, 0x27, 0xff, 0xff, 0x14, 0x1e, 0x21, 0x48, 0x15, 0xf9, 0xbc, 0xbd, 0xcb, 0xdc, 0xbd, 0xcb,
0xdc, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xab, 0xe2, 0x36, 0x25, 0x80,
0x2f, 0xfb, 0x08, 0xc6, 0x31, 0x8c, 0x6f, 0xad, 0x6b, 0x5a, 0xd6, 0x36, 0x03, 0x4b, 0xff, 0xeb,
0xc5, 0x63, 0x41, 0x65, 0x0a, 0x0c, 0x08, 0x18, 0xba, 0xd7, 0xff, 0xf7, 0xf2, 0x5f, 0x53, 0x0e,
0xbe, 0xd7, 0xe6, 0x1a, 0xd5, 0x75, 0x30, 0xa2, 0xaf, 0xff, 0x5e, 0x5e, 0x52, 0x94, 0x4f, 0x21,
0x29, 0x08, 0xa5, 0xbf, 0xbc, 0xff, 0xfb, 0xc1, 0xf6, 0xc9, 0x77, 0xc4, 0x88, 0x96, 0xee, 0x22,
0xbd, 0x16, 0x4f, 0xff, 0xea, 0x6c, 0x84, 0x20, 0xfc, 0xc6, 0x13, 0x44, 0x4d, 0x48, 0x56, 0x7f,
0xff, 0x5d, 0x66, 0xc1, 0x99, 0x29, 0xb7, 0xe2, 0x87, 0x73, 0x1f, 0xdc, 0x02, 0xf5, 0xff, 0xf8,
0x93, 0x6e, 0xdb, 0xb9, 0xee, 0x66, 0x4b, 0x3c, 0xfd, 0x63, 0xc6, 0x4c, 0x7b, 0xff, 0xf2, 0x3f,
0xb0, 0x4a, 0xa8, 0xc5, 0x0e, 0x87, 0x4e, 0x84, 0xf5, 0x99, 0x36, 0x80, 0xac, 0x7f, 0xfe, 0x4f,
0x7b, 0xad, 0x46, 0x83, 0x6c, 0xb9, 0xa6, 0x5d, 0x76, 0xca, 0x57, 0x08, 0xff, 0xfc, 0x76, 0xd9,
0x45, 0x85, 0x77, 0xf4, 0x9a, 0x09, 0xcb, 0xc2, 0x6e, 0x53, 0x03, 0xa7, 0x64, 0x00, 0x78, 0x92,
0xba, 0xea, 0xdd, 0xb9, 0xae, 0xec, 0xc9, 0xdb, 0x6a, 0xab, 0xff, 0xf6, 0x2c, 0x94, 0x07, 0x9e,
0x76, 0x5b, 0x4e, 0x17, 0x35, 0x0b, 0xad, 0xd6, 0x79, 0xff, 0xfb, 0x5b, 0x84, 0xd1, 0x00, 0x16,
0xc4, 0x5b, 0xa1, 0x12, 0xa1, 0xaf, 0x57, 0xd5, 0x8a, 0x3f, 0xff, 0x22, 0xe5, 0xff, 0xe7, 0x9e,
0x2a, 0x10, 0xbe, 0x98, 0xff, 0x4e, 0x32, 0x46, 0x9f, 0xff, 0x90, 0x1c, 0x19, 0x58, 0xeb, 0x75,
0xba, 0x0b, 0x50, 0xf1, 0xa6, 0xfd, 0x2c, 0xf7, 0x4a, 0xff, 0xff, 0xae, 0xb3, 0x60, 0xcc, 0x94,
0xdb, 0xf1, 0x43, 0xb9, 0x93, 0xab, 0xff, 0xd7, 0x10, 0x0f, 0x3b, 0x8a, 0xf4, 0xc9, 0x7c, 0x5d,
0xbb, 0xf2, 0xf3, 0xbf, 0xfe, 0x66, 0x6f, 0x82, 0x36, 0xed, 0xe9, 0xbf, 0x6d, 0x54, 0xc7, 0x9f,
0xff, 0x31, 0xaf, 0x10, 0x37, 0x79, 0xc3, 0xe4, 0x44, 0x04, 0xc7, 0x9b, 0xa2, 0xaf, 0xff, 0xd6,
0xbd, 0x8c, 0x63, 0x02, 0xd0, 0x1b, 0xdc, 0x6f, 0xdb, 0x96, 0x9f, 0xfe, 0xb0, 0xd5, 0x05, 0x68,
0x65, 0xb1, 0x62, 0xa5, 0xb2, 0x19, 0xb5, 0x97, 0xff, 0xf2, 0xf0, 0xd6, 0xb5, 0xa6, 0x77, 0xe3,
0x3d, 0x8d, 0x17, 0xb5, 0x4f, 0xff, 0x95, 0xc6, 0x26, 0x53, 0xc2, 0x7f, 0xfb, 0x3c, 0xb7, 0x47,
0xc6, 0xca, 0x79, 0xc8, 0x7f, 0xff, 0xea, 0x14, 0x53, 0xa7, 0x46, 0x89, 0xb3, 0x0b, 0x10, 0xb1,
0x0b, 0x10, 0xb1, 0x0c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xbf, 0xcb,
0xff, 0xfe, 0x78, 0xff, 0xb5, 0x5e, 0xcd, 0x7d, 0xb8, 0x9e, 0x9b, 0xf6, 0x77, 0x9c, 0xb6, 0x6b,
0xb3, 0xd6, 0xbc, 0x5a, 0x3a, 0x85, 0x00, 0x5d, 0x6e, 0x8f, 0x57, 0x3b, 0xf7, 0x0d, 0x2d, 0x5f,
0x2f, 0xa4, 0xbe, 0x92, 0xfa, 0x4b, 0xe9, 0x30, 0x02, 0x40, 0x09, 0x00, 0x24, 0x00, 0x90, 0x02,
0x40, 0x09, 0x00, 0x23, 0xe9, 0x97, 0xae, 0xc0, 0x38, 0xb7, 0x4c, 0x8b, 0x9f, 0x2b, 0x82, 0xc6,
0x93, 0xf6, 0xc3, 0xdb, 0x0f, 0x6c, 0x3d, 0xb0, 0xf6, 0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xbf, 0x26,
0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xb8, 0xd8, 0x91, 0x7f, 0xff, 0xde, 0xf2, 0x9f, 0x72, 0x5f, 0xa4,
0xdf, 0x42, 0xd1, 0x53, 0xe9, 0xe1, 0x81, 0x9b, 0x85, 0x88, 0x7b, 0xb1, 0xf1, 0xff, 0xfd, 0xed,
0xc1, 0xe8, 0x25, 0x65, 0xdd, 0x23, 0xcd, 0xe2, 0x3c, 0xbd, 0xd5, 0x70, 0xef, 0xfc, 0x2b, 0x7e,
0xd8, 0x76, 0x9a, 0x5f, 0xff, 0xef, 0x62, 0xf8, 0x1e, 0xc4, 0x28, 0xd5, 0xd5, 0x53, 0x4b, 0xb8,
0x01, 0x3b, 0x94, 0x00, 0x9d, 0xe8, 0x5e, 0x76, 0xaf, 0xff, 0xef, 0x57, 0x8e, 0x51, 0x21, 0xe7,
0xdc, 0x18, 0x7b, 0xe1, 0xc7, 0xea, 0x80, 0xfa, 0xfa, 0x1b, 0x98, 0x35, 0x31, 0x02, 0x2f, 0xff,
0xec, 0x5a, 0x9f, 0x51, 0x14, 0xaa, 0x31, 0xb1, 0x17, 0x53, 0x71, 0xf9, 0x54, 0xac, 0xfc, 0xaa,
0x7a, 0x67, 0x94, 0x7f, 0xff, 0xec, 0x4f, 0x2d, 0xf7, 0xe1, 0xd4, 0x9a, 0xcb, 0x35, 0xe5, 0xbc,
0x5a, 0xc5, 0x0a, 0xf2, 0xf4, 0xb6, 0x9e, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0xc2, 0x0e, 0xc5,
0x55, 0x55, 0x55, 0x56, 0x73, 0xcf, 0x3c, 0xf3, 0xcf, 0x44, 0x4f, 0xff, 0xc9, 0xed, 0xa4, 0x9b,
0x63, 0x01, 0x60, 0x76, 0xb4, 0x22, 0x96, 0xdd, 0xff, 0xff, 0xe8, 0x9f, 0xd0, 0x8f, 0x37, 0x39,
0x09, 0x7a, 0x97, 0xa9, 0x7a, 0x97, 0xa9, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf,
0x7b, 0xf7, 0xaa, 0x37, 0xc8, 0x01, 0x25, 0x4b, 0xfb, 0xde, 0xf7, 0xbe, 0x3c, 0x63, 0x18, 0xc6,
0x2e, 0xb9, 0xdf, 0x3f, 0xff, 0x37, 0x0d, 0x7b, 0x55, 0x6f, 0xef, 0x54, 0x55, 0x48, 0x47, 0xaf,
0xff, 0x93, 0x79, 0x1c, 0xfd, 0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x94, 0x8b, 0xb5, 0xff, 0xf2,
0x9c, 0x0c, 0x63, 0x11, 0xa5, 0x98, 0x4e, 0xe1, 0x5d, 0xe0, 0xaf, 0xff, 0x93, 0x79, 0x1c, 0xfd,
0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x80, 0x2b, 0x7f, 0xfc, 0xa7, 0x03, 0x18, 0xc4, 0x69, 0x66,
0x13, 0xb8, 0x57, 0x78, 0x2b, 0xff, 0xe4, 0xde, 0x47, 0x3f, 0x71, 0xad, 0xa0, 0x84, 0x2f, 0xbc,
0xa9, 0x90, 0x7f, 0xff, 0xff, 0x61, 0x11, 0x60, 0xbe, 0x7a, 0x34, 0x05, 0xfa, 0x7c, 0x2c, 0x02,
0x93, 0x7f, 0x8f, 0x3b, 0x49, 0x78, 0xe9, 0x6f, 0xff, 0xf4, 0x70, 0xc4, 0xb3, 0x3c, 0x00, 0xcf,
0x71, 0x94, 0xc8, 0x94, 0xdc, 0x24, 0xd1, 0x48, 0xe9, 0xf2, 0x10, 0xa7, 0xa2, 0x2f, 0xff, 0xef,
0x2a, 0x8c, 0x71, 0x0f, 0x59, 0xc2, 0x0c, 0x95, 0x81, 0x8b, 0xe2, 0x2b, 0xe1, 0xee, 0x90, 0x10,
0xb3, 0x86, 0xff, 0xfe, 0xf2, 0xa8, 0xc7, 0x10, 0xf5, 0x9c, 0x20, 0xc9, 0x58, 0x18, 0xbe, 0x22,
0xbe, 0x1e, 0xe9, 0x01, 0x0b, 0x1e, 0xd5, 0x01, 0xc0, 0x08, 0x99, 0x6e, 0xc0, 0x1b, 0x0a, 0x14,
0xed, 0x5f, 0x98, 0xe1, 0xe9, 0x7f, 0xff, 0x9c, 0x7e, 0xd2, 0x28, 0xb1, 0xf8, 0xa4, 0xa1, 0x96,
0xc9, 0x93, 0x29, 0x3e, 0xe9, 0x94, 0xa0, 0x2a, 0xab, 0x01, 0x7f, 0xff, 0x9c, 0x7d, 0xda, 0x96,
0xf9, 0xd2, 0x69, 0xb0, 0xa7, 0x3b, 0x45, 0x93, 0xda, 0xa0, 0x79, 0x82, 0x63, 0xd1, 0xee, 0x72,
0x7f, 0xff, 0x70, 0x31, 0x67, 0xd0, 0x12, 0x65, 0x89, 0x85, 0x1f, 0x93, 0x7d, 0xd7, 0x33, 0x1e,
0xeb, 0x9a, 0x96, 0x50, 0x7e, 0x7f, 0xff, 0x70, 0x30, 0x02, 0x04, 0xb5, 0xf0, 0xc5, 0xf9, 0xe0,
0x80, 0x2d, 0x60, 0x15, 0xf9, 0x1c, 0x28, 0xfe, 0xa0, 0x24, 0x9b, 0x49, 0xff, 0xec, 0x64, 0xeb,
0x1e, 0x03, 0xe5, 0xf2, 0x2e, 0xab, 0xf9, 0x94, 0xff, 0xf6, 0x32, 0x75, 0x8f, 0x01, 0xf2, 0xf9,
0x17, 0x55, 0xfa, 0x3d, 0x5f, 0x6f, 0xff, 0x7a, 0xee, 0x0e, 0xbd, 0xf3, 0xa6, 0xca, 0xe6, 0x65,
0xee, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73, 0x32, 0xe6, 0x3a, 0x79, 0xc9,
0xff, 0xec, 0xa5, 0xd2, 0x94, 0x9a, 0xdc, 0x53, 0x86, 0xce, 0x4c, 0x7d, 0x4f, 0xff, 0x63, 0x27,
0x58, 0xf0, 0x1f, 0x2f, 0x91, 0x75, 0x5f, 0xa3, 0xdf, 0x42, 0xff, 0xf7, 0xca, 0x69, 0x4a, 0x4e,
0x36, 0x07, 0xe4, 0x5f, 0xa6, 0x64, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73,
0x32, 0xe6, 0x31, 0xc9, 0xa5, 0xc6, 0xbf, 0xff, 0xb9, 0x36, 0x97, 0x97, 0x1b, 0x06, 0x0c, 0x18,
0x32, 0x9b, 0x36, 0x6c, 0xd9, 0xb3, 0x66, 0x6f, 0x5f, 0xff, 0xa7, 0x4d, 0x7d, 0x81, 0x4a, 0x12,
0x07, 0xa6, 0x03, 0x36, 0x99, 0x29, 0xfb, 0x89, 0x00, 0x29, 0x36, 0xb1, 0xcb, 0x53, 0xbb, 0x3f,
0x16, 0x2b, 0x94, 0x29, 0x42, 0x94, 0x29, 0x42, 0x94, 0xce, 0x4c, 0xe4, 0xce, 0x4c, 0xe4, 0xce,
0x4c, 0xe4, 0xce, 0x43, 0x05, 0x97, 0xff, 0xff, 0x71, 0xfd, 0xb8, 0xb5, 0xb7, 0x91, 0x60, 0x43,
0xbc, 0x3b, 0xc3, 0xbc, 0x3b, 0xc3, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0,
0xbb, 0xdf, 0x60, 0x17, 0xff, 0xe9, 0xd3, 0x5f, 0x60, 0x52, 0x84, 0x81, 0xe9, 0x80, 0xcd, 0xa6,
0x4a, 0xbc, 0xaf, 0xff, 0xd3, 0xa6, 0xbe, 0xc0, 0xa5, 0x09, 0x03, 0xd3, 0x01, 0x9b, 0x4c, 0x95,
0x00, 0x59, 0xd7, 0xef, 0xff, 0xdb, 0x33, 0xb2, 0x06, 0x18, 0x54, 0x3c, 0x70, 0xf1, 0xf0, 0x79,
0x40, 0x76, 0xf7, 0xff, 0xed, 0x96, 0x31, 0x59, 0x07, 0xe9, 0xf6, 0x47, 0xc7, 0x39, 0xb1, 0xe3,
0x67, 0x37, 0x16, 0xff, 0xfd, 0x3a, 0xf8, 0xc4, 0xf3, 0xce, 0x3c, 0xec, 0x73, 0x29, 0xb9, 0x9a,
0x4c, 0xa5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e, 0x98, 0x0c, 0xda, 0x64, 0xa8,
0x07, 0x39, 0xcf, 0x8d, 0x7f, 0xfe, 0x9d, 0x7c, 0x62, 0x79, 0xe7, 0x9e, 0x7b, 0x07, 0x1c, 0x71,
0xc7, 0x1c, 0x5a, 0x57, 0xff, 0xc0, 0xe0, 0x1d, 0x85, 0xc5, 0x93, 0xe7, 0xb5, 0x6b, 0x38, 0x79,
0xff, 0xff, 0xf6, 0xf8, 0x6d, 0xad, 0xb2, 0x85, 0x51, 0x0c, 0xb0, 0xcb, 0x0c, 0xb0, 0xcb, 0x0d,
0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xcb, 0x4a, 0x10, 0x05, 0xd0, 0x23,
0x08, 0x42, 0x10, 0x85, 0xa7, 0x39, 0xce, 0x73, 0x74, 0xe4, 0x2f, 0x7f, 0xfb, 0x29, 0x99, 0x54,
0x5f, 0x24, 0x27, 0x86, 0x0b, 0x58, 0x02, 0xff, 0xf6, 0x53, 0x32, 0xa8, 0xbe, 0x48, 0x4f, 0x0c,
0x16, 0xa7, 0x55, 0xbf, 0x56, 0xff, 0xf9, 0x82, 0x5a, 0xa9, 0xbf, 0xfa, 0xf8, 0x8b, 0xe9, 0x45,
0x1b, 0xff, 0xe5, 0xca, 0x81, 0xda, 0xd2, 0x08, 0x7d, 0x9a, 0x79, 0xdc, 0xd1, 0x79, 0x7f, 0xfc,
0x17, 0x7a, 0x21, 0x94, 0xd2, 0x7c, 0x99, 0xf6, 0xac, 0x65, 0xff, 0xf0, 0x38, 0x07, 0x61, 0x71,
0x64, 0xf9, 0xed, 0x5a, 0xce, 0xb5, 0x4a, 0x6d, 0xff, 0xfb, 0x65, 0x8c, 0x56, 0x41, 0xfa, 0x7d,
0x91, 0xf1, 0xce, 0x6c, 0x78, 0xe5, 0x7b, 0xff, 0xf6, 0xcb, 0x18, 0xac, 0x83, 0xf4, 0xfb, 0x23,
0xe3, 0x9c, 0xd8, 0xf1, 0xb3, 0xa9, 0xf5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e,
0x98, 0x0c, 0xda, 0x64, 0xab, 0xca, 0xff, 0xfd, 0x3a, 0x6b, 0xec, 0x0a, 0x50, 0x90, 0x3d, 0x30,
0x19, 0xb4, 0xc9, 0x4f, 0xb3, 0x9a, 0xfc, 0x01, 0x07, 0x82, 0xbd, 0x99, 0xa4, 0x39, 0xc6, 0x16,
0x38, 0xe1, 0x3e, 0x7f, 0xfe, 0xc3, 0x11, 0x1f, 0x0c, 0x30, 0x50, 0x9a, 0x8a, 0x51, 0x85, 0x2b,
0x30, 0xc4, 0xbf, 0xff, 0x56, 0x20, 0x9e, 0xf3, 0xfe, 0xee, 0xf8, 0x08, 0x93, 0x45, 0x49, 0x85,
0xce, 0x83, 0xa7, 0xff, 0xe1, 0x8b, 0x31, 0xd8, 0x61, 0x7b, 0x18, 0xf8, 0x2b, 0x60, 0x15, 0xeb,
0xd2, 0xd3, 0xff, 0xf0, 0xc2, 0x92, 0x82, 0xe0, 0xf7, 0x90, 0x77, 0x52, 0xa4, 0xdb, 0xac, 0x6b,
0xc8, 0x4a, 0xf1, 0xff, 0xea, 0x4e, 0xd2, 0xf7, 0x03, 0xe5, 0x41, 0xd4, 0x47, 0x86, 0x33, 0xff,
0xd4, 0x9d, 0xa5, 0xee, 0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe5, 0xbd, 0x2a, 0xff, 0xf8, 0x71, 0xaa,
0xe2, 0x37, 0xa6, 0x65, 0x92, 0xa9, 0xb2, 0x35, 0xff, 0xf0, 0xe3, 0x55, 0xc4, 0x6f, 0x4c, 0xcb,
0x25, 0x53, 0x56, 0xb2, 0x09, 0xed, 0xff, 0xf1, 0x07, 0x9b, 0xb9, 0x9e, 0x74, 0xf2, 0x13, 0xd8,
0xa6, 0xd7, 0xff, 0xc3, 0x8d, 0x57, 0x11, 0xbd, 0x33, 0x2c, 0x95, 0x4d, 0x5a, 0x50, 0x74, 0xff,
0xf5, 0x41, 0x72, 0x20, 0x9d, 0xa7, 0x0f, 0xfc, 0x4b, 0xab, 0x33, 0xff, 0xd4, 0x9d, 0xa5, 0xee,
0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe6, 0x10, 0x50,
]
}
@@ -0,0 +1,96 @@
import CoreVideo
import XCTest
import simd
@testable import PunktfunkKit
/// Mirrors pf-client-core's `csc_rows` tests (crates/pf-client-core/src/video.rs) the Swift port
/// must stay in LOCKSTEP with the Rust implementation, so these are the same fixtures with the
/// same tolerances. A divergence here means the two sides would render the same stream
/// differently.
final class CscRowsTests: XCTestCase {
private func apply(_ u: CscUniform, _ yuv: SIMD3<Float>) -> SIMD3<Float> {
SIMD3(
simd_dot(SIMD3(u.r0.x, u.r0.y, u.r0.z), yuv) + u.r0.w,
simd_dot(SIMD3(u.r1.x, u.r1.y, u.r1.z), yuv) + u.r1.w,
simd_dot(SIMD3(u.r2.x, u.r2.y, u.r2.z), yuv) + u.r2.w)
}
/// 10-bit limited MSB-packed (P010/x444): reference white Y=940, black Y=64, neutral
/// chroma 512 sampled as UNORM16 of `code << 6`.
func testBt2020TenBitLimitedWhiteBlack() {
let rows = CscRows.rows(.init(matrix: 9, fullRange: false), depth: 10, msbPacked: true)
func s(_ code: UInt32) -> Float { Float(code << 6) / 65535.0 }
let white = apply(rows, SIMD3(s(940), s(512), s(512)))
let black = apply(rows, SIMD3(s(64), s(512), s(512)))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 0.002, "white \(white)")
XCTAssertEqual(black[i], 0.0, accuracy: 0.002, "black \(black)")
}
}
/// Reference white (Y=235, U=V=128 limited) RGB 1.0; reference black (Y=16) 0.0.
func testBt709LimitedWhiteBlack() {
let rows = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
let white = apply(rows, SIMD3(235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
let black = apply(rows, SIMD3(16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 0.005, "white \(white)")
XCTAssertEqual(black[i], 0.0, accuracy: 0.005, "black \(black)")
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the matrix-code
/// dispatch the two matrices MUST differ measurably, that difference is the whole bug
/// class this port fixes).
func testFullRangeAndRedExcursion() {
let rows601 = CscRows.rows(.init(matrix: 5, fullRange: true), depth: 8, msbPacked: false)
let white = apply(rows601, SIMD3(1.0, 0.5, 0.5))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 1e-5, "\(white)")
}
let red601 = apply(rows601, SIMD3(0.0, 0.5, 1.0))
XCTAssertEqual(red601[0], 2.0 * (1.0 - 0.299) * 0.5, accuracy: 1e-4, "\(red601)")
let rows709 = CscRows.rows(.init(matrix: 1, fullRange: true), depth: 8, msbPacked: false)
let red709 = apply(rows709, SIMD3(0.0, 0.5, 1.0))
XCTAssertEqual(red709[0], 2.0 * (1.0 - 0.2126) * 0.5, accuracy: 1e-4, "\(red709)")
XCTAssertGreaterThan(abs(red601[0] - red709[0]), 0.05)
}
/// Unspecified (2) and unknown matrix codes fall back to BT.709 the same default as the
/// Rust side and every punktfunk host's implicit SDR baseline.
func testUnspecifiedFallsBackTo709() {
let unspec = CscRows.rows(.init(matrix: 2, fullRange: false), depth: 8, msbPacked: false)
let bt709 = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
XCTAssertEqual(unspec, bt709)
}
/// `signal(of:)` reads the matrix off the buffer's attachment (what VideoToolbox propagates
/// from the VUI) and the range off the pixel format a 601-tagged buffer must come back as
/// matrix 5, an untagged one as unspecified (2), and a full-range sibling as fullRange.
func testSignalReadsAttachmentAndRange() throws {
func makeBuffer(_ format: OSType) throws -> CVPixelBuffer {
var pb: CVPixelBuffer?
let status = CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, format, nil, &pb)
guard status == kCVReturnSuccess, let pb else {
throw XCTSkip("could not allocate a \(format) pixel buffer")
}
return pb
}
let tagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
CVBufferSetAttachment(
tagged, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4,
.shouldPropagate)
XCTAssertEqual(CscRows.signal(of: tagged), CscRows.Signal(matrix: 5, fullRange: false))
let untagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
XCTAssertEqual(CscRows.signal(of: untagged), CscRows.Signal(matrix: 2, fullRange: false))
let full = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
CVBufferSetAttachment(
full, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020,
.shouldPropagate)
XCTAssertEqual(CscRows.signal(of: full), CscRows.Signal(matrix: 9, fullRange: true))
}
}
@@ -88,16 +88,19 @@ final class LoopbackIntegrationTests: XCTestCase {
// one feedback burst on the hostclient planes drain both and verify, end to // one feedback burst on the hostclient planes drain both and verify, end to
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD). // end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" { if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
var rumble: (pad: UInt16, low: UInt16, high: UInt16)? var rumble: (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
var hidout: [PunktfunkConnection.HidOutputEvent] = [] var hidout: [PunktfunkConnection.HidOutputEvent] = []
let feedbackDeadline = Date().addingTimeInterval(10) let feedbackDeadline = Date().addingTimeInterval(10)
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline { while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r } if rumble == nil, let r = try conn.nextRumble2(timeoutMs: 100) { rumble = r }
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) } if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
} }
XCTAssertEqual(rumble?.pad, 0) XCTAssertEqual(rumble?.pad, 0)
XCTAssertEqual(rumble?.low, 0x4000) XCTAssertEqual(rumble?.low, 0x4000)
XCTAssertEqual(rumble?.high, 0x8000) XCTAssertEqual(rumble?.high, 0x8000)
// The synthetic host emits a v2 envelope (400 ms TTL) assert the self-terminating tail
// survived the full wire C ABI Swift path, not just the level.
XCTAssertEqual(rumble?.ttlMs, 400)
XCTAssertTrue( XCTAssertTrue(
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)), hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
"missing the scripted lightbar event: \(hidout)") "missing the scripted lightbar event: \(hidout)")
@@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase {
renderer.stop() renderer.stop()
} }
func testLeaseSecondsInterpretsWireTTL() {
// The legacy no-lease sentinel nil (fall back to the staleness watchdog).
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
// A real lease its duration in seconds (non-nil for any ttl != noTTL).
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
}
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
// The whole point of v2: a host-supplied lease silences the motor faster than the
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
// default 400 ms TTL is well under that, on every platform.
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
XCTAssertNotNil(defaultTTL)
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
// The ticker must be able to observe an expired lease promptly (well within one TTL).
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
}
/// A v2 envelope with a short TTL, left unrenewed, must self-silence the renderer's core
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
func testEnvelopeExpiresWhenUnrenewed() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
// A 100 ms lease, then no renewal the ticker (50 ms) must silence it on its own.
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
Thread.sleep(forTimeInterval: 0.3)
// No assertion on private state; this exercises the expiry path + serial-queue teardown
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() { func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps) // The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going". // but trip well before a stuck rumble reads as "still going".
+1
View File
@@ -496,6 +496,7 @@ impl AppModel {
2, // audio_channels: stereo 2, // audio_channels: stereo
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference 0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
None, // launch: probe connect, no game None, // launch: probe connect, no game
pin, pin,
Some(identity), Some(identity),
+89 -2
View File
@@ -111,6 +111,11 @@ struct Args {
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made. /// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>, discover: Option<u64>,
/// `--clock-resync` — after the connect-time skew handshake, immediately run a SECOND
/// handshake on the same control stream and assert both estimates are sane and consistent:
/// the headless validator for the host answering `ClockProbe` at any time (what the native
/// clients' mid-stream re-sync relies on). Aborts the session when the re-probe fails.
clock_resync: bool,
} }
fn parse_mode(m: &str) -> Option<Mode> { fn parse_mode(m: &str) -> Option<Mode> {
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
.iter() .iter()
.any(|a| a == "--discover") .any(|a| a == "--discover")
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)), .then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
} }
} }
@@ -485,6 +491,10 @@ async fn session(args: Args) -> Result<()> {
| punktfunk_core::quic::CODEC_AV1, | punktfunk_core::quic::CODEC_AV1,
// `--codec` soft preference (0 = auto). The host honors it when it can emit it. // `--codec` soft preference (0 = auto). The host honors it when it can emit it.
preferred_codec: args.preferred_codec, preferred_codec: args.preferred_codec,
// PUNKTFUNK_CLIENT_PEAK_NITS=<nits> advertises a synthetic display volume — the host
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
// path can be validated headlessly (check the host's monitor caps / ADD log line).
display_hdr: punktfunk_core::client::display_hdr_env_override(),
} }
.encode(), .encode(),
) )
@@ -523,7 +533,8 @@ async fn session(args: Args) -> Result<()> {
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test // Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
// take it): align our clock to the host's so the per-frame capture→received latency is valid // take it): align our clock to the host's so the per-frame capture→received latency is valid
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0). // across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await { let first_skew = punktfunk_core::quic::clock_sync(&mut send, &mut recv).await;
let clock_offset_ns = match &first_skew {
Some(skew) => { Some(skew) => {
tracing::info!( tracing::info!(
offset_ns = skew.offset_ns, offset_ns = skew.offset_ns,
@@ -536,6 +547,42 @@ async fn session(args: Args) -> Result<()> {
None => None, None => None,
}; };
// `--clock-resync`: prove the host answers `ClockProbe` mid-session, not just at connect —
// the contract the native clients' mid-stream re-sync rests on. Run a full second handshake
// and require a sane, consistent estimate: both batches measure the same physical skew, so
// they must agree to within RTT-scale error (the handshake's own uncertainty is ≈ RTT/2).
if args.clock_resync {
let first = first_skew.as_ref().ok_or_else(|| {
anyhow!("clock-resync: host never answered the connect-time handshake")
})?;
let second = punktfunk_core::quic::clock_sync(&mut send, &mut recv)
.await
.ok_or_else(|| anyhow!("clock-resync: host did not answer the re-probe"))?;
let disagree_ns = (second.offset_ns - first.offset_ns).unsigned_abs();
let bound_ns = (first.rtt_ns + second.rtt_ns).max(2_000_000);
tracing::info!(
first_offset_ns = first.offset_ns,
second_offset_ns = second.offset_ns,
disagree_us = disagree_ns / 1000,
bound_us = bound_ns / 1000,
second_rtt_us = second.rtt_ns / 1000,
rounds = second.rounds,
"clock re-probe answered"
);
if second.rounds < 8 || disagree_ns > bound_ns {
return Err(anyhow!(
"clock-resync: re-probe unsound (rounds {}, disagreement {} µs > bound {} µs)",
second.rounds,
disagree_ns / 1000,
bound_ns / 1000
));
}
println!(
"clock-resync OK: offsets {} / {} ns",
first.offset_ns, second.offset_ns
);
}
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The // Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
// speed test reads their delta over the burst window so throughput/loss reflect every delivered // speed test reads their delta over the burst window so throughput/loss reflect every delivered
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs. // wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
@@ -960,6 +1007,10 @@ async fn session(args: Args) -> Result<()> {
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Set when a self-terminating v2 rumble envelope (0xCA with the seq+ttl tail) arrives — the
// Rust-side contract check for `PUNKTFUNK_TEST_FEEDBACK` (asserted at report time). A legacy v1
// datagram leaves it false, so this only ever fails when a v2 tail we EXPECTED went missing.
let saw_v2_rumble = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
// Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts // Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts
// and reports the host/network split. try_send: overflow drops samples, never blocks QUIC. // and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
let (host_timing_tx, host_timing_rx) = let (host_timing_tx, host_timing_rx) =
@@ -971,6 +1022,7 @@ async fn session(args: Args) -> Result<()> {
rumble_pkts.clone(), rumble_pkts.clone(),
hidout_pkts.clone(), hidout_pkts.clone(),
); );
let saw_v2 = saw_v2_rumble.clone();
let ht_tx = host_timing_tx; let ht_tx = host_timing_tx;
let conn2 = conn.clone(); let conn2 = conn.clone();
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes // Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
@@ -979,6 +1031,7 @@ async fn session(args: Args) -> Result<()> {
tokio::spawn(async move { tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::Ordering::Relaxed;
let mut hdr_logged = false; let mut hdr_logged = false;
let mut rumble_logged = false;
let layout = punktfunk_core::audio::layout_for(audio_channels, false); let layout = punktfunk_core::audio::layout_for(audio_channels, false);
let mut audio_dec = let mut audio_dec =
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok(); opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
@@ -1004,7 +1057,24 @@ async fn session(args: Args) -> Result<()> {
Err(e) => tracing::debug!(error = %e, "probe audio decode"), Err(e) => tracing::debug!(error = %e, "probe audio decode"),
} }
} }
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() { } else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
envelope = ?u.envelope,
"rumble (0xCA)"
);
}
// Record that a v2 tail was present — the Rust-side seq/ttl contract check for
// PUNKTFUNK_TEST_FEEDBACK (asserted at report time).
if u.envelope.is_some() {
saw_v2.store(true, Relaxed);
}
r.fetch_add(1, Relaxed); r.fetch_add(1, Relaxed);
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) { } else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
// HDR static metadata (0xCE). Log the first receipt so a loopback test can // HDR static metadata (0xCE). Log the first receipt so a loopback test can
@@ -1245,6 +1315,23 @@ async fn session(args: Args) -> Result<()> {
} }
} }
// Rust-side rumble-envelope contract check: when the host was told to script a feedback burst
// (PUNKTFUNK_TEST_FEEDBACK, shared by a loopback harness), fail if no self-terminating v2 tail
// (seq + TTL) arrived — a regression that reverted the host to v1 level datagrams increments the
// rumble counter identically and would otherwise pass silently. Only tightens an otherwise-OK run
// (a video failure stays the primary error). The level + hidout planes are asserted end-to-end by
// the Apple loopback; this covers the seq/ttl tail on the Rust/Linux path.
let result = if std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
&& result.is_ok()
&& !saw_v2_rumble.load(std::sync::atomic::Ordering::Relaxed)
{
Err(anyhow::anyhow!(
"PUNKTFUNK_TEST_FEEDBACK: expected a v2 rumble envelope (0xCA seq+ttl tail), received none"
))
} else {
result
};
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal // `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect). // exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
let close_code = if args.quit { let close_code = if args.quit {
+4
View File
@@ -146,6 +146,10 @@ mod session_main {
} else { } else {
0 0
}, },
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
display_hdr: None,
mic_enabled: settings.mic_enabled, mic_enabled: settings.mic_enabled,
// The Settings preference (auto → VAAPI where it exists; the presenter // The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs). // demotes to software on boxes whose Vulkan can't import the dmabufs).
+2 -2
View File
@@ -52,7 +52,7 @@ impl PartialEq for StreamProps {
thread_local! { thread_local! {
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires /// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
/// later, once the native panel exists). /// later, once the native panel exists).
static PENDING: RefCell<Option<(crate::session::FrameRx, i64)>> = const { RefCell::new(None) }; static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown). /// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) }; static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
} }
@@ -88,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
move || { move || {
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() { if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
let mode = connector.mode(); let mode = connector.mode();
let clock_offset = connector.clock_offset_ns; let clock_offset = connector.clock_offset_shared();
connector_ref.set(Some(connector.clone())); connector_ref.set(Some(connector.clone()));
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset))); PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
crate::input::install(connector, mode, inhibit, show_stats, stop); crate::input::install(connector, mode, inhibit, show_stats, stop);
+9 -5
View File
@@ -607,18 +607,22 @@ fn run(
} }
} }
// Feedback planes (this thread is their single consumer). The host re-sends rumble state // Feedback planes (this thread is their single consumer). Rumble arrives as
// periodically, so a generous duration with refresh-on-update is safe — a dropped stop // self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
// heals within ~500 ms. // lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
// proven 5 s duration and rely on its periodic re-send as before.
if let Some(connector) = w.attached.clone() { if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
if pad == 0 { if pad == 0 {
// Floor the lease so a jittered renewal can't gap the actuator between writes.
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in // Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs // host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render. // client-render.
if let Err(e) = p.set_rumble(low, high, 5_000) { if let Err(e) = p.set_rumble(low, high, dur_ms) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else { } else {
tracing::debug!(low, high, "rumble: rendered"); tracing::debug!(low, high, "rumble: rendered");
+142 -85
View File
@@ -4,7 +4,9 @@
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by) //! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
//! the XAML thread. //! the XAML thread.
//! //!
//! Two frame sources, one pair of YUV shaders (identical colour math for both): //! Two frame sources, ONE YCbCr→RGB shader whose conversion rows arrive per frame in a constant
//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour
//! math for both sources, and the stream's signaled matrix/range is honored, not assumed):
//! //!
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010 //! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both //! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
@@ -46,10 +48,14 @@ use windows::Win32::Graphics::Dxgi::Common::*;
use windows::Win32::Graphics::Dxgi::*; use windows::Win32::Graphics::Dxgi::*;
use windows::Win32::System::Threading::WaitForSingleObject; use windows::Win32::System::Threading::WaitForSingleObject;
// One vertex shader (fullscreen triangle) + two pixel shaders, selected per frame colour space. // One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination:
// tex0 is the luma plane, tex1 the chroma plane. The YUV→RGB matrices fold the limited→full range // tex0 is the luma plane, tex1 the chroma plane, and the YCbCr→RGB conversion arrives as three
// scale into the coefficients; for P010 the R16 sample is rescaled (×65535/65472) to undo the // constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` —
// 10-bits-in-the-high-bits packing, then converted with BT.2020 NCL, PQ preserved. // bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader
// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old
// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to
// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded
// RGB passed through as-is to the HDR10 swapchain, exactly as before.
const SHADER_HLSL: &str = r#" const SHADER_HLSL: &str = r#"
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
VSOut vs_main(uint vid : SV_VertexID) { VSOut vs_main(uint vid : SV_VertexID) {
@@ -62,47 +68,47 @@ VSOut vs_main(uint vid : SV_VertexID) {
Texture2D tex0 : register(t0); Texture2D tex0 : register(t0);
Texture2D tex1 : register(t1); Texture2D tex1 : register(t1);
SamplerState smp : register(s0); SamplerState smp : register(s0);
cbuffer Csc : register(b0) {
float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w
float4 r1;
float4 r2;
};
float4 ps_nv12(VSOut i) : SV_Target { float4 ps_yuv(VSOut i) : SV_Target {
float y = tex0.Sample(smp, i.uv).r; // 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
float2 uv = tex1.Sample(smp, i.uv).rg; // what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
float yy = (y - 0.0627451) * 1.164384; // (Y-16/255)*255/219 // siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
float u = uv.x - 0.5; // texels to re-align (the same correction the Apple client applies). Self-disables when the
float v = uv.y - 0.5; // BT.709 limited, chroma scale folded // plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct).
float r = yy + 1.792741 * v; float lw, lh, cw, ch;
float g = yy - 0.213249 * u - 0.532909 * v; tex0.GetDimensions(lw, lh);
float b = yy + 2.112402 * u; tex1.GetDimensions(cw, ch);
return float4(saturate(float3(r, g, b)), 1.0); float2 cuv = i.uv;
} if (cw < lw) { cuv.x += 0.25 / cw; }
float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg);
float4 ps_p010(VSOut i) : SV_Target { float3 rgb = float3(dot(r0.xyz, yuv) + r0.w,
const float S = 65535.0 / 65472.0; // undo P010 high-bit packing → exact 10-bit / 1023 dot(r1.xyz, yuv) + r1.w,
float y = tex0.Sample(smp, i.uv).r * S; dot(r2.xyz, yuv) + r2.w);
float2 uv = tex1.Sample(smp, i.uv).rg * S; return float4(saturate(rgb), 1.0);
float yy = (y - 0.0625611) * 1.167808; // (Y-64/1023)*1023/876
float u = uv.x - 0.5;
float v = uv.y - 0.5; // BT.2020 NCL limited, chroma scale folded; PQ kept
float r = yy + 1.683611 * v;
float g = yy - 0.187877 * u - 0.652337 * v;
float b = yy + 2.148072 * u;
return float4(saturate(float3(r, g, b)), 1.0);
} }
"#; "#;
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane /// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
/// textures) + the colour space that picks the shader. Redraws (resize, letterbox) re-present it. /// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds
/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived.
struct Bound { struct Bound {
y: ID3D11ShaderResourceView, y: ID3D11ShaderResourceView,
c: ID3D11ShaderResourceView, c: ID3D11ShaderResourceView,
hdr: bool,
} }
pub struct Presenter { pub struct Presenter {
device: ID3D11Device, device: ID3D11Device,
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
vs: ID3D11VertexShader, vs: ID3D11VertexShader,
ps_nv12: ID3D11PixelShader, ps_yuv: ID3D11PixelShader,
ps_p010: ID3D11PixelShader, /// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten
/// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init).
csc_buf: ID3D11Buffer,
sampler: ID3D11SamplerState, sampler: ID3D11SamplerState,
swap: IDXGISwapChain1, swap: IDXGISwapChain1,
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails. /// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
@@ -157,7 +163,22 @@ impl Presenter {
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?; let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
let device = shared.device.clone(); let device = shared.device.clone();
let context = shared.context.clone(); let context = shared.context.clone();
let (vs, ps_nv12, ps_p010, sampler) = build_pipeline(&device)?; let (vs, ps_yuv, sampler) = build_pipeline(&device)?;
// The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind.
let csc_desc = D3D11_BUFFER_DESC {
ByteWidth: 48,
Usage: D3D11_USAGE_DYNAMIC,
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
..Default::default()
};
let csc_buf = unsafe {
let mut b = None;
device
.CreateBuffer(&csc_desc, None, Some(&mut b))
.context("CreateBuffer (CSC rows)")?;
b.ok_or_else(|| anyhow!("null CSC constant buffer"))?
};
let (swap, swap_flags) = let (swap, swap_flags) =
create_composition_swapchain(&device, width.max(1), height.max(1))?; create_composition_swapchain(&device, width.max(1), height.max(1))?;
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn // ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
@@ -175,8 +196,8 @@ impl Presenter {
device, device,
context, context,
vs, vs,
ps_nv12, ps_yuv,
ps_p010, csc_buf,
sampler, sampler,
swap, swap,
swap_flags, swap_flags,
@@ -327,12 +348,10 @@ impl Presenter {
let (fy, fc) = plane_formats(g.ten_bit); let (fy, fc) = plane_formats(g.ten_bit);
let y = self.plane_srv(&dst, fy)?; let y = self.plane_srv(&dst, fy)?;
let c = self.plane_srv(&dst, fc)?; let c = self.plane_srv(&dst, fc)?;
if g.ten_bit != g.hdr { self.write_csc_rows(g.color, g.ten_bit)?;
warn_bitdepth_mismatch_once(g.ten_bit, g.hdr);
}
self.src_w = g.width; self.src_w = g.width;
self.src_h = g.height; self.src_h = g.height;
self.bound = Some(Bound { y, c, hdr: g.hdr }); self.bound = Some(Bound { y, c });
// Hold the frame until the next bind: its decode surface stays out of the reuse pool // Hold the frame until the next bind: its decode surface stays out of the reuse pool
// until this copy is queued ahead of any later decoder write (previous frame drops here). // until this copy is queued ahead of any later decoder write (previous frame drops here).
self.gpu_frame = Some(g); self.gpu_frame = Some(g);
@@ -428,12 +447,13 @@ impl Presenter {
w.div_ceil(2) as usize * 2 * bytes, w.div_ceil(2) as usize * 2 * bytes,
h.div_ceil(2) as usize, h.div_ceil(2) as usize,
)?; )?;
let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone());
self.write_csc_rows(frame.color, frame.ten_bit)?;
self.src_w = w; self.src_w = w;
self.src_h = h; self.src_h = h;
self.bound = Some(Bound { self.bound = Some(Bound {
y: y_srv.clone(), y: y_srv,
c: uv_srv.clone(), c: uv_srv,
hdr: frame.hdr,
}); });
self.gpu_frame = None; // drop any held GPU frame self.gpu_frame = None; // drop any held GPU frame
Ok(()) Ok(())
@@ -464,6 +484,32 @@ impl Presenter {
} }
} }
/// Recompute the bound frame's YCbCr→RGB rows from its CICP signaling and Map-discard them
/// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010
/// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit).
fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> {
let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit);
unsafe {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(
&self.csc_buf,
0,
D3D11_MAP_WRITE_DISCARD,
0,
Some(&mut mapped),
)
.context("Map CSC constant buffer")?;
std::ptr::copy_nonoverlapping(
rows.as_ptr() as *const u8,
mapped.pData as *mut u8,
48, // [[f32; 4]; 3]
);
self.context.Unmap(&self.csc_buf, 0);
}
Ok(())
}
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`). /// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
fn map_rows( fn map_rows(
&self, &self,
@@ -525,14 +571,8 @@ impl Presenter {
c.IASetInputLayout(None); c.IASetInputLayout(None);
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
c.VSSetShader(&self.vs, None); c.VSSetShader(&self.vs, None);
c.PSSetShader( c.PSSetShader(&self.ps_yuv, None);
if bound.hdr { c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())]));
&self.ps_p010
} else {
&self.ps_nv12
},
None,
);
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())])); c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())])); c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
c.Draw(3, 0); c.Draw(3, 0);
@@ -645,20 +685,6 @@ fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) {
} }
} }
/// The host couples 10-bit ⟺ HDR today; a mismatch means the shader's transfer/matrix assumption
/// is off for this stream (rendered anyway — approximate colour beats no picture).
fn warn_bitdepth_mismatch_once(ten_bit: bool, hdr: bool) {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::warn!(
ten_bit,
hdr,
"bit depth / HDR mismatch — colour may be approximate"
);
}
}
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the /// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was /// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
/// created with (every `ResizeBuffers` must re-pass them). /// created with (every `ResizeBuffers` must re-pass them).
@@ -708,28 +734,18 @@ fn create_composition_swapchain(
fn build_pipeline( fn build_pipeline(
device: &ID3D11Device, device: &ID3D11Device,
) -> Result<( ) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
ID3D11VertexShader,
ID3D11PixelShader,
ID3D11PixelShader,
ID3D11SamplerState,
)> {
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?; let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
let nv12_blob = compile(SHADER_HLSL, "ps_nv12", "ps_5_0")?; let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?;
let p010_blob = compile(SHADER_HLSL, "ps_p010", "ps_5_0")?;
unsafe { unsafe {
let mut vs = None; let mut vs = None;
device device
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs)) .CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
.context("CreateVertexShader")?; .context("CreateVertexShader")?;
let mut ps_nv12 = None; let mut ps_yuv = None;
device device
.CreatePixelShader(blob_bytes(&nv12_blob), None, Some(&mut ps_nv12)) .CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv))
.context("CreatePixelShader (nv12)")?; .context("CreatePixelShader (yuv)")?;
let mut ps_p010 = None;
device
.CreatePixelShader(blob_bytes(&p010_blob), None, Some(&mut ps_p010))
.context("CreatePixelShader (p010)")?;
let sdesc = D3D11_SAMPLER_DESC { let sdesc = D3D11_SAMPLER_DESC {
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
@@ -742,12 +758,7 @@ fn build_pipeline(
device device
.CreateSamplerState(&sdesc, Some(&mut sampler)) .CreateSamplerState(&sdesc, Some(&mut sampler))
.context("CreateSamplerState")?; .context("CreateSamplerState")?;
Ok(( Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap()))
vs.unwrap(),
ps_nv12.unwrap(),
ps_p010.unwrap(),
sampler.unwrap(),
))
} }
} }
@@ -823,6 +834,52 @@ pub fn display_supports_hdr() -> bool {
false false
} }
/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in
/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`.
/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS
/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats
/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m²
/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no
/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session
/// gates on that check first, so both look at the same panel in the single-HDR-display case.
pub fn display_hdr_volume() -> Option<punktfunk_core::quic::HdrMeta> {
let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16;
unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut ai = 0u32;
while let Ok(adapter) = factory.EnumAdapters1(ai) {
ai += 1;
let mut oi = 0u32;
while let Ok(output) = adapter.EnumOutputs(oi) {
oi += 1;
let Ok(o6) = output.cast::<IDXGIOutput6>() else {
continue;
};
let Ok(desc) = o6.GetDesc1() else { continue };
if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 {
continue;
}
return Some(punktfunk_core::quic::HdrMeta {
// ST.2086 order is G, B, R.
display_primaries: [
[to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])],
[to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])],
[to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])],
],
white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])],
max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round()
as u32,
min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round()
as u32,
max_cll: 0,
max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16,
});
}
}
}
None
}
/// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display, /// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display,
/// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives. /// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 { fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
+13 -6
View File
@@ -12,7 +12,7 @@
use crate::present::Presenter; use crate::present::Presenter;
use crate::session::{FrameRx, FrameTimes}; use crate::session::{FrameRx, FrameTimes};
use crossbeam_channel::RecvTimeoutError; use crossbeam_channel::RecvTimeoutError;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -122,12 +122,13 @@ unsafe impl Send for SendPresenter {}
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our /// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid /// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
/// (same math as the pump's host+network stage). /// (same math as the pump's host+network stage). A live handle (loaded per present) so
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
pub fn spawn( pub fn spawn(
presenter: Presenter, presenter: Presenter,
frames: FrameRx, frames: FrameRx,
shared: Arc<RenderShared>, shared: Arc<RenderShared>,
clock_offset_ns: i64, clock_offset_ns: Arc<AtomicI64>,
) -> RenderThread { ) -> RenderThread {
let boxed = SendPresenter(presenter); let boxed = SendPresenter(presenter);
let shared_w = shared.clone(); let shared_w = shared.clone();
@@ -162,7 +163,12 @@ fn poll_window_dpi() -> Option<u32> {
} }
} }
fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clock_offset_ns: i64) { fn run(
presenter: SendPresenter,
frames: FrameRx,
shared: Arc<RenderShared>,
clock_offset_ns: Arc<AtomicI64>,
) {
let mut p = presenter.0; let mut p = presenter.0;
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
let mut presented = 0u32; let mut presented = 0u32;
@@ -232,8 +238,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
let displayed_ns = now_ns(); let displayed_ns = now_ns();
// End-to-end = capture → displayed, host-clock corrected, measured directly // End-to-end = capture → displayed, host-clock corrected, measured directly
// (never the sum of stage percentiles). Clamped (0, 10 s). // (never the sum of stage percentiles). Clamped (0, 10 s).
let e2e = let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
(displayed_ns as i128 + clock_offset_ns as i128 - t.pts_ns as i128).max(0) as u64; - t.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 { if e2e > 0 && e2e < 10_000_000_000 {
e2e_us.push(e2e / 1000); e2e_us.push(e2e / 1000);
} }
+36 -13
View File
@@ -144,6 +144,7 @@ pub fn run_speed_probe(
2, // audio_channels: stereo baseline 2, // audio_channels: stereo baseline
crate::video::decodable_codecs(), crate::video::decodable_codecs(),
0, // preferred_codec: no preference 0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
None, // launch: no game None, // launch: no game
pin, pin,
Some(identity), Some(identity),
@@ -235,6 +236,34 @@ fn pump(
frame_rx: FrameRx, frame_rx: FrameRx,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) { ) {
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
// rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020
// PQ frames (P010 / X2BGR10).
let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr();
if params.hdr_enabled && !hdr_active {
tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR");
}
// With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it
// into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream
// arrives already inside its volume — the client presents it untouched.
// PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs.
let display_hdr = if hdr_active {
let vol = punktfunk_core::client::display_hdr_env_override()
.or_else(crate::present::display_hdr_volume);
if let Some(m) = vol {
tracing::info!(
max_nits = m.max_display_mastering_luminance / 10_000,
min_millinits = m.min_display_mastering_luminance / 10,
max_fall = m.max_fall,
"advertising this display's HDR volume to the host"
);
}
vol
} else {
None
};
let connector = match NativeClient::connect( let connector = match NativeClient::connect(
&params.host, &params.host,
params.port, params.port,
@@ -242,24 +271,15 @@ fn pump(
params.compositor, params.compositor,
params.gamepad, params.gamepad,
params.bitrate_kbps, params.bitrate_kbps,
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR if hdr_active {
// mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still
// applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream
// rather than PQ the panel would mis-tone-map (washed-out/dark). An HDR display self-tone-maps
// from the mastering metadata we apply. The presenter handles BT.2020 PQ frames (P010 / X2BGR10).
if params.hdr_enabled && crate::present::display_supports_hdr() {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else { } else {
if params.hdr_enabled {
tracing::info!(
"HDR enabled in settings but no HDR display detected — requesting SDR"
);
}
0 0
}, },
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto) params.preferred_codec, // the user's soft codec preference (0 = auto)
display_hdr,
None, // launch: the Windows client has no library picker yet None, // launch: the Windows client has no library picker yet
params.pin, params.pin,
Some(params.identity), Some(params.identity),
@@ -330,7 +350,9 @@ fn pump(
// "PPS id out of range" (a black screen) until one arrives. // "PPS id out of range" (a black screen) until one arrives.
let _ = connector.request_keyframe(); let _ = connector.request_keyframe();
let clock_offset = connector.clock_offset_ns; // Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
let clock_offset_live = connector.clock_offset_shared();
let mut total_frames = 0u64; let mut total_frames = 0u64;
let session_start = Instant::now(); let session_start = Instant::now();
let mut window_start = Instant::now(); let mut window_start = Instant::now();
@@ -363,6 +385,7 @@ fn pump(
frames_n += 1; frames_n += 1;
bytes_n += frame.data.len() as u64; bytes_n += frame.data.len() as u64;
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s). // `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128) let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64; .max(0) as u64;
if hostnet > 0 && hostnet < 10_000_000_000 { if hostnet > 0 && hostnet < 10_000_000_000 {
@@ -500,7 +523,7 @@ fn pump(
host_ms: host_p50 as f32 / 1000.0, host_ms: host_p50 as f32 / 1000.0,
net_ms: net_p50 as f32 / 1000.0, net_ms: net_p50 as f32 / 1000.0,
split, split,
same_host: clock_offset == 0, same_host: clock_offset_live.load(Ordering::Relaxed) == 0,
hardware, hardware,
hdr, hdr,
codec: connector.codec, codec: connector.codec,
+18 -7
View File
@@ -32,6 +32,7 @@ use ffmpeg::format::Pixel;
use ffmpeg::software::scaling; use ffmpeg::software::scaling;
use ffmpeg::util::frame::Video as AvFrame; use ffmpeg::util::frame::Video as AvFrame;
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use pf_client_core::video::ColorDesc;
use std::ffi::c_void; use std::ffi::c_void;
use std::ptr; use std::ptr;
use windows::core::{Interface, GUID}; use windows::core::{Interface, GUID};
@@ -95,8 +96,12 @@ pub struct CpuFrame {
pub uv_stride: usize, pub uv_stride: usize,
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats. /// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
pub ten_bit: bool, pub ten_bit: bool,
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects shader + swapchain colour space. /// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space.
pub hdr: bool, pub hdr: bool,
/// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives
/// its YCbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux
/// host's RGB-input NVENC) no longer renders with BT.709 coefficients.
pub color: ColorDesc,
} }
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder /// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
@@ -112,9 +117,11 @@ pub struct GpuFrame {
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the /// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
/// source array exactly for `CopySubresourceRegion`. /// source array exactly for `CopySubresourceRegion`.
pub ten_bit: bool, pub ten_bit: bool,
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects shader + swapchain /// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour
/// colour space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`). /// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
pub hdr: bool, pub hdr: bool,
/// Per-frame CICP signaling — see [`CpuFrame::color`].
pub color: ColorDesc,
guard: D3d11FrameGuard, guard: D3d11FrameGuard,
} }
@@ -329,9 +336,10 @@ impl SoftwareDecoder {
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the /// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
/// D3D11VA path, so software frames are bit-comparable with hardware ones. /// D3D11VA path, so software frames are bit-comparable with hardware ones.
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> { fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
use ffmpeg::color::TransferCharacteristic;
let (fmt, w, h) = (frame.format(), frame.width(), frame.height()); let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let hdr = frame.color_transfer_characteristic() == TransferCharacteristic::SMPTE2084; // SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call.
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
let hdr = color.is_pq();
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API). // Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
let ten_bit = unsafe { let ten_bit = unsafe {
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into()); let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
@@ -356,6 +364,7 @@ impl SoftwareDecoder {
uv_stride: conv.stride(1), uv_stride: conv.stride(1),
ten_bit, ten_bit,
hdr, hdr,
color,
}) })
} }
} }
@@ -586,8 +595,9 @@ impl D3d11vaDecoder {
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 { if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
bail!("decoder returned a software frame (no D3D11 surface)"); bail!("decoder returned a software frame (no D3D11 surface)");
} }
let hdr = // SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call.
(*self.frame).color_trc == ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084; let color = ColorDesc::from_raw(self.frame);
let hdr = color.is_pq();
let ten_bit = { let ten_bit = {
let hwfc = (*self.frame).hw_frames_ctx; let hwfc = (*self.frame).hw_frames_ctx;
!hwfc.is_null() !hwfc.is_null()
@@ -604,6 +614,7 @@ impl D3d11vaDecoder {
index: (*self.frame).data[1] as usize as u32, index: (*self.frame).data[1] as usize as u32,
ten_bit, ten_bit,
hdr, hdr,
color,
guard: D3d11FrameGuard(cloned), guard: D3d11FrameGuard(cloned),
}; };
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit); log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
+116 -17
View File
@@ -61,6 +61,16 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
/// left untouched.
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384; const MENU_DEADZONE: u16 = 16384;
@@ -641,6 +651,21 @@ struct Worker {
menu_mode: bool, menu_mode: bool,
menu_nav: MenuNav, menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>, menu_tx: async_channel::Sender<MenuEvent>,
/// Last rumble value handed to the active pad (the logical host value, pre-jitter) and
/// when — drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
rumble_last: (u16, u16),
rumble_last_at: Option<Instant>,
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
rumble_jitter: bool,
/// The host lease from a v2 rumble envelope: last non-zero level expires at this instant
/// unless the host renews it. `None` outside a live rumble or against a legacy host (which
/// sends no lease — the pad then relies on SDL's own duration expiry as before).
rumble_deadline: Option<Instant>,
/// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble`
/// duration; `0` = legacy host (fall back to the proven 1.5 s duration). Read by
/// [`Worker::issue_rumble`].
rumble_ttl_ms: u16,
} }
impl Worker { impl Worker {
@@ -1225,33 +1250,102 @@ impl Worker {
} }
} }
/// Hand a rumble value to SDL on the active pad, remembering it for the Deck keep-alive.
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
/// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
/// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
/// self-silences at the TTL); against a legacy host (`rumble_ttl_ms == 0`) it stays the proven
/// 1.5 s.
fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) {
let dur_ms: u32 = if self.rumble_ttl_ms == 0 {
1_500 // legacy host: no lease — keep the proven duration
} else {
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
// actuator between SDL writes.
(self.rumble_ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
};
let (out_low, out_high) =
if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) {
self.rumble_jitter = !self.rumble_jitter;
(low ^ self.rumble_jitter as u16, high)
} else {
(low, high)
};
match self
.open
.as_mut()
.map(|(_, p)| p.set_rumble(out_low, out_high, dur_ms))
{
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the
// right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs
// the send side on 0xCA, so the two together pinpoint host-game vs client-render.
Some(Err(e)) => tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"),
Some(Ok(())) => tracing::debug!(low, high, "rumble: rendered"),
None => tracing::debug!(low, high, "rumble: received but no active pad to render"),
}
self.rumble_last = (low, high);
self.rumble_last_at = Some(Instant::now());
}
/// Drain and render the feedback planes — rumble plus HID output (lightbar / /// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// player LEDs / adaptive triggers) — on the active pad; this thread is their single
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only /// consumer. Rumble arrives as self-terminating v2 envelopes: each carries a TTL the host
/// needs to outlive a couple of refresh periods: long enough that one or two lost /// renews while the level holds and lets expire when it stops, so the actuator's divergence
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state /// from the host's intent is bounded by the wire, not by a client guess. A legacy host
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of /// (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration expiry as
/// droning for seconds. /// before.
fn render_feedback(&mut self) { fn render_feedback(&mut self) {
let Some(connector) = self.attached.clone() else { let Some(connector) = self.attached.clone() else {
return; return;
}; };
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { // The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble
// resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a
// steady host value is felt as a periodic pulse rather than a continuous buzz. Detect the
// Deck pad here and keep it fed below the decay (`DECK_RUMBLE_KEEPALIVE_MS`) — an actuator
// limitation no wire lease can fix — but bound the re-kick by the host's TTL so it can no
// longer sustain a value the host has stopped renewing. Every other pad sustains (and
// expires) at the SDL/hardware level.
let deck = self
.open
.as_ref()
.and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck));
let mut fresh = false;
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
if pad == 0 { if pad == 0 {
if let Some((_, p)) = self.open.as_mut() { fresh = true;
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in self.rumble_ttl_ms = ttl.unwrap_or(0);
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // A v2 lease sets an explicit client-side deadline; a legacy update clears it and
// host logs the send side on 0xCA, so the two together pinpoint host-game vs // leans on SDL's own duration expiry (unchanged behaviour).
// client-render. self.rumble_deadline = match ttl {
if let Err(e) = p.set_rumble(low, high, 1_500) { Some(ms) if (low, high) != (0, 0) => {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); Some(Instant::now() + Duration::from_millis(ms as u64))
} else {
tracing::debug!(low, high, "rumble: rendered");
} }
} else { _ => None,
tracing::debug!(low, high, "rumble: received but no active pad to render"); };
self.issue_rumble(low, high, deck);
} }
} }
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched. If the
// host lease has expired, silence the actuator (the host stopped renewing — the stop
// datagram was lost, or the host died); otherwise re-kick it so its discrete haptic bursts
// fuse into a continuous buzz. A legacy update leaves `rumble_deadline` None, so the
// re-kick behaves exactly as before (SDL's duration is the only backstop). Non-Deck pads
// never enter here (`deck` is false).
if deck && !fresh && self.rumble_last != (0, 0) {
if self.rumble_deadline.is_some_and(|d| Instant::now() >= d) {
self.rumble_deadline = None;
self.rumble_ttl_ms = 0;
self.issue_rumble(0, 0, deck);
} else if self
.rumble_last_at
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
{
let (low, high) = self.rumble_last;
self.issue_rumble(low, high, deck);
}
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let is_ds = self let is_ds = self
@@ -1318,6 +1412,11 @@ impl Worker {
menu_mode: false, menu_mode: false,
menu_nav: MenuNav::new(), menu_nav: MenuNav::new(),
menu_tx, menu_tx,
rumble_last: (0, 0),
rumble_last_at: None,
rumble_jitter: false,
rumble_deadline: None,
rumble_ttl_ms: 0,
} }
} }
} }
+34 -3
View File
@@ -33,6 +33,11 @@ pub struct SessionParams {
/// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0` /// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0`
/// when the user turned HDR off in Settings ("never send me 10-bit"). /// when the user turned HDR off in Settings ("never send me 10-bit").
pub video_caps: u8, pub video_caps: u8,
/// This display's HDR colour volume (primaries/white/luminance), when the embedder can read
/// it from the OS. Rides `Hello::display_hdr` → the host's virtual-display EDID, so host apps
/// tone-map to THIS panel. `None` = unknown/SDR (host EDID defaults). Overridable for testing
/// via `PUNKTFUNK_CLIENT_PEAK_NITS` (synthesizes a BT.2020 volume at that peak).
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool, pub mic_enabled: bool,
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see /// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
@@ -221,6 +226,9 @@ fn pump(
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto) params.preferred_codec, // the user's soft codec preference (0 = auto)
// This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an
// A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600).
punktfunk_core::client::display_hdr_env_override().or(params.display_hdr),
params.launch.clone(), params.launch.clone(),
params.pin, params.pin,
Some(params.identity), Some(params.identity),
@@ -279,7 +287,9 @@ fn pump(
}) })
.flatten(); .flatten();
let clock_offset = connector.clock_offset_ns; // Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
let clock_offset_live = connector.clock_offset_shared();
let mut total_frames = 0u64; let mut total_frames = 0u64;
let mut window_start = Instant::now(); let mut window_start = Instant::now();
let mut frames_n = 0u32; let mut frames_n = 0u32;
@@ -352,6 +362,8 @@ fn pump(
let decoded_ns = now_ns(); let decoded_ns = now_ns();
// `host+network` stage: received expressed in the host's capture // `host+network` stage: received expressed in the host's capture
// clock, minus the host-stamped capture pts (clamped (0, 10 s)). // clock, minus the host-stamped capture pts (clamped (0, 10 s)).
let clock_offset =
clock_offset_live.load(std::sync::atomic::Ordering::Relaxed);
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128) let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
.max(0) as u64; .max(0) as u64;
if hn > 0 && hn < 10_000_000_000 { if hn > 0 && hn < 10_000_000_000 {
@@ -562,18 +574,37 @@ fn spawn_audio(
.name("punktfunk-audio-rx".into()) .name("punktfunk-audio-rx".into())
.spawn(move || { .spawn(move || {
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
while !stop.load(Ordering::SeqCst) { while !stop.load(Ordering::SeqCst) {
match connector.next_audio(Duration::from_millis(100)) { match connector.next_audio(Duration::from_millis(100)) {
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) { Ok(pkt) => {
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one
// that arrived: empty input synthesizes `frame_samples` of interpolation per
// missing packet — an inaudible fade instead of the click a hard gap makes.
for _ in 0..gaps.missing_before(pkt.seq) {
let plc = frame_samples * channels as usize;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
}
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..samples * channels as usize]);
player.push(buf);
}
}
match dec.decode_float(&pkt.data, &mut pcm, false) {
// `samples` is per-channel; the interleaved frame is `samples * channels`. // `samples` is per-channel; the interleaved frame is `samples * channels`.
Ok(samples) => { Ok(samples) => {
frame_samples = samples;
let n = samples * channels as usize; let n = samples * channels as usize;
let mut buf = player.take_buffer(); let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..n]); buf.extend_from_slice(&pcm[..n]);
player.push(buf); player.push(buf);
} }
Err(e) => tracing::debug!(error = %e, "opus decode"), Err(e) => tracing::debug!(error = %e, "opus decode"),
}, }
}
Err(PunktfunkError::NoFrame) => {} Err(PunktfunkError::NoFrame) => {}
Err(_) => break, // plane closed — the session is ending Err(_) => break, // plane closed — the session is ending
} }
+244 -2
View File
@@ -119,11 +119,13 @@ pub struct ColorDesc {
} }
impl ColorDesc { impl ColorDesc {
/// Read the CICP fields off a raw decoded frame. /// Read the CICP fields off a raw decoded frame. Public: the Windows client's raw-FFI
/// D3D11VA/software decoders build their per-frame `ColorDesc` with it too (same
/// `ffmpeg-next` major, so the `AVFrame` type unifies across the workspace).
/// ///
/// # Safety /// # Safety
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call). /// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
pub(crate) unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc { pub unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads. // SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
unsafe { unsafe {
ColorDesc { ColorDesc {
@@ -141,6 +143,57 @@ impl ColorDesc {
} }
} }
/// The YCbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
/// client's D3D11 constant buffer), so a stream's signaled matrix/range is honored identically
/// everywhere; the Apple client ports this function (and its tests) to Swift.
///
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix {
5 | 6 => (0.299, 0.114),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range {
(pack, 0.0f64, pack)
} else {
(
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
};
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
];
core::array::from_fn(|r| {
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
})
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride). /// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame { pub struct CpuFrame {
pub width: u32, pub width: u32,
@@ -1387,6 +1440,117 @@ unsafe extern "C" fn pick_vulkan(
mod tests { mod tests {
use super::*; use super::*;
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc {
primaries: 1,
transfer: 1,
matrix,
full_range,
}
}
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
core::array::from_fn(|r| {
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
})
}
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form.
#[test]
fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
assert!(b.abs() < 0.005, "black {black:?}");
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the
/// matrix-code dispatch), same as the GL presenter's test.
#[test]
fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
"{red709:?}"
);
assert!((red[0] - red709[0]).abs() > 0.05);
}
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
/// grid of inputs — same math, different packing.
#[test]
fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full);
let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let (sy, oy, sc) = if full {
(1.0f32, 0.0f32, 1.0f32)
} else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
};
let mat = [
sy,
sy,
sy,
0.0,
-2.0 * (1.0 - kb) * kb / kg * sc,
2.0 * (1.0 - kb) * sc,
2.0 * (1.0 - kr) * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
0.0,
];
let off = [oy, -0.5, -0.5];
for yuv in [
[0.1f32, 0.3, 0.7],
[0.9, 0.5, 0.5],
[0.5, 0.2, 0.8],
[16.0 / 255.0, 0.5, 0.5],
] {
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
let gl: [f32; 3] =
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
let ours = apply(&rows, yuv);
for (a, b) in gl.iter().zip(ours) {
assert!(
(a - b).abs() < 1e-5,
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
);
}
}
}
}
/// Lock the DRM FourCC magic numbers against typos — these are the exact values /// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green. /// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test] #[test]
@@ -1434,4 +1598,82 @@ mod tests {
assert!(f.color.is_pq()); assert!(f.color.is_pq());
assert_eq!((f.width, f.height), (64, 64)); assert_eq!((f.width, f.height), (64, 64));
} }
/// Golden colour fixtures: one 256×64 LOSSLESS x265 IDR of 8 fully-saturated colour bars per
/// signaling variant (generated offline with ffmpeg/libx265; the RGB→YUV conversion matched
/// to the VUI each fixture declares, so the original RGB is recoverable ±1 code). Decoding
/// through the real CPU path (`SoftwareDecoder` → per-frame `ColorDesc` → swscale with the
/// signaled matrix/range) must reproduce the bars — the end-to-end guard for the
/// signaling-driven CSC across BT.601/709 × limited/full. A hardcoded-709 regression fails
/// the 601 fixture by tens of code points; a range mix-up fails the full-range one.
#[test]
fn software_decode_reproduces_golden_bars() {
const BARS: [(u8, u8, u8); 8] = [
(255, 255, 255),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
];
let fixtures: [(&str, &[u8], ColorDesc); 3] = [
(
"601-limited",
include_bytes!("../tests/bars-601-limited.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 5, // BT.470BG — what a Linux host's RGB-input NVENC signals
full_range: false,
},
),
(
"709-limited",
include_bytes!("../tests/bars-709-limited.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 1,
full_range: false,
},
),
(
"709-full",
include_bytes!("../tests/bars-709-full.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 1,
full_range: true, // the PUNKTFUNK_444_FULLRANGE experiment's signaling
},
),
];
for (name, au, want_color) in fixtures {
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
let mut got = dec.decode(au).expect("decode");
if got.is_none() {
dec.decoder.send_eof().ok();
let mut frame = AvFrame::empty();
if dec.decoder.receive_frame(&mut frame).is_ok() {
got = Some(dec.convert_rgba(&frame).expect("convert"));
}
}
let f = got.unwrap_or_else(|| panic!("{name}: no frame decoded"));
assert_eq!(f.color, want_color, "{name}: signaling");
assert_eq!((f.width, f.height), (256, 64), "{name}: dims");
for (i, (r, g, b)) in BARS.iter().enumerate() {
let (cx, cy) = (i * 32 + 16, 32usize);
let o = cy * f.stride + cx * 4;
let px = &f.rgba[o..o + 3];
for (got, want) in px.iter().zip([r, g, b]) {
assert!(
got.abs_diff(*want) <= 3,
"{name} bar {i}: got {px:?}, want ({r},{g},{b})"
);
}
}
}
}
} }
+13 -4
View File
@@ -52,10 +52,12 @@ use windows::Win32::Graphics::Direct3D11::{
D3D11_VPOV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D,
}; };
use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, DXGI_SAMPLE_DESC, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
DXGI_SAMPLE_DESC,
}; };
use windows::Win32::Graphics::Dxgi::{ use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1, CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
@@ -629,9 +631,16 @@ impl D3d11vaDecoder {
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ // Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums. // stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
// BT.601 (5/6) matters in practice: a Linux host's RGB-input NVENC paths signal
// BT470BG limited (NVENC's fixed internal RGB→YUV is BT.601 — ffmpeg force-writes
// that VUI), and mapping it to P709 here was a constant hue error on those streams.
// DXGI has no full-range G2084 YCbCr enum, so PQ is studio regardless of range.
let in_cs = match (color.transfer, color.matrix, color.full_range) { let in_cs = match (color.transfer, color.matrix, color.full_range) {
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, (16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
(_, 9, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, (_, 9 | 10, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
(_, 9 | 10, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
(_, 5 | 6, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
(_, 5 | 6, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, (_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, _ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
}; };
Binary file not shown.
Binary file not shown.
Binary file not shown.
+277 -7
View File
@@ -53,7 +53,13 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication /// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is /// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
/// incompatible by design. /// incompatible by design.
pub const PROTOCOL_VERSION: u32 = 2; /// v3: ring↔monitor binding hardening for parallel displays
/// (`design/windows-parallel-virtual-displays.md` §3): [`frame::SharedHeader`] names its monitor
/// (`target_id`, the former `_pad` — same size, same offsets) and the driver's publisher refuses to
/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's
/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver
/// against a v2 host would refuse every attach — lockstep by the handshake, as ever.
pub const PROTOCOL_VERSION: u32 = 3;
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
pub const fn ctl_code(func: u32) -> u32 { pub const fn ctl_code(func: u32) -> u32 {
@@ -89,6 +95,13 @@ pub mod control {
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
/// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default). /// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default).
///
/// **Size compatibility**: the client-HDR luminance tail (the three fields after
/// `preferred_monitor_id`) was appended without a protocol bump because BOTH directions degrade
/// cleanly: an un-upgraded driver reads the [`ADD_REQUEST_LEGACY_SIZE`]-byte prefix of a new
/// host's request (its `read_input` accepts a larger buffer) and keeps its built-in EDID
/// luminance; an upgraded driver accepts a legacy-size request and zero-fills the tail (`0` =
/// unknown → the built-in defaults). Any FURTHER field must follow the same discipline.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct AddRequest { pub struct AddRequest {
@@ -104,8 +117,26 @@ pub mod control {
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded /// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded
/// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`]. /// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
pub preferred_monitor_id: u32, pub preferred_monitor_id: u32,
/// The CLIENT display's peak luminance in nits — written into this monitor's EDID CTA-861.3
/// HDR static-metadata block (Desired Content Max Luminance), so host apps and the OS
/// tone-map to the panel the stream actually lands on instead of the driver's built-in
/// ~1000-nit placeholder. `0` = unknown → the driver keeps its built-in default block.
pub max_luminance_nits: u32,
/// The client display's max frame-average luminance in nits (→ Desired Content Max
/// Frame-average Luminance). `0` = unknown/not indicated.
pub max_frame_avg_nits: u32,
/// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance
/// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown.
pub min_luminance_millinits: u32,
/// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding);
/// free expansion room for the next appended field.
pub _reserved: u32,
} }
/// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded
/// driver reads and the whole request an un-upgraded host sends (see the struct docs).
pub const ADD_REQUEST_LEGACY_SIZE: usize = 24;
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to /// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
/// match `windows` `LUID { LowPart: u32, HighPart: i32 }`). /// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
#[repr(C)] #[repr(C)]
@@ -193,12 +224,16 @@ pub mod control {
const _: () = { const _: () = {
use core::mem::{offset_of, size_of}; use core::mem::{offset_of, size_of};
assert!(size_of::<AddRequest>() == 24); assert!(size_of::<AddRequest>() == 40);
assert!(offset_of!(AddRequest, session_id) == 0); assert!(offset_of!(AddRequest, session_id) == 0);
assert!(offset_of!(AddRequest, width) == 8); assert!(offset_of!(AddRequest, width) == 8);
assert!(offset_of!(AddRequest, height) == 12); assert!(offset_of!(AddRequest, height) == 12);
assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
// The client-HDR luminance tail starts exactly at the legacy boundary (prefix-compat).
assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE);
assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28);
assert!(offset_of!(AddRequest, min_luminance_millinits) == 32);
assert!(size_of::<AddReply>() == 20); assert!(size_of::<AddReply>() == 20);
assert!(offset_of!(AddReply, adapter_luid_low) == 0); assert!(offset_of!(AddReply, adapter_luid_low) == 0);
@@ -228,6 +263,88 @@ pub mod control {
}; };
} }
/// CTA-861.3 "Desired Content Luminance" coding for the pf-vdisplay EDID's HDR Static Metadata
/// Data Block — the three bytes that tell Windows (and through it every host app) what luminance
/// volume the virtual display's panel has. The HOST fills [`control::AddRequest`]'s luminance
/// fields from the CLIENT's real display volume and the DRIVER codes them here, so games tone-map
/// to the panel the stream actually lands on.
///
/// Lives in this shared crate (not the driver) deliberately: the driver only builds under the WDK
/// on Windows, but this byte-level coding is exactly the fiddly part that wants unit tests on
/// every dev machine BEFORE a driver build/sign/deploy cycle. `no_std` + integer-only (fixed
/// point), so it drops into the driver unchanged.
pub mod edid {
/// `2^(k/32)` for `k = 0..32` in Q16 fixed point (`round(2^(k/32) * 65536)`) — the fractional
/// step table for the CTA-861.3 luminance exponent.
const POW2_Q16: [u32; 32] = [
65536, 66971, 68438, 69936, 71468, 73032, 74632, 76266, 77936, 79642, 81386, 83169, 84990,
86851, 88752, 90696, 92682, 94711, 96785, 98905, 101070, 103283, 105545, 107856, 110218,
112631, 115098, 117618, 120194, 122825, 125515, 128263,
];
/// Decode a CTA-861.3 max / frame-average luminance code to MILLI-nits:
/// `L = 50 * 2^(CV/32)` cd/m², so `L_millinits = 50_000 * 2^(CV/32)`.
/// (`CV = 255` ≈ 12_525 nits — comfortably inside u64 at Q16.)
pub const fn cta_max_millinits(code: u8) -> u64 {
let whole = code as u32 / 32;
let frac = code as u32 % 32;
((50_000u64 << whole) * POW2_Q16[frac as usize] as u64) >> 16
}
/// Code a display's peak (or frame-average) luminance in nits as a CTA-861.3 luminance value:
/// the LARGEST code whose decoded luminance does not exceed the panel's — never advertise a
/// volume brighter than the glass, so a host app's tone map can't clip on the client. Clamped
/// to `1..=255`: `0` is "no data" on the wire, and callers gate on `nits > 0` themselves (a
/// sub-51-nit request — no real HDR panel — still codes as 1).
pub fn cta_max_luminance_code(nits: u32) -> u8 {
let target = nits as u64 * 1000;
let mut code = 1u8;
while code < 255 && cta_max_millinits(code + 1) <= target {
code += 1;
}
code
}
/// Floor integer square root (Newton's method — `u64::isqrt` needs Rust 1.84, above this
/// crate's 1.82 MSRV). Converges in ≤ 6 iterations from the power-of-two seed.
fn isqrt_u64(x: u64) -> u64 {
if x == 0 {
return 0;
}
// Seed strictly above sqrt(x): 2^(ceil(bits/2)).
let mut r = 1u64 << (64 - x.leading_zeros()).div_ceil(2);
loop {
let next = (r + x / r) / 2;
if next >= r {
return r;
}
r = next;
}
}
/// Code a display's min luminance (MILLI-nits) as the CTA-861.3 min-luminance value, which is
/// relative to the block's coded max: `L_min = L_max * (CV/255)^2 / 100`, so
/// `CV = 255 * sqrt(100 * L_min / L_max)` — rounded to nearest. `max_code` is the byte
/// produced by [`cta_max_luminance_code`]; a result of `0` (a true-black panel, or
/// `millinits = 0` = unknown) is valid on the wire.
pub fn cta_min_luminance_code(millinits: u32, max_code: u8) -> u8 {
let max_millinits = cta_max_millinits(max_code);
if millinits == 0 || max_millinits == 0 {
return 0;
}
// CV = sqrt(100 * 255^2 * L_min / L_max); round to nearest by comparing the two flanking
// squares (the integer sqrt floors).
let x = (100u64 * 255 * 255).saturating_mul(millinits as u64) / max_millinits;
let floor = isqrt_u64(x);
let cv = if (floor + 1) * (floor + 1) - x <= x - floor * floor {
floor + 1
} else {
floor
};
cv.min(255) as u8
}
}
/// The IDD-push frame transport: the host-created shared ring header, the publish token, and the /// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
/// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures; /// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
/// the driver reaches them (and the header + event) only through handles the host duplicated into its /// the driver reaches them (and the header + event) only through handles the host duplicated into its
@@ -255,6 +372,12 @@ pub mod frame {
pub const DRV_STATUS_TEX_FAIL: u32 = 2; pub const DRV_STATUS_TEX_FAIL: u32 = 2;
/// Driver has no `ID3D11Device1` to open shared resources. /// Driver has no `ID3D11Device1` to open shared resources.
pub const DRV_STATUS_NO_DEVICE1: u32 = 3; pub const DRV_STATUS_NO_DEVICE1: u32 = 3;
/// Driver refused the attach because the mapped ring names a DIFFERENT monitor
/// ([`SharedHeader::target_id`] != the monitor the delivery landed on) — a host stash cross-wire
/// or stale-delivery race that, with parallel displays, would carry one client's frames into
/// another client's stream. Fail-closed binding validation (v3, invariant #10 of
/// `design/idd-push-security.md`); `driver_status_detail` carries the target id the ring claims.
pub const DRV_STATUS_BIND_FAIL: u32 = 4;
/// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`, /// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`,
/// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout. /// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout.
@@ -272,7 +395,14 @@ pub mod frame {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub dxgi_format: u32, pub dxgi_format: u32,
pub _pad: u32, /// The OS target id of the monitor this ring belongs to (v3 — the former `_pad`, same
/// offset). Host-stamped at ring creation, BEFORE the magic (the magic-last publish ordering
/// guarantees the driver never reads it half-initialized) and never changed afterwards (a
/// mid-session recreate reuses the mapping, so the binding is stable for the ring's life).
/// The driver's publisher attaches only when it equals the monitor's own target id
/// ([`check_attach`]) — a mis-delivered ring fails closed ([`DRV_STATUS_BIND_FAIL`]) instead
/// of carrying another display's frames (invariant #10, `design/idd-push-security.md`).
pub target_id: u32,
/// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`]. /// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
pub latest: u64, pub latest: u64,
pub qpc_pts: u64, pub qpc_pts: u64,
@@ -285,6 +415,43 @@ pub mod frame {
pub driver_status_detail: u32, pub driver_status_detail: u32,
} }
/// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the
/// two reject outcomes of [`check_attach`], each with different driver behavior.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachReject {
/// The header isn't (or is no longer) the ring this delivery described: magic missing, or
/// the host recreated the ring again before the attach (a fresh delivery is on its way).
/// Benign — drop the delivery silently; no status is written.
Stale,
/// The ring names a DIFFERENT monitor (`SharedHeader::target_id` mismatch) — a host
/// stash/delivery cross-wire that, with parallel displays, would publish this monitor's
/// frames into another client's stream. Fail closed: refuse the attach and write
/// [`DRV_STATUS_BIND_FAIL`] so the host's wait-for-attach fails the open loudly.
BindMismatch,
}
/// The publisher's attach precondition (v3): given the mapped header's `magic`, `generation`
/// and `target_id` plus the delivery's generation and the monitor's own target id, decide
/// whether the attach may proceed. Staleness is checked FIRST — a superseded delivery's binding
/// is meaningless (the fresh delivery re-validates it), so it never false-alarms as a bind
/// failure. Pure and shared-crate-owned so the reject paths are unit-tested on every dev
/// machine (the driver workspace builds `panic = "abort"` and cannot host a test harness).
pub fn check_attach(
magic: u32,
header_generation: u32,
header_target_id: u32,
delivery_generation: u32,
monitor_target_id: u32,
) -> Result<(), AttachReject> {
if magic != MAGIC || header_generation != delivery_generation {
return Err(AttachReject::Stale);
}
if header_target_id != monitor_target_id {
return Err(AttachReject::BindMismatch);
}
Ok(())
}
/// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`. /// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`.
/// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a /// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a
/// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never /// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never
@@ -316,8 +483,9 @@ pub mod frame {
} }
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the // Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after // mapping, so a same-size field reorder would silently corrupt. Pin every offset. `target_id`
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too. // (v3, the former `_pad`) after `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 —
// assert that too.
const _: () = { const _: () = {
use core::mem::{offset_of, size_of}; use core::mem::{offset_of, size_of};
@@ -329,7 +497,7 @@ pub mod frame {
assert!(offset_of!(SharedHeader, width) == 16); assert!(offset_of!(SharedHeader, width) == 16);
assert!(offset_of!(SharedHeader, height) == 20); assert!(offset_of!(SharedHeader, height) == 20);
assert!(offset_of!(SharedHeader, dxgi_format) == 24); assert!(offset_of!(SharedHeader, dxgi_format) == 24);
assert!(offset_of!(SharedHeader, _pad) == 28); assert!(offset_of!(SharedHeader, target_id) == 28);
assert!(offset_of!(SharedHeader, latest) == 32); assert!(offset_of!(SharedHeader, latest) == 32);
assert!(offset_of!(SharedHeader, qpc_pts) == 40); assert!(offset_of!(SharedHeader, qpc_pts) == 40);
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48); assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
@@ -588,12 +756,52 @@ mod tests {
h.magic = frame::MAGIC; h.magic = frame::MAGIC;
h.width = 5120; h.width = 5120;
h.height = 1440; h.height = 1440;
h.target_id = 262;
let bytes = bytemuck::bytes_of(&h); let bytes = bytemuck::bytes_of(&h);
assert_eq!(bytes.len(), 64); assert_eq!(bytes.len(), 64);
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes); let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
assert_eq!(back.magic, frame::MAGIC); assert_eq!(back.magic, frame::MAGIC);
assert_eq!(back.width, 5120); assert_eq!(back.width, 5120);
assert_eq!(back.height, 1440); assert_eq!(back.height, 1440);
// v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2
// host left it zero there).
assert_eq!(bytes[28..32], 262u32.to_le_bytes());
}
#[test]
fn attach_check_binds_ring_to_monitor() {
use frame::{check_attach, AttachReject, MAGIC};
// The good path: magic + matching generation + matching monitor binding.
assert_eq!(check_attach(MAGIC, 7, 262, 7, 262), Ok(()));
// Missing magic / superseded generation → Stale (silent drop, re-delivery coming) — and
// staleness WINS over a binding mismatch, since a superseded delivery's binding is
// meaningless (the fresh one re-validates).
assert_eq!(
check_attach(0, 7, 262, 7, 262),
Err(AttachReject::Stale),
"no magic"
);
assert_eq!(
check_attach(MAGIC, 8, 262, 7, 262),
Err(AttachReject::Stale),
"recreated ring"
);
assert_eq!(
check_attach(0, 8, 999, 7, 262),
Err(AttachReject::Stale),
"stale outranks bind"
);
// The v3 hardening: a fresh, magic-valid ring naming a DIFFERENT monitor fails closed.
assert_eq!(
check_attach(MAGIC, 7, 999, 7, 262),
Err(AttachReject::BindMismatch)
);
// A v2-host header (never stamped, target_id = 0) also fails closed against a v3 driver —
// the GET_INFO handshake rejects that pairing first, but the channel must not rely on it.
assert_eq!(
check_attach(MAGIC, 7, 0, 7, 262),
Err(AttachReject::BindMismatch)
);
} }
#[test] #[test]
@@ -604,12 +812,32 @@ mod tests {
height: 2160, height: 2160,
refresh_hz: 120, refresh_hz: 120,
preferred_monitor_id: 7, preferred_monitor_id: 7,
max_luminance_nits: 800,
max_frame_avg_nits: 400,
min_luminance_millinits: 50, // 0.05 nits
_reserved: 0,
}; };
let bytes = bytemuck::bytes_of(&req); let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 24); assert_eq!(bytes.len(), 40);
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req); assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible. // preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
assert_eq!(bytes[20..24], 7u32.to_le_bytes()); assert_eq!(bytes[20..24], 7u32.to_le_bytes());
// The client-HDR luminance tail rides after the legacy boundary; a zero-filled tail decodes
// as "unknown" (the un-upgraded-host form the driver's legacy read synthesizes).
assert_eq!(bytes[24..28], 800u32.to_le_bytes());
let mut legacy = [0u8; 40];
legacy[..control::ADD_REQUEST_LEGACY_SIZE]
.copy_from_slice(&bytes[..control::ADD_REQUEST_LEGACY_SIZE]);
let old = *bytemuck::from_bytes::<control::AddRequest>(&legacy);
assert_eq!(old.preferred_monitor_id, 7);
assert_eq!(
(
old.max_luminance_nits,
old.max_frame_avg_nits,
old.min_luminance_millinits
),
(0, 0, 0)
);
let reply = control::AddReply { let reply = control::AddReply {
adapter_luid_low: 0x1234_5678, adapter_luid_low: 0x1234_5678,
@@ -702,6 +930,48 @@ mod tests {
} }
} }
#[test]
fn cta_luminance_codes_hit_the_reference_points() {
// The driver's historical built-in EDID block: 0x8A ≈ 993 nits, 0x60 = 400 nits (exact),
// 0x12 for a ~0.05-nit floor. Our coder must land on the same bytes for those volumes.
assert_eq!(edid::cta_max_millinits(0x60), 400_000); // 50·2^3 exactly
assert_eq!(edid::cta_max_millinits(0x8A) / 1000, 993);
assert_eq!(edid::cta_max_luminance_code(400), 0x60);
// 0x8A decodes to 993.481 nits, so 994 is the smallest whole-nit input that reaches it
// under the never-advertise-brighter floor.
assert_eq!(edid::cta_max_luminance_code(994), 0x8A);
assert_eq!(edid::cta_min_luminance_code(50, 0x8A), 0x12); // 0.05 nits @ a 993-nit max
// Floor semantics: never advertise brighter than the panel. 1000 nits sits between
// code 138 (993) and 139 (~1015) → 138.
assert_eq!(edid::cta_max_luminance_code(1000), 138);
assert!(edid::cta_max_millinits(edid::cta_max_luminance_code(1000)) <= 1_000_000);
// Every real code decodes below or at its input (round-down), within one step (~2.2%).
// (Starts above code 1's 51.094 nits — beneath that the documented clamp-to-1 wins.)
for nits in [52u32, 80, 120, 250, 400, 604, 800, 1_499, 4_000, 10_000] {
let c = edid::cta_max_luminance_code(nits);
let dec = edid::cta_max_millinits(c);
assert!(dec <= nits as u64 * 1000, "{nits} → {c} decoded {dec}");
assert!(
dec * 1023 / 1000 >= nits as u64 * 1000,
"{nits} → {c} more than a step low"
);
}
// Clamps: 0/tiny stays a valid on-wire code (callers gate presence on nits > 0); the
// ceiling saturates at 255.
assert_eq!(edid::cta_max_luminance_code(0), 1);
assert_eq!(edid::cta_max_luminance_code(u32::MAX), 255);
// Min-luminance: 0 = unknown/true black stays 0; a floor brighter than the max clamps.
assert_eq!(edid::cta_min_luminance_code(0, 0x8A), 0);
assert_eq!(edid::cta_min_luminance_code(u32::MAX, 1), 255);
// Round-trip a typical HDR400 panel: max 400 nits / min 0.4 nits.
let max_c = edid::cta_max_luminance_code(400);
let min_c = edid::cta_min_luminance_code(400, max_c);
// decode: L_min = L_max·(cv/255)²/100 — must come back within ~10% of 0.4 nits.
let back =
edid::cta_max_millinits(max_c) * (min_c as u64 * min_c as u64) / (255 * 255) / 100;
assert!((360..=440).contains(&back), "min decoded {back} millinits");
}
#[test] #[test]
fn guid_is_not_sudovda() { fn guid_is_not_sudovda() {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
+11 -1
View File
@@ -62,7 +62,17 @@ vec3 srgb_oetf(vec3 c) {
} }
void main() { void main() {
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg); // 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
vec2 cuv = v_uv;
int cw = textureSize(u_c, 0).x;
if (cw < textureSize(u_y, 0).x) {
cuv.x += 0.25 / float(cw);
}
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
vec3 rgb = vec3( vec3 rgb = vec3(
dot(pc.r0.xyz, yuv) + pc.r0.w, dot(pc.r0.xyz, yuv) + pc.r0.w,
dot(pc.r1.xyz, yuv) + pc.r1.w, dot(pc.r1.xyz, yuv) + pc.r1.w,
Binary file not shown.
+4 -164
View File
@@ -9,55 +9,11 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use ash::vk; use ash::vk;
use pf_client_core::video::ColorDesc;
/// The push-constant block's matrix half: three vec4 rows, // The coefficient math lives in pf-client-core next to `ColorDesc` (one tested
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. // implementation shared with the Windows client's D3D11 constant buffer and mirrored by the
/// // Apple client's Swift port); re-exported here so presenter callers keep their import path.
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit: pub use pf_client_core::video::csc_rows;
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix {
5 | 6 => (0.299, 0.114),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range {
(pack, 0.0f64, pack)
} else {
(
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
};
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
];
core::array::from_fn(|r| {
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
})
}
/// The pass objects (everything except the per-video-size framebuffer, which lives with /// The pass objects (everything except the per-video-size framebuffer, which lives with
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's /// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
@@ -336,119 +292,3 @@ pub(crate) fn build_fullscreen_pipeline(
} }
Ok(pipeline?[0]) Ok(pipeline?[0])
} }
#[cfg(test)]
mod tests {
use super::*;
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc {
primaries: 1,
transfer: 1,
matrix,
full_range,
}
}
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
core::array::from_fn(|r| {
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
})
}
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form.
#[test]
fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
assert!(b.abs() < 0.005, "black {black:?}");
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the
/// matrix-code dispatch), same as the GL presenter's test.
#[test]
fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
"{red709:?}"
);
assert!((red[0] - red709[0]).abs() > 0.05);
}
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
/// grid of inputs — same math, different packing.
#[test]
fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full);
let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let (sy, oy, sc) = if full {
(1.0f32, 0.0f32, 1.0f32)
} else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
};
let mat = [
sy,
sy,
sy,
0.0,
-2.0 * (1.0 - kb) * kb / kg * sc,
2.0 * (1.0 - kb) * sc,
2.0 * (1.0 - kr) * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
0.0,
];
let off = [oy, -0.5, -0.5];
for yuv in [
[0.1f32, 0.3, 0.7],
[0.9, 0.5, 0.5],
[0.5, 0.2, 0.8],
[16.0 / 255.0, 0.5, 0.5],
] {
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
let gl: [f32; 3] =
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
let ours = apply(&rows, yuv);
for (a, b) in gl.iter().zip(ours) {
assert!(
(a - b).abs() < 1e-5,
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
);
}
}
}
}
}
+10 -4
View File
@@ -154,7 +154,9 @@ struct StreamState {
canceled: bool, canceled: bool,
ready_announced: bool, ready_announced: bool,
mode_line: String, mode_line: String,
clock_offset_ns: i64, /// Live host↔client clock offset handle (None until Connected): loaded per present so
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
hdr: bool, hdr: bool,
// Presenter-side 1 s window (design/stats-unification.md): end-to-end // Presenter-side 1 s window (design/stats-unification.md): end-to-end
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
@@ -205,7 +207,7 @@ impl StreamState {
canceled: false, canceled: false,
ready_announced: false, ready_announced: false,
mode_line: String::new(), mode_line: String::new(),
clock_offset_ns: 0, clock_offset: None,
hdr: false, hdr: false,
win_e2e_us: Vec::with_capacity(256), win_e2e_us: Vec::with_capacity(256),
win_disp_us: Vec::with_capacity(256), win_disp_us: Vec::with_capacity(256),
@@ -657,7 +659,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.set_title(&format!("{} · {}", opts.window_title, st.mode_line)) .set_title(&format!("{} · {}", opts.window_title, st.mode_line))
.ok(); .ok();
gamepad.attach(c.clone()); gamepad.attach(c.clone());
st.clock_offset_ns = c.clock_offset_ns; st.clock_offset = Some(c.clock_offset_shared());
let mut cap = Capture::new(c.clone()); let mut cap = Capture::new(c.clone());
cap.engage(); // capture engages when the stream starts (ui_stream parity) cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true); apply_capture(&mut window, &mouse, true);
@@ -960,7 +962,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
println!("{{\"ready\":true}}"); println!("{{\"ready\":true}}");
} }
// The `displayed` stamp (same clamp rules as the pump's windows). // The `displayed` stamp (same clamp rules as the pump's windows).
let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128) let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - pts_ns as i128)
.max(0) as u64; .max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 { if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000); st.win_e2e_us.push(e2e / 1000);
+19
View File
@@ -341,6 +341,11 @@ pub struct Presenter {
hdr10_format: Option<vk::SurfaceFormatKHR>, hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode. /// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool, hdr_active: bool,
/// One-shot latch: a PQ frame arrived but the surface offers no HDR10 colorspace, so the
/// CSC pass silently tone-maps to SDR. Warned once — the single most useful signal for
/// diagnosing "HDR isn't advertised" (e.g. gamescope's WSI layer invisible in a flatpak
/// sandbox) vs. the host simply not sending PQ.
hdr_downgrade_warned: bool,
/// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do). /// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do).
hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>, hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>,
/// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the /// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the
@@ -739,6 +744,7 @@ impl Presenter {
format, format,
hdr10_format, hdr10_format,
hdr_active: false, hdr_active: false,
hdr_downgrade_warned: false,
hdr_metadata_d, hdr_metadata_d,
hdr_meta: None, hdr_meta: None,
video_format: vk::Format::R8G8B8A8_UNORM, video_format: vk::Format::R8G8B8A8_UNORM,
@@ -1066,6 +1072,19 @@ impl Presenter {
FrameInput::D3d11(d) => Some(d.color.is_pq()), FrameInput::D3d11(d) => Some(d.color.is_pq()),
}; };
if let Some(pq) = frame_pq { if let Some(pq) = frame_pq {
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
// "HDR isn't advertised": the compositor never sees an HDR-committing app. Say so
// once — its presence proves PQ IS arriving and the surface/compositor is the
// blocker (on the Deck: gamescope's WSI layer not visible in the flatpak sandbox);
// its absence, with a plain SDR stream, points back at the host not sending PQ.
if pq && self.hdr10_format.is_none() && !self.hdr_downgrade_warned {
self.hdr_downgrade_warned = true;
tracing::warn!(
"PQ (HDR10) stream tone-mapped to SDR — the surface offers no HDR10 \
colorspace, so no HDR is committed to the compositor. Under gamescope this \
usually means the gamescope Vulkan WSI layer is not visible in the sandbox."
);
}
let want = pq && self.hdr10_format.is_some(); let want = pq && self.hdr10_format.is_some();
if want != self.hdr_active { if want != self.hdr_active {
self.set_hdr_mode(window, want)?; self.set_hdr_mode(window, want)?;
+8 -1
View File
@@ -77,7 +77,14 @@ libc = "0.2"
# windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the # windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the
# `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR. # `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR.
# Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature. # Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature.
windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_System_IO"] } # Win32_NetworkManagement_QoS + Win32_Foundation: the qWAVE flow API for real on-the-wire DSCP
# marking (transport/qos_windows.rs) — plain IP_TOS is stripped by the Windows stack.
windows-sys = { version = "0.59", features = [
"Win32_Networking_WinSock",
"Win32_System_IO",
"Win32_Foundation",
"Win32_NetworkManagement_QoS",
] }
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
@@ -71,6 +71,15 @@ fn bench_crypto(c: &mut Criterion) {
g.bench_function("open", |b| { g.bench_function("open", |b| {
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
}); });
g.bench_function("open_in_place", |b| {
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
let mut buf = sealed.clone();
b.iter(|| {
buf.copy_from_slice(black_box(&sealed));
black_box(client.open_in_place(0, &mut buf).unwrap());
})
});
g.finish(); g.finish();
} }
+64
View File
@@ -1374,6 +1374,10 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
crate::audio::normalize_channels(audio_channels), crate::audio::normalize_channels(audio_channels),
video_codecs, video_codecs,
preferred_codec, preferred_codec,
// No display-HDR-volume parameter in the C ABI yet: Apple/Android clients tone-map
// themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8`
// variant can carry it if a passthrough embedder ever needs it.
None,
launch, launch,
pin, pin,
identity, identity,
@@ -1748,6 +1752,10 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop. /// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`]. /// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
/// ///
/// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this
/// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to
/// honor the host-supplied lease and delete the client-side timeout heuristics.
///
/// # Safety /// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At /// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers. /// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
@@ -1788,6 +1796,62 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble(
}) })
} }
/// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
/// datagram — an old host that sent no self-termination lease. The client then falls back to its
/// own staleness heuristic for that update instead of a host-supplied deadline.
pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
/// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to
/// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus
/// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews
/// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side
/// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a
/// stale/reordered envelope never reaches the caller.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
c: *mut PunktfunkConnection,
pad: *mut u16,
low: *mut u16,
high: *mut u16,
ttl_ms: *mut u32,
timeout_ms: u32,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
match c
.inner
.next_rumble_ttl(std::time::Duration::from_millis(timeout_ms as u64))
{
Ok((p, l, h, ttl)) => {
unsafe {
if !pad.is_null() {
*pad = p;
}
if !low.is_null() {
*low = l;
}
if !high.is_null() {
*high = h;
}
if !ttl_ms.is_null() {
*ttl_ms = ttl.map_or(PUNKTFUNK_RUMBLE_NO_TTL, u32::from);
}
}
PunktfunkStatus::Ok
}
Err(e) => e.status(),
}
})
}
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger) /// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
/// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on /// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend /// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
+71
View File
@@ -125,6 +125,54 @@ pub fn normalize_channels(requested: u8) -> u8 {
} }
} }
/// Loss detector for the client audio plane, shared by every platform decoder.
///
/// The `0xC9` audio datagrams carry a per-packet sequence the host advances by 1 (wrapping), but
/// ride the lossy datagram plane with no FEC — a lost 5 ms Opus packet used to play out as a hard
/// gap (a click/pop; the jitter rings just emit silence). Feeding this tracker each received
/// packet's sequence tells the decoder how many packets went missing *immediately before it*, so
/// it can synthesize that many frames of libopus packet-loss concealment (`decode` with empty
/// input) before decoding the real one — turning clicks into an inaudible interpolation.
///
/// Reorders and duplicates conceal nothing (the plane has no reorder buffer; playing a late
/// packet where it lands is the existing behaviour), and a gap is capped at
/// [`MAX_CONCEAL_PACKETS`] (50 ms at the protocol's 5 ms frames) — libopus PLC fades to silence
/// after a few frames anyway, so past the cap the ring's underrun/re-prime path takes over as
/// before.
#[derive(Debug, Default)]
pub struct AudioGapTracker {
/// Sequence of the newest packet seen (`None` until the first).
last_seq: Option<u32>,
}
/// Most packets a single gap will ask concealment for (50 ms at the protocol's 5 ms frames).
/// Crate-internal: callers only ever see `missing_before`'s already-capped count (and cbindgen
/// must not export it — it's not part of the C ABI).
const MAX_CONCEAL_PACKETS: u32 = 10;
impl AudioGapTracker {
pub fn new() -> Self {
Self::default()
}
/// Feed the next received packet's sequence; returns how many packets are missing immediately
/// before it (`0` for in-order, the first packet, duplicates, and reorders), capped at
/// [`MAX_CONCEAL_PACKETS`]. Wrapping-safe: a sequence in the backward half of the u32 space is
/// a reorder, not a 2³¹-packet gap.
pub fn missing_before(&mut self, seq: u32) -> u32 {
let Some(last) = self.last_seq else {
self.last_seq = Some(seq);
return 0;
};
let delta = seq.wrapping_sub(last);
if delta == 0 || delta > u32::MAX / 2 {
return 0; // duplicate, or a reorder older than the newest — nothing to conceal
}
self.last_seq = Some(seq);
(delta - 1).min(MAX_CONCEAL_PACKETS)
}
}
// ---- per-platform channel-layout helpers (pure data; no platform deps) -------------------- // ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout. /// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
@@ -215,6 +263,29 @@ mod tests {
} }
} }
#[test]
fn gap_tracker_counts_only_forward_gaps() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(100), 0, "first packet");
assert_eq!(t.missing_before(101), 0, "in order");
assert_eq!(t.missing_before(104), 2, "102+103 lost");
assert_eq!(t.missing_before(104), 0, "duplicate");
assert_eq!(t.missing_before(103), 0, "late reorder conceals nothing");
assert_eq!(t.missing_before(105), 0, "reorder didn't move the anchor");
// A huge gap is capped; the stream continues from the new anchor.
assert_eq!(t.missing_before(105 + 1000), MAX_CONCEAL_PACKETS);
assert_eq!(t.missing_before(105 + 1001), 0);
}
#[test]
fn gap_tracker_survives_seq_wraparound() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(u32::MAX - 1), 0);
assert_eq!(t.missing_before(u32::MAX), 0, "in order at the edge");
assert_eq!(t.missing_before(1), 1, "seq 0 lost across the wrap");
assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap");
}
#[test] #[test]
fn wasapi_masks_are_correct() { fn wasapi_masks_are_correct() {
assert_eq!(wasapi_channel_mask(2), 0x3); assert_eq!(wasapi_channel_mask(2), 0x3);
+391 -119
View File
@@ -17,14 +17,14 @@ use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::packet::FLAG_PROBE; use crate::packet::FLAG_PROBE;
use crate::quic::{ use crate::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, ColorInfo, HdrMeta, Hello, HidOutput, accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RichInput, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
SetBitrate, Start, Welcome, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RichInput, SetBitrate, Start, Welcome,
}; };
use crate::session::{Frame, Session}; use crate::session::{Frame, Session};
use crate::transport::UdpTransport; use crate::transport::UdpTransport;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -53,28 +53,42 @@ enum CtrlRequest {
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's /// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
/// [`BitrateController`] when the user's bitrate setting is Automatic. /// [`BitrateController`] when the user's bitrate setting is Automatic.
SetBitrate(u32), SetBitrate(u32),
/// Start a mid-stream clock re-sync batch now (see [`ClockResync`]). Sent by the pump on
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
ClockResync,
} }
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated /// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's /// [`Welcome`]-resolved session parameters (mode, backends, encode/colour/audio geometry) plus the
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset /// host certificate fingerprint and the connect-time clock offset. Mirrored one-to-one onto the
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake). /// public `NativeClient` fields of the same names.
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc` #[derive(Clone, Copy)]
/// (1 = 4:2:0, 3 = 4:4:4), the resolved audio channel count (2/6/8), and the resolved video codec struct Negotiated {
/// (`quic::CODEC_*`), with [`ColorInfo`] the resolved colour signalling — all from the [`Welcome`]. mode: Mode,
type Negotiated = ( compositor: CompositorPref,
Mode, gamepad: GamepadPref,
CompositorPref, /// SHA-256 of the certificate the host actually presented (TOFU callers persist this).
GamepadPref, host_fingerprint: [u8; 32],
[u8; 32], /// The encoder bitrate the host actually configured (kbps); `0` = an older host.
u32, bitrate_kbps: u32,
i64, /// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
u8, clock_offset_ns: i64,
ColorInfo, /// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
u8, /// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
u8, /// compares each batch against this baseline ([`accept_resync`]).
u8, clock_rtt_ns: Option<u64>,
); /// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
bit_depth: u8,
/// Resolved CICP colour signalling.
color: ColorInfo,
/// Resolved chroma subsampling as the HEVC `chroma_format_idc` (1 = 4:2:0, 3 = 4:4:4).
chroma_format: u8,
/// Resolved audio channel count (2/6/8) — what the Opus decoders must be built from.
audio_channels: u8,
/// The single codec the host will emit (`quic::CODEC_*`).
codec: u8,
}
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the /// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
/// session's packet-level receive counters here; the control task finalizes the delivered figure /// session's packet-level receive counters here; the control task finalizes the delivered figure
@@ -85,7 +99,8 @@ type Negotiated = (
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived. /// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
#[derive(Default)] #[derive(Default)]
struct ProbeState { struct ProbeState {
/// A probe is in progress (set by `request_probe`, cleared by nothing — the latest one wins). /// A probe is in progress: set by `request_probe`, cleared when the host's [`ProbeResult`]
/// lands (a re-probe just overwrites the whole state — the latest one wins).
active: bool, active: bool,
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first /// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
/// tick while active) and latest, mirrored every pump iteration. /// tick while active) and latest, mirrored every pump iteration.
@@ -177,6 +192,41 @@ const FLUSH_AFTER_FRAMES: u32 = 30;
/// warning instead of a continuous flush/keyframe storm. /// warning instead of a continuous flush/keyframe storm.
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2); const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
/// shifted the skew-corrected latency by a constant — every future frame reads over-bound and the
/// detector would fire forever, one flush + recovery IDR per cooldown, dragging the bitrate
/// controller to its floor; or the delay is standing in an **upstream queue** (router bufferbloat),
/// which a local flush can't drain — the OWD signal already feeds the bitrate controller, the
/// actual remedy. Even at the 5 Mbps bitrate floor a genuine 400 ms backlog is ~170 datagrams, so
/// 64 cleanly separates "empty" from "real". See `NOOP_CLOCK_FLUSHES_TO_DISARM`.
const NOOP_FLUSH_DATAGRAMS: u64 = 64;
/// Consecutive no-op clock-triggered flushes (see [`NOOP_FLUSH_DATAGRAMS`]) before the clock-based
/// detector is disarmed. The clock-free standing-queue detector stays armed — it measures the
/// local queue directly and can't be fooled by a clock step. No longer for the rest of the
/// session: an applied mid-stream clock re-sync re-arms the detector (the disarm stays as the
/// final backstop between re-syncs).
const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
/// Cadence of the control task's periodic mid-stream clock re-sync (see [`ClockResync`]): often
/// enough to bound slow drift and pick up an NTP step within a minute, rare enough to be free
/// (8 tiny control messages per batch). The pump additionally fires one immediately after the
/// FIRST no-op clock flush — the moment a step is actually suspected.
const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Outbound mic uplink queue depth: 5 ms Opus frames, so 64 is ~320 ms of audio — far beyond
/// any worker stall a live mic session survives anyway. On overflow the FRESH frame is dropped
/// (a tokio mpsc can't shed from the head; by the time 320 ms are queued the stream is broken
/// either way, and the bound is about memory, not audio quality) and logged at debug.
const MIC_QUEUE: usize = 64;
/// Outbound control-request queue depth. The requests are sparse (mode switches, keyframe
/// requests, ~1.3 loss reports/s, clock re-syncs) — 32 is hours of headroom; a full queue means
/// the control task is wedged, which callers treat as a closed session.
const CTRL_QUEUE: usize = 32;
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes /// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the /// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next /// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
@@ -272,10 +322,17 @@ impl FrameChannel {
const AUDIO_QUEUE: usize = 64; const AUDIO_QUEUE: usize = 64;
/// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same /// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same
/// `try_send` discipline as the other planes) — the host re-sends rumble state /// `try_send` discipline as the other planes) — the host renews rumble state periodically
/// periodically, so a dropped transition (including a stop) heals within ~500 ms. /// (v2 envelopes) or re-sends it (legacy v1), so a dropped transition (including a stop) heals
/// within one renewal/refresh period.
const RUMBLE_QUEUE: usize = 16; const RUMBLE_QUEUE: usize = 16;
/// A rumble update handed to the embedder: `(pad, low, high, ttl_ms)`. `ttl_ms` is `Some(ms)` for
/// a self-terminating v2 envelope (render for at most that long) and `None` for a legacy v1
/// datagram (an old host — the renderer applies its own staleness policy). The seq from a v2
/// envelope is consumed by the reorder gate in the datagram demux and is NOT forwarded.
type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder. /// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder.
/// Same overflow discipline as rumble; the host re-sends on the next feedback change. /// Same overflow discipline as rumble; the host re-sends on the next feedback change.
const HIDOUT_QUEUE: usize = 32; const HIDOUT_QUEUE: usize = 32;
@@ -305,7 +362,7 @@ pub struct NativeClient {
// and two threads racing one plane now serialize instead of being undefined). // and two threads racing one plane now serialize instead of being undefined).
frames: Arc<FrameChannel>, frames: Arc<FrameChannel>,
audio: Mutex<Receiver<AudioPacket>>, audio: Mutex<Receiver<AudioPacket>>,
rumble: Mutex<Receiver<(u16, u16, u16)>>, rumble: Mutex<Receiver<RumbleUpdate>>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>, hidout: Mutex<Receiver<HidOutput>>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams. /// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
@@ -315,11 +372,15 @@ pub struct NativeClient {
host_timing: Mutex<Receiver<crate::quic::HostTiming>>, host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>, input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>, /// Bounded ([`MIC_QUEUE`]): a wedged worker drops fresh frames (logged) instead of queueing
/// audio-latency (and memory) without limit — mic is best-effort end to end.
mic_tx: tokio::sync::mpsc::Sender<(u32, u64, Vec<u8>)>,
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker. /// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>, rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
/// Outbound control-stream requests (mode switch, speed test) → the worker's control task. /// Outbound control-stream requests (mode switch, speed test) → the worker's control task.
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, /// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
/// is wedged/dead, and callers treat it like a closed session.
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
/// Speed-test accumulator, shared with the data-plane pump + control task. /// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
@@ -343,6 +404,11 @@ pub struct NativeClient {
/// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without /// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without
/// `gettid` (see [`current_hot_tid`]). /// `gettid` (see [`current_hot_tid`]).
hot_tids: Arc<Mutex<Vec<i32>>>, hot_tids: Arc<Mutex<Vec<i32>>>,
/// The LIVE host↔client clock offset (ns): seeded with the connect-time estimate, then kept
/// fresh by the control task's mid-stream re-syncs (every [`CLOCK_RESYNC_INTERVAL`], plus on
/// the pump's first no-op clock flush). Shared with the pump and, via
/// [`clock_offset_shared`](Self::clock_offset_shared), with embedder latency-math threads.
clock_offset: Arc<AtomicI64>,
worker: Option<std::thread::JoinHandle<()>>, worker: Option<std::thread::JoinHandle<()>>,
/// The currently active session mode (the Welcome's, then updated by every accepted /// The currently active session mode (the Welcome's, then updated by every accepted
/// [`NativeClient::request_mode`]). /// [`NativeClient::request_mode`]).
@@ -364,7 +430,9 @@ pub struct NativeClient {
/// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local /// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local
/// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making /// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't /// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
/// answer, or genuinely synced clocks). /// answer, or genuinely synced clocks). This is the CONNECT-TIME estimate, kept for ABI/compat;
/// ongoing latency math should read [`clock_offset_now_ns`](Self::clock_offset_now_ns), which
/// follows mid-stream re-syncs after a wall-clock step or drift.
pub clock_offset_ns: i64, pub clock_offset_ns: i64,
/// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or /// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or
/// `10` for a Main10 / HDR session. `8` for an older host that didn't report it. /// `10` for a Main10 / HDR session. `8` for an older host that didn't report it.
@@ -491,6 +559,11 @@ impl NativeClient {
// it emits from these and echoes it in [`NativeClient::codec`]. // it emits from these and echoes it in [`NativeClient::codec`].
video_codecs: u8, video_codecs: u8,
preferred_codec: u8, preferred_codec: u8,
// The client display's HDR colour volume (primaries/white/luminance), read from the OS
// (e.g. DXGI `GetDesc1`) when presenting HDR. The host forwards it into the virtual
// display's EDID so host apps tone-map to the client's real panel; `None` = unknown/SDR
// (the host keeps its built-in EDID defaults). See [`crate::quic::Hello::display_hdr`].
display_hdr: Option<HdrMeta>,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
@@ -498,15 +571,15 @@ impl NativeClient {
) -> Result<NativeClient> { ) -> Result<NativeClient> {
let frame_chan = Arc::new(FrameChannel::new()); let frame_chan = Arc::new(FrameChannel::new());
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE); let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<RumbleUpdate>(RUMBLE_QUEUE);
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE); let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE); let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) = let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE); std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>(); let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>(); let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>(); let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>(); let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false)); let quit = Arc::new(AtomicBool::new(false));
@@ -515,6 +588,7 @@ impl NativeClient {
let frames_dropped = Arc::new(AtomicU64::new(0)); let frames_dropped = Arc::new(AtomicU64::new(0));
let fec_recovered = Arc::new(AtomicU64::new(0)); let fec_recovered = Arc::new(AtomicU64::new(0));
let hot_tids = Arc::new(Mutex::new(Vec::new())); let hot_tids = Arc::new(Mutex::new(Vec::new()));
let clock_offset = Arc::new(AtomicI64::new(0));
let host = host.to_string(); let host = host.to_string();
let frame_chan_w = frame_chan.clone(); let frame_chan_w = frame_chan.clone();
@@ -525,6 +599,7 @@ impl NativeClient {
let frames_dropped_w = frames_dropped.clone(); let frames_dropped_w = frames_dropped.clone();
let fec_recovered_w = fec_recovered.clone(); let fec_recovered_w = fec_recovered.clone();
let hot_tids_w = hot_tids.clone(); let hot_tids_w = hot_tids.clone();
let clock_offset_w = clock_offset.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new() let worker = std::thread::Builder::new()
.name("punktfunk-client".into()) .name("punktfunk-client".into())
@@ -555,6 +630,7 @@ impl NativeClient {
audio_channels, audio_channels,
video_codecs, video_codecs,
preferred_codec, preferred_codec,
display_hdr,
launch, launch,
pin, pin,
identity, identity,
@@ -577,23 +653,12 @@ impl NativeClient {
frames_dropped: frames_dropped_w, frames_dropped: frames_dropped_w,
fec_recovered: fec_recovered_w, fec_recovered: fec_recovered_w,
hot_tids: hot_tids_w, hot_tids: hot_tids_w,
clock_offset: clock_offset_w,
})); }));
}) })
.map_err(PunktfunkError::Io)?; .map_err(PunktfunkError::Io)?;
let ( let negotiated = match ready_rx.recv_timeout(timeout) {
negotiated,
resolved_compositor,
resolved_gamepad,
fingerprint,
resolved_bitrate_kbps,
clock_offset_ns,
bit_depth,
color,
chroma_format,
audio_channels,
codec,
) = match ready_rx.recv_timeout(timeout) {
Ok(Ok(t)) => t, Ok(Ok(t)) => t,
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
@@ -601,7 +666,7 @@ impl NativeClient {
return Err(PunktfunkError::Timeout); return Err(PunktfunkError::Timeout);
} }
}; };
*mode_slot.lock().unwrap() = negotiated; *mode_slot.lock().unwrap() = negotiated.mode;
Ok(NativeClient { Ok(NativeClient {
frames: frame_chan, frames: frame_chan,
audio: Mutex::new(audio_rx), audio: Mutex::new(audio_rx),
@@ -620,17 +685,18 @@ impl NativeClient {
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
hot_tids, hot_tids,
clock_offset,
mode: mode_slot, mode: mode_slot,
host_fingerprint: fingerprint, host_fingerprint: negotiated.host_fingerprint,
resolved_compositor, resolved_compositor: negotiated.compositor,
resolved_gamepad, resolved_gamepad: negotiated.gamepad,
resolved_bitrate_kbps, resolved_bitrate_kbps: negotiated.bitrate_kbps,
clock_offset_ns, clock_offset_ns: negotiated.clock_offset_ns,
bit_depth, bit_depth: negotiated.bit_depth,
color, color: negotiated.color,
chroma_format, chroma_format: negotiated.chroma_format,
audio_channels, audio_channels: negotiated.audio_channels,
codec, codec: negotiated.codec,
}) })
} }
@@ -788,7 +854,7 @@ impl NativeClient {
/// reflects it. A rejected request leaves the session unchanged. /// reflects it. A rejected request leaves the session unchanged.
pub fn request_mode(&self, mode: Mode) -> Result<()> { pub fn request_mode(&self, mode: Mode) -> Result<()> {
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Mode(mode)) .try_send(CtrlRequest::Mode(mode))
.map_err(|_| PunktfunkError::Closed) .map_err(|_| PunktfunkError::Closed)
} }
@@ -798,7 +864,7 @@ impl NativeClient {
/// lands, so requesting on every frame would flood the control stream). /// lands, so requesting on every frame would flood the control stream).
pub fn request_keyframe(&self) -> Result<()> { pub fn request_keyframe(&self) -> Result<()> {
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Keyframe) .try_send(CtrlRequest::Keyframe)
.map_err(|_| PunktfunkError::Closed) .map_err(|_| PunktfunkError::Closed)
} }
@@ -849,6 +915,23 @@ impl NativeClient {
self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default() self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default()
} }
/// The LIVE host↔client clock offset (ns): the connect-time skew estimate, kept fresh by
/// mid-stream re-syncs (every 60 s, plus immediately when a wall-clock step is suspected).
/// Prefer this over the connect-time [`clock_offset_ns`](Self::clock_offset_ns) field for any
/// ongoing latency math — after an NTP step or slow drift the connect-time value silently
/// corrupts every capture-clock comparison. `0` = no skew handshake (old host / synced clocks).
pub fn clock_offset_now_ns(&self) -> i64 {
self.clock_offset.load(Ordering::Relaxed)
}
/// Shared handle to the live clock offset for plane threads that outlive a `&self` borrow
/// (render/display trackers). Read with [`AtomicI64::load`]`(Ordering::Relaxed)` at each use —
/// never cache the value across frames. Holding this does NOT keep the session alive (unlike
/// an `Arc<NativeClient>`, whose drop disconnects).
pub fn clock_offset_shared(&self) -> Arc<AtomicI64> {
self.clock_offset.clone()
}
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at /// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the /// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its /// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
@@ -861,7 +944,7 @@ impl NativeClient {
..Default::default() ..Default::default()
}; };
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Probe(ProbeRequest { .try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps, target_kbps,
duration_ms, duration_ms,
})) }))
@@ -948,8 +1031,20 @@ impl NativeClient {
} }
/// Pull the next rumble update `(pad, low, high)`; same semantics as /// Pull the next rumble update `(pad, low, high)`; same semantics as
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. /// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. The self-terminating
/// TTL of a v2 envelope is dropped here — use [`NativeClient::next_rumble_ttl`] to honor it (a
/// renderer that only sees `(pad, low, high)` keeps its own staleness policy exactly as before,
/// which is what makes this back-compatible for un-updated embedders).
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> { pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
self.next_rumble_ttl(timeout).map(|(p, l, h, _)| (p, l, h))
}
/// Pull the next rumble update including its self-termination TTL: `(pad, low, high, ttl_ms)`.
/// `ttl_ms` is `Some(ms)` for a v2 envelope — render the level for at most that long, then
/// silence — and `None` for a legacy v1 datagram (an old host with no lease; fall back to the
/// renderer's own staleness heuristic). The reorder gate (seq) is applied in the datagram demux
/// before the update reaches this queue, so a stale/reordered envelope never surfaces here.
pub fn next_rumble_ttl(&self, timeout: Duration) -> Result<RumbleUpdate> {
match self.rumble.lock().unwrap().recv_timeout(timeout) { match self.rumble.lock().unwrap().recv_timeout(timeout) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
@@ -1007,9 +1102,17 @@ impl NativeClient {
/// uses them only for diagnostics). The host decodes it into a virtual microphone source. /// uses them only for diagnostics). The host decodes it into a virtual microphone source.
/// Best-effort — like every datagram, it's dropped under loss; no retransmit. /// Best-effort — like every datagram, it's dropped under loss; no retransmit.
pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> { pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> {
self.mic_tx use tokio::sync::mpsc::error::TrySendError;
.send((seq, pts_ns, opus)) match self.mic_tx.try_send((seq, pts_ns, opus)) {
.map_err(|_| PunktfunkError::Closed) Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => {
// Bounded queue full = the worker stalled for ~MIC_QUEUE x 5 ms. Shed this
// frame (mic is best-effort end to end) instead of queueing latency/memory.
tracing::debug!("mic uplink queue full — dropping frame");
Ok(())
}
Err(TrySendError::Closed(_)) => Err(PunktfunkError::Closed),
}
} }
/// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a /// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a
@@ -1040,6 +1143,33 @@ impl Drop for NativeClient {
} }
} }
/// Test/A-B hatch shared by the client shells: `PUNKTFUNK_CLIENT_PEAK_NITS=<nits>` synthesizes a
/// display colour volume at that peak (BT.2020 primaries, D65, a 0.005-nit floor, frame-average
/// unknown) for [`Hello::display_hdr`](crate::quic::Hello::display_hdr), overriding whatever the
/// shell read from the OS — so the host-side tone-map target (the virtual display's EDID volume)
/// can be pinned exactly for validation, and shells with no OS display-volume query get a manual
/// knob. `None` when unset/unparsable/zero.
pub fn display_hdr_env_override() -> Option<HdrMeta> {
let nits: u32 = std::env::var("PUNKTFUNK_CLIENT_PEAK_NITS")
.ok()?
.trim()
.parse()
.ok()
.filter(|&n| n > 0)?;
tracing::info!(
nits,
"PUNKTFUNK_CLIENT_PEAK_NITS: overriding the advertised display volume"
);
Some(HdrMeta {
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]], // BT.2020 G, B, R
white_point: [15635, 16450], // D65
max_display_mastering_luminance: nits.saturating_mul(10_000),
min_display_mastering_luminance: 50, // 0.005 nits
max_cll: 0,
max_fall: 0,
})
}
struct WorkerArgs { struct WorkerArgs {
host: String, host: String,
port: u16, port: u16,
@@ -1051,20 +1181,21 @@ struct WorkerArgs {
audio_channels: u8, audio_channels: u8,
video_codecs: u8, video_codecs: u8,
preferred_codec: u8, preferred_codec: u8,
display_hdr: Option<HdrMeta>,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
frames: Arc<FrameChannel>, frames: Arc<FrameChannel>,
audio_tx: SyncSender<AudioPacket>, audio_tx: SyncSender<AudioPacket>,
rumble_tx: SyncSender<(u16, u16, u16)>, rumble_tx: SyncSender<RumbleUpdate>,
hidout_tx: SyncSender<HidOutput>, hidout_tx: SyncSender<HidOutput>,
hdr_meta_tx: SyncSender<HdrMeta>, hdr_meta_tx: SyncSender<HdrMeta>,
host_timing_tx: SyncSender<crate::quic::HostTiming>, host_timing_tx: SyncSender<crate::quic::HostTiming>,
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>, input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>, mic_rx: tokio::sync::mpsc::Receiver<(u32, u64, Vec<u8>)>,
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>, rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
ctrl_rx: tokio::sync::mpsc::UnboundedReceiver<CtrlRequest>, ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>, ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
@@ -1074,6 +1205,9 @@ struct WorkerArgs {
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
fec_recovered: Arc<AtomicU64>, fec_recovered: Arc<AtomicU64>,
hot_tids: Arc<Mutex<Vec<i32>>>, hot_tids: Arc<Mutex<Vec<i32>>>,
/// The live clock offset (see [`NativeClient::clock_offset`]): the worker seeds it with the
/// connect-time estimate; the control task's mid-stream re-syncs update it.
clock_offset: Arc<AtomicI64>,
} }
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking /// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
@@ -1090,6 +1224,7 @@ async fn worker_main(args: WorkerArgs) {
audio_channels, audio_channels,
video_codecs, video_codecs,
preferred_codec, preferred_codec,
display_hdr,
launch, launch,
pin, pin,
identity, identity,
@@ -1112,6 +1247,7 @@ async fn worker_main(args: WorkerArgs) {
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
hot_tids, hot_tids,
clock_offset,
} = args; } = args;
let setup = async { let setup = async {
let remote: std::net::SocketAddr = join_host_port(&host, port) let remote: std::net::SocketAddr = join_host_port(&host, port)
@@ -1168,6 +1304,9 @@ async fn worker_main(args: WorkerArgs) {
// resolves the emitted codec from these and reports it in `Welcome::codec`. // resolves the emitted codec from these and reports it in `Welcome::codec`.
video_codecs, video_codecs,
preferred_codec, preferred_codec,
// The client display's HDR volume → the host's virtual-display EDID (host apps
// tone-map to the client's real panel). `None` = unknown/SDR.
display_hdr,
} }
.encode(), .encode(),
) )
@@ -1203,7 +1342,8 @@ async fn worker_main(args: WorkerArgs) {
// it): align our clock to the host's so the embedder can express receive/present instants in // it): align our clock to the host's so the embedder can express receive/present instants in
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock // the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
// assumption, as before). This is the substrate for glass-to-glass present-time measurement. // assumption, as before). This is the substrate for glass-to-glass present-time measurement.
let clock_offset_ns = match crate::quic::clock_sync(&mut send, &mut recv).await { let (clock_offset_ns, clock_rtt_ns) =
match crate::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => { Some(skew) => {
tracing::info!( tracing::info!(
offset_ns = skew.offset_ns, offset_ns = skew.offset_ns,
@@ -1211,9 +1351,9 @@ async fn worker_main(args: WorkerArgs) {
rounds = skew.rounds, rounds = skew.rounds,
"clock skew estimated (host-client)" "clock skew estimated (host-client)"
); );
skew.offset_ns (skew.offset_ns, Some(skew.rtt_ns))
} }
None => 0, None => (0, None),
}; };
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port); let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
@@ -1231,58 +1371,42 @@ async fn worker_main(args: WorkerArgs) {
session, session,
send, send,
recv, recv,
welcome.mode, Negotiated {
welcome.compositor, mode: welcome.mode,
welcome.gamepad, compositor: welcome.compositor,
fingerprint, gamepad: welcome.gamepad,
welcome.bitrate_kbps, host_fingerprint: fingerprint,
bitrate_kbps: welcome.bitrate_kbps,
clock_offset_ns, clock_offset_ns,
welcome.bit_depth, clock_rtt_ns,
welcome.color, bit_depth: welcome.bit_depth,
welcome.chroma_format, color: welcome.color,
welcome.audio_channels, chroma_format: welcome.chroma_format,
welcome.codec, audio_channels: welcome.audio_channels,
codec: welcome.codec,
},
welcome.host_caps, welcome.host_caps,
)) ))
}; };
let ( let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
conn, {
mut session,
mut ctrl_send,
mut ctrl_recv,
negotiated,
resolved_compositor,
resolved_gamepad,
fingerprint,
resolved_bitrate_kbps,
clock_offset_ns,
bit_depth,
color,
chroma_format,
audio_channels,
codec,
host_caps,
) = match setup.await {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
let _ = ready_tx.send(Err(e)); let _ = ready_tx.send(Err(e));
return; return;
} }
}; };
let _ = ready_tx.send(Ok(( // Copies the pump needs after `negotiated` is handed over to `connect`.
negotiated, let clock_rtt_ns = negotiated.clock_rtt_ns;
resolved_compositor, let resolved_bitrate_kbps = negotiated.bitrate_kbps;
resolved_gamepad, // Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
fingerprint, // client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
resolved_bitrate_kbps, clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
clock_offset_ns, // Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
bit_depth, // reset its staleness counters and re-arm the clock-based jump-to-live detector.
color, let clock_gen = Arc::new(AtomicU32::new(0));
chroma_format, let _ = ready_tx.send(Ok(negotiated));
audio_channels,
codec,
)));
// Input task: embedder events → QUIC datagrams. Toward a host that advertised // Input task: embedder events → QUIC datagrams. Toward a host that advertised
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are // HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
@@ -1365,7 +1489,20 @@ async fn worker_main(args: WorkerArgs) {
let mode_slot = mode_slot.clone(); let mode_slot = mode_slot.clone();
let probe = probe.clone(); let probe = probe.clone();
let bitrate_ack = bitrate_ack.clone(); let bitrate_ack = bitrate_ack.clone();
let clock_offset = clock_offset.clone();
let clock_gen = clock_gen.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
// its first no-op clock flush). Echoes interleave with the other control replies in
// the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes.
let mut resync = ClockResync::new();
let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL,
);
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
tokio::select! { tokio::select! {
req = ctrl_rx.recv() => { req = ctrl_rx.recv() => {
@@ -1376,11 +1513,23 @@ async fn worker_main(args: WorkerArgs) {
CtrlRequest::Keyframe => RequestKeyframe.encode(), CtrlRequest::Keyframe => RequestKeyframe.encode(),
CtrlRequest::Loss(r) => r.encode(), CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(), CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer
}
resync.begin(wall_clock_ns()).encode()
}
}; };
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() { if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break; break;
} }
} }
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = io::read_msg(&mut ctrl_recv) => { msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) { if let Ok(ack) = Reconfigured::decode(&msg) {
@@ -1404,6 +1553,7 @@ async fn worker_main(args: WorkerArgs) {
p.host_send_dropped = result.send_dropped; p.host_send_dropped = result.send_dropped;
p.host_duration_ms = result.duration_ms; p.host_duration_ms = result.duration_ms;
p.done = true; p.done = true;
p.active = false; // burst over — the pump stops mirroring counters
tracing::info!( tracing::info!(
host_goodput_bytes = result.bytes_sent, host_goodput_bytes = result.bytes_sent,
wire_packets_sent = result.wire_packets_sent, wire_packets_sent = result.wire_packets_sent,
@@ -1421,6 +1571,35 @@ async fn worker_main(args: WorkerArgs) {
"host re-targeted encoder bitrate" "host re-targeted encoder bitrate"
); );
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps); *bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
break;
}
}
ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read
// late exactly then) — keep the old estimate and let the next
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
offset_ns,
rtt_us = rtt_ns / 1000,
"mid-stream clock re-sync applied"
);
} else {
tracing::debug!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
}
}
ResyncStep::Idle => {}
}
} else { } else {
tracing::warn!("unknown control message — ignoring"); tracing::warn!("unknown control message — ignoring");
} }
@@ -1433,6 +1612,10 @@ async fn worker_main(args: WorkerArgs) {
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the // Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
// newest packet rather than backing up the QUIC receive path). // newest packet rather than backing up the QUIC receive path).
let dgram_conn = conn.clone(); let dgram_conn = conn.clone();
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(d) = dgram_conn.read_datagram().await { while let Ok(d) = dgram_conn.read_datagram().await {
match d.first() { match d.first() {
@@ -1446,8 +1629,31 @@ async fn worker_main(args: WorkerArgs) {
} }
} }
Some(&crate::quic::RUMBLE_MAGIC) => { Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(r) = crate::quic::decode_rumble_datagram(&d) { if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
let _ = rumble_tx.try_send(r); // Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
} else {
true // out-of-range pad (host never sends these): no gate
}
}
None => true,
};
if fresh {
let ttl = u.envelope.map(|e| e.ttl_ms);
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
}
} }
} }
Some(&crate::quic::HIDOUT_MAGIC) => { Some(&crate::quic::HIDOUT_MAGIC) => {
@@ -1487,6 +1693,8 @@ async fn worker_main(args: WorkerArgs) {
let pump_shutdown = shutdown.clone(); let pump_shutdown = shutdown.clone();
let pump_probe = probe.clone(); let pump_probe = probe.clone();
let pump_hot_tids = hot_tids.clone(); let pump_hot_tids = hot_tids.clone();
let pump_clock_offset = clock_offset.clone();
let pump_clock_gen = clock_gen.clone();
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
@@ -1515,7 +1723,34 @@ async fn worker_main(args: WorkerArgs) {
let mut stale_frames: u32 = 0; let mut stale_frames: u32 = 0;
let mut standing_frames: u32 = 0; let mut standing_frames: u32 = 0;
let mut last_flush: Option<Instant> = None; let mut last_flush: Option<Instant> = None;
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
// the control task for an immediate re-sync (via the report tick): the flush finding no
// local backlog IS the "the wall clock stepped under me" signal.
let mut noop_clock_flushes: u32 = 0;
let mut clock_detector_armed = true;
let mut resync_wanted = false;
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
while !pump_shutdown.load(Ordering::SeqCst) { while !pump_shutdown.load(Ordering::SeqCst) {
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
// re-sync takes effect on the very next frame's latency math.
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
// An applied re-sync invalidates the staleness run measured under the OLD offset:
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
let gen = pump_clock_gen.load(Ordering::Relaxed);
if gen != seen_clock_gen {
seen_clock_gen = gen;
stale_frames = 0;
noop_clock_flushes = 0;
if !clock_detector_armed {
clock_detector_armed = true;
tracing::info!(
"clock re-sync applied — clock-based jump-to-live re-armed"
);
}
}
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery // Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput // loop, and (during a speed test) the packet-level receive counters for the throughput
// measurement. Updated every iteration (not just on a produced frame) so they stay current // measurement. Updated every iteration (not just on a produced frame) so they stay current
@@ -1534,13 +1769,19 @@ async fn worker_main(args: WorkerArgs) {
p.active && !p.done p.active && !p.done
}; };
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL { if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
if resync_wanted {
resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped); let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm( let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered), st.fec_recovered_shards.wrapping_sub(last_recovered),
st.packets_received.wrapping_sub(last_received), st.packets_received.wrapping_sub(last_received),
window_dropped, window_dropped,
); );
let _ = ctrl_tx.send(CtrlRequest::Loss(LossReport { loss_ppm })); let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then // Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
// feed the controller this window's congestion signals; a decision becomes a // feed the controller this window's congestion signals; a decision becomes a
// SetBitrate on the control stream. // SetBitrate on the control stream.
@@ -1558,7 +1799,7 @@ async fn worker_main(args: WorkerArgs) {
flush_in_window, flush_in_window,
) { ) {
tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target"); tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target");
let _ = ctrl_tx.send(CtrlRequest::SetBitrate(kbps)); let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
} }
flush_in_window = false; flush_in_window = false;
last_report = Instant::now(); last_report = Instant::now();
@@ -1605,7 +1846,10 @@ async fn worker_main(args: WorkerArgs) {
owd_sum_ns += lat_ns; owd_sum_ns += lat_ns;
owd_frames += 1; owd_frames += 1;
} }
if clock_offset_ns != 0 && lat_ns > FLUSH_LATENCY.as_nanos() as i128 { if clock_detector_armed
&& clock_offset_ns != 0
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
{
stale_frames += 1; stale_frames += 1;
} else { } else {
stale_frames = 0; stale_frames = 0;
@@ -1627,7 +1871,7 @@ async fn worker_main(args: WorkerArgs) {
flush_in_window = true; // strongest "link can't hold the rate" signal flush_in_window = true; // strongest "link can't hold the rate" signal
let flushed = session.flush_backlog().unwrap_or(0); let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear(); let dropped = frames.clear();
let _ = ctrl_tx.send(CtrlRequest::Keyframe); let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
tracing::warn!( tracing::warn!(
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 }, behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
queue_depth = depth, queue_depth = depth,
@@ -1635,6 +1879,34 @@ async fn worker_main(args: WorkerArgs) {
dropped_frames = dropped, dropped_frames = dropped,
"receive backlog stopped draining — jumped to live (flush + keyframe)" "receive backlog stopped draining — jumped to live (flush + keyframe)"
); );
// Clock-detector health check: a clock-only trigger whose flush found
// no local backlog is a false "behind" reading (a wall-clock step, or
// an upstream queue a local flush can't drain) — repeated, it would
// cost a recovery IDR every cooldown forever. Disarm after two in a
// row; the clock-free queue detector keeps covering real backlogs.
if clock_behind && !queue_behind
&& flushed < NOOP_FLUSH_DATAGRAMS
&& dropped == 0
{
noop_clock_flushes += 1;
if noop_clock_flushes == 1 {
// First no-op flush = a wall-clock step is the prime
// suspect: ask for an immediate re-sync (sent on the next
// report tick). Applied, it resets these counters and
// re-arms the detector before the disarm below triggers.
resync_wanted = true;
}
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
clock_detector_armed = false;
tracing::warn!(
"clock-based jump-to-live disarmed — its flushes found no \
local backlog (clock step or upstream queueing suspected); \
the queue-depth detector stays armed"
);
}
} else {
noop_clock_flushes = 0;
}
continue; // this frame is part of the stale past — don't render it continue; // this frame is part of the stale past — don't render it
} }
} }
+58
View File
@@ -90,6 +90,31 @@ impl SessionCrypto {
) )
.map_err(|_| PunktfunkError::Crypto) .map_err(|_| PunktfunkError::Crypto)
} }
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
/// mirror of [`seal_in_place`](Self::seal_in_place).
pub fn open_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<usize> {
if buf.len() < TAG_LEN {
return Err(PunktfunkError::BadPacket);
}
let nonce = nonce(self.recv_salt, seq);
let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split);
self.cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&seq.to_be_bytes(),
ciphertext,
aes_gcm::Tag::from_slice(tag),
)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(split)
}
} }
fn direction(role: Role) -> u8 { fn direction(role: Role) -> u8 {
@@ -164,6 +189,39 @@ mod tests {
); );
} }
#[test]
fn open_in_place_matches_open_and_rejects_tampering() {
let key = random_key();
let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [
&b""[..],
b"x",
b"the quick brown fox jumps over 13 lazy dogs!!",
] {
let sealed = host.seal(9, msg).unwrap();
let mut buf = sealed.clone();
let n = client.open_in_place(9, &mut buf).unwrap();
assert_eq!(
&buf[..n],
msg,
"in-place open must be byte-identical to open"
);
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
let mut buf = sealed.clone();
assert!(client.open_in_place(8, &mut buf).is_err());
// A flipped ciphertext/tag bit → authentication failure.
let mut buf = sealed.clone();
let last = buf.len() - 1;
buf[last] ^= 1;
assert!(client.open_in_place(9, &mut buf).is_err());
}
// Shorter than a tag can't be a sealed packet at all.
let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err());
}
#[test] #[test]
fn seal_in_place_matches_seal_and_opens() { fn seal_in_place_matches_seal_and_opens() {
let key = random_key(); let key = random_key();
+1 -1
View File
@@ -12,7 +12,7 @@ impl ErasureCoder for Gf16Coder {
FecScheme::Gf16 FecScheme::Gf16
} }
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
if recovery_count == 0 { if recovery_count == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
+9 -9
View File
@@ -15,7 +15,7 @@ impl ErasureCoder for Gf8Coder {
FecScheme::Gf8 FecScheme::Gf8
} }
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
if recovery_count == 0 { if recovery_count == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -24,13 +24,12 @@ impl ErasureCoder for Gf8Coder {
let shard_len = data[0].len(); let shard_len = data[0].len();
let rs = ReedSolomon::new(k, recovery_count) let rs = ReedSolomon::new(k, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
// fec-rs fills parity in place: shards = data || zeroed parity. // `encode_sep` reads the data shards by reference and fills the parity in place —
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(k + recovery_count); // same Cauchy codec as `encode`, without copying the data into a shards scratch.
shards.extend_from_slice(data); let mut parity: Vec<Vec<u8>> = (0..recovery_count).map(|_| vec![0u8; shard_len]).collect();
shards.resize_with(k + recovery_count, || vec![0u8; shard_len]); rs.encode_sep(data, &mut parity)
rs.encode(&mut shards)
.map_err(|_| FecError::Backend("gf8 encode"))?; .map_err(|_| FecError::Backend("gf8 encode"))?;
Ok(shards.split_off(k)) Ok(parity)
} }
fn reconstruct( fn reconstruct(
@@ -84,7 +83,7 @@ mod tests {
fn nanors_exact_parity_vectors() { fn nanors_exact_parity_vectors() {
let coder = Gf8Coder; let coder = Gf8Coder;
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0]. // The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
let data = vec![vec![10u8], vec![20], vec![30], vec![40]]; let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]];
let parity = coder.encode(&data, 2).unwrap(); let parity = coder.encode(&data, 2).unwrap();
assert_eq!(parity, vec![vec![136u8], vec![0u8]]); assert_eq!(parity, vec![vec![136u8], vec![0u8]]);
@@ -106,7 +105,8 @@ mod tests {
fn recovers_erased_data_shards() { fn recovers_erased_data_shards() {
let coder = Gf8Coder; let coder = Gf8Coder;
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect(); let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
let parity = coder.encode(&data, 3).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let parity = coder.encode(&refs, 3).unwrap();
let mut received: Vec<Option<Vec<u8>>> = data let mut received: Vec<Option<Vec<u8>>> = data
.iter() .iter()
.cloned() .cloned()
+8 -4
View File
@@ -30,7 +30,9 @@ pub trait ErasureCoder: Send + Sync {
/// Encode `data` (K original shards) into `recovery_count` (M) parity shards. /// Encode `data` (K original shards) into `recovery_count` (M) parity shards.
/// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`. /// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`.
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>; /// Takes shard *references* so the packetizer can point straight into the frame
/// buffer instead of copying every data byte into per-shard `Vec`s first.
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are /// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost. /// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
@@ -79,7 +81,7 @@ pub(crate) fn validate_block_shape(
} }
/// Validate `encode` inputs: at least one data shard, all of equal length. /// Validate `encode` inputs: at least one data shard, all of equal length.
pub(crate) fn validate_encode_shape(data: &[Vec<u8>]) -> Result<(), FecError> { pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
let first = data let first = data
.first() .first()
.ok_or(FecError::Config("no data shards"))? .ok_or(FecError::Config("no data shards"))?
@@ -100,7 +102,8 @@ mod tests {
let data: Vec<Vec<u8>> = (0..k) let data: Vec<Vec<u8>> = (0..k)
.map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect()) .map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect())
.collect(); .collect();
let recovery = coder.encode(&data, m).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = coder.encode(&refs, m).unwrap();
assert_eq!(recovery.len(), m); assert_eq!(recovery.len(), m);
let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m); let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m);
@@ -128,7 +131,8 @@ mod tests {
#[test] #[test]
fn gf8_too_much_loss_errors() { fn gf8_too_much_loss_errors() {
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect(); let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
let recovery = Gf8Coder.encode(&data, 2).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = Gf8Coder.encode(&refs, 2).unwrap();
let mut received: Vec<Option<Vec<u8>>> = data let mut received: Vec<Option<Vec<u8>>> = data
.iter() .iter()
.cloned() .cloned()
+5 -1
View File
@@ -57,7 +57,11 @@ pub use stats::Stats;
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). /// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
/// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — /// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
/// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). /// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
pub const ABI_VERSION: u32 = 4; /// v5: added `punktfunk_connection_next_rumble2` (rumble pull that also yields the self-terminating
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
/// [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 5;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+57 -20
View File
@@ -104,6 +104,11 @@ pub struct Packetizer {
shard_payload: usize, shard_payload: usize,
fec: crate::config::FecConfig, fec: crate::config::FecConfig,
version: u8, version: u8,
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
/// exact `shard_payload` multiple (and for the single all-zero shard of an empty frame).
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
tail: Vec<u8>,
} }
impl Packetizer { impl Packetizer {
@@ -114,6 +119,7 @@ impl Packetizer {
shard_payload: config.shard_payload, shard_payload: config.shard_payload,
fec: config.fec, fec: config.fec,
version: config.phase as u8, version: config.phase as u8,
tail: Vec::new(),
} }
} }
@@ -129,7 +135,9 @@ impl Packetizer {
self.fec.fec_percent self.fec.fec_percent
} }
/// Packetize one access unit into wire packets (header + shard payload each). /// Packetize one access unit into owned wire packets (header ++ shard payload each).
/// Thin wrapper over [`packetize_each`](Self::packetize_each) — the allocation-free
/// streaming path's reference implementation (tests and the loss harness use this).
pub fn packetize( pub fn packetize(
&mut self, &mut self,
frame: &[u8], frame: &[u8],
@@ -137,6 +145,31 @@ impl Packetizer {
user_flags: u32, user_flags: u32,
coder: &dyn ErasureCoder, coder: &dyn ErasureCoder,
) -> Result<Vec<Vec<u8>>> { ) -> Result<Vec<Vec<u8>>> {
let mut packets = Vec::new();
self.packetize_each(frame, pts_ns, user_flags, coder, |hdr, body| {
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
pkt.extend_from_slice(hdr.as_bytes());
pkt.extend_from_slice(body);
packets.push(pkt);
Ok(())
})?;
Ok(packets)
}
/// Packetize one access unit, yielding each packet to `emit` as a `(header, shard bytes)`
/// pair — in exact wire order, which is also the order the session's nonce counter
/// advances. No per-packet allocation happens here, so the caller can write header and
/// shard straight into a pooled wire buffer and seal in place
/// ([`Session::seal_frame`](crate::session::Session::seal_frame)). An `emit` error aborts
/// the frame mid-way (packet numbering has already advanced — callers treat it as fatal).
pub fn packetize_each(
&mut self,
frame: &[u8],
pts_ns: u64,
user_flags: u32,
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let payload = self.shard_payload; let payload = self.shard_payload;
let frame_index = self.next_frame_index; let frame_index = self.next_frame_index;
self.next_frame_index = self.next_frame_index.wrapping_add(1); self.next_frame_index = self.next_frame_index.wrapping_add(1);
@@ -159,23 +192,31 @@ impl Packetizer {
)); ));
} }
let mut packets = Vec::new(); // Stage the frame's one possibly-partial shard (the last) in the reusable
// zero-padded scratch; every full shard is referenced in place below.
let full_shards = frame.len() / payload;
self.tail.clear();
self.tail.resize(payload, 0);
let rem = frame.len() % payload;
if rem > 0 {
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
}
let tail = &self.tail;
let shard_at = |s: usize| -> &[u8] {
if s < full_shards {
&frame[s * payload..(s + 1) * payload]
} else {
tail.as_slice()
}
};
for b in 0..block_count { for b in 0..block_count {
let first = b * max_block; let first = b * max_block;
let last = ((b + 1) * max_block).min(total_data); let last = ((b + 1) * max_block).min(total_data);
let block_data_count = last - first; let block_data_count = last - first;
// Build this block's data shards (each `payload` bytes, last zero-padded). // This block's data shards: references into `frame` (plus the staged tail).
let mut data_shards: Vec<Vec<u8>> = Vec::with_capacity(block_data_count); let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect();
for s in first..last {
let start = s * payload;
let end = (start + payload).min(frame.len());
let mut shard = vec![0u8; payload];
if start < frame.len() {
shard[..end - start].copy_from_slice(&frame[start..end]);
}
data_shards.push(shard);
}
let recovery_count = self.fec.recovery_for(block_data_count); let recovery_count = self.fec.recovery_for(block_data_count);
let recovery = coder.encode(&data_shards, recovery_count)?; let recovery = coder.encode(&data_shards, recovery_count)?;
@@ -186,7 +227,7 @@ impl Packetizer {
for shard_index in 0..total_shards { for shard_index in 0..total_shards {
let body: &[u8] = if shard_index < block_data_count { let body: &[u8] = if shard_index < block_data_count {
&data_shards[shard_index] data_shards[shard_index]
} else { } else {
&recovery[shard_index - block_data_count] &recovery[shard_index - block_data_count]
}; };
@@ -219,14 +260,10 @@ impl Packetizer {
fec_scheme: coder.scheme() as u8, fec_scheme: coder.scheme() as u8,
flags, flags,
}; };
emit(&hdr, body)?;
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
pkt.extend_from_slice(hdr.as_bytes());
pkt.extend_from_slice(body);
packets.push(pkt);
} }
} }
Ok(packets) Ok(())
} }
} }
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
//! Wall-clock skew: the connect-time handshake ([`clock_sync`]), the NTP-style offset
//! estimator ([`clock_offset_ns`]), and the mid-stream re-sync state machine
//! ([`ClockResync`]).
use super::{io, ClockEcho, ClockProbe};
/// Estimate the host↔client clock offset (**host minus client**, ns) and RTT (ns) from skew-handshake
/// samples `(t1, t2, t3, t4)` — NTP's formula, taking the **minimum-RTT** sample (least queuing
/// noise; also discards the first round's host-setup latency). Offset is positive when the host
/// clock is ahead of the client's; add it to a client timestamp to express it in the host clock.
/// Returns `None` for an empty sample set.
pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
samples
.iter()
.map(|&(t1, t2, t3, t4)| {
let rtt = ((t4 as i128 - t1 as i128) - (t3 as i128 - t2 as i128)).max(0) as u64;
let offset = (((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) / 2) as i64;
(offset, rtt)
})
.min_by_key(|&(_, rtt)| rtt)
}
/// One wall-clock skew-handshake outcome (see [`clock_sync`]).
pub struct ClockSkew {
/// Host clock minus client clock, ns: add it to a client timestamp to express it in host time.
pub offset_ns: i64,
/// Round-trip time of the minimum-RTT sample, ns.
pub rtt_ns: u64,
/// How many probe rounds the host answered.
pub rounds: usize,
}
/// Run the wall-clock skew handshake from the client side over the (already-open) control stream:
/// `ROUNDS` [`ClockProbe`]/[`ClockEcho`] round-trips, returning the host↔client offset from the
/// minimum-RTT sample. `None` if the host never answers (an old host) — the caller then assumes a
/// shared clock. Each read is bounded so a silent host can't wedge session start. Shared by the
/// reference client and the embeddable connector; uses the realtime clock the host stamps `pts_ns`
/// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync(
send: &mut quinn::SendStream,
recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> {
use std::time::Duration;
const ROUNDS: usize = 8;
let read_timeout = Duration::from_secs(2);
let mut samples: Vec<(u64, u64, u64, u64)> = Vec::with_capacity(ROUNDS);
for _ in 0..ROUNDS {
let t1 = wall_clock_ns();
let probe = ClockProbe { t1_ns: t1 }.encode();
if io::write_msg(send, &probe).await.is_err() {
break;
}
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
let echo = match read {
Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e,
Err(_) => break,
},
_ => break, // timeout or stream error -> old host / no skew support
};
samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, wall_clock_ns()));
}
clock_offset_ns(&samples).map(|(offset_ns, rtt_ns)| ClockSkew {
offset_ns,
rtt_ns,
rounds: samples.len(),
})
}
/// Wall-clock now (ns since the Unix epoch) — the clock the skew handshake stamps and the host
/// stamps AU `pts_ns` with (CLOCK_REALTIME basis, deliberately NOT monotonic: steps/slew are
/// exactly what the handshake measures across machines).
pub fn wall_clock_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
/// What [`ClockResync::on_echo`] asks the driver to do next.
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle,
/// Send this next-round probe and keep feeding echoes.
Probe(ClockProbe),
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 },
}
/// Mid-stream wall-clock re-sync (networking-audit deferred plan §2): the same 8-round
/// probe/echo estimate as the connect-time [`clock_sync`], restructured as a state machine so
/// the client's control task can drive it from its `select!` loop without blocking the stream —
/// echoes interleave with other control traffic; rounds are matched by the echoed `t1`.
///
/// A step or slow drift of either wall clock after connect silently corrupts the clock-based
/// jump-to-live signal, the ABR one-way-delay signal, and every latency stat. Re-syncing
/// restores them; the disarm heuristic stays as the final backstop.
pub struct ClockResync {
/// `t1_ns` of the probe in flight; `None` = no batch active. An echo whose `t1` doesn't
/// match is stale (an abandoned batch) and ignored.
pending_t1: Option<u64>,
samples: Vec<(u64, u64, u64, u64)>,
}
impl ClockResync {
/// Rounds per batch — matches the connect-time [`clock_sync`].
pub const ROUNDS: usize = 8;
pub fn new() -> ClockResync {
ClockResync {
pending_t1: None,
samples: Vec::with_capacity(Self::ROUNDS),
}
}
/// Start a (new) batch, abandoning any batch still in flight — its late echoes won't match
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear();
self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns }
}
/// Feed an inbound [`ClockEcho`] received at `now_ns` (the round's `t4`).
pub fn on_echo(&mut self, echo: &ClockEcho, now_ns: u64) -> ResyncStep {
if self.pending_t1 != Some(echo.t1_ns) {
return ResyncStep::Idle; // stale (abandoned batch) or unsolicited
}
self.samples
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS {
self.pending_t1 = Some(now_ns);
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
self.pending_t1 = None;
match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
}
}
}
impl Default for ClockResync {
fn default() -> Self {
Self::new()
}
}
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
/// biases the offset by its queueing delay, and frames already read late exactly then; better
/// to keep the old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
}
+508
View File
@@ -0,0 +1,508 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
/// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
pub const MIC_MAGIC: u8 = 0xCB;
/// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
/// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
/// kind-tagged (see [`RichInput`]).
pub const RICH_INPUT_MAGIC: u8 = 0xCC;
/// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
/// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
/// [`HidOutput`].
pub const HIDOUT_MAGIC: u8 = 0xCD;
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(AUDIO_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != AUDIO_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
/// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v2`] instead; this is kept
/// for the loopback tests and as the wire an old host still speaks (a new client decodes both via
/// [`decode_rumble_envelope`]).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b
}
/// Wire length of a v1 (legacy, level) rumble datagram.
pub const RUMBLE_V1_LEN: usize = 7;
/// Wire length of a v2 (envelope) rumble datagram — the v1 body plus a `[u8 seq][u16 ttl_ms LE]`
/// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
/// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
/// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
pub const RUMBLE_V2_LEN: usize = 10;
/// Rumble envelope datagram (v2), host → client:
/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE]`.
///
/// A *self-terminating* force-feedback command: the level is authorized for at most `ttl_ms`, so
/// a rumble the host stops renewing (or a host that dies) silences on its own — "stuck forever"
/// is inexpressible on the wire. `seq` is a per-pad wrapping counter (bumped on every send,
/// changes *and* renewals) compared with [`GamepadSnapshot::seq_newer`](crate::input::GamepadSnapshot::seq_newer)
/// so a reordered stale start can't re-light the motors after a stop. Renewals fully replace the
/// prior envelope's deadline; they never stack. An explicit stop is still `low == high == 0` sent
/// immediately (expiry is the safety net, never the stop mechanism).
pub fn encode_rumble_datagram_v2(pad: u16, low: u16, high: u16, seq: u8, ttl_ms: u16) -> [u8; 10] {
let mut b = [0u8; RUMBLE_V2_LEN];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b[7] = seq;
b[8..10].copy_from_slice(&ttl_ms.to_le_bytes());
b
}
/// The self-termination tail of a v2 rumble envelope (see [`encode_rumble_datagram_v2`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleEnvelope {
/// Per-pad wrapping send counter — the reorder gate (see [`decode_rumble_envelope`]).
pub seq: u8,
/// How long, in ms, this envelope authorizes the stated level before the client must silence.
pub ttl_ms: u16,
}
/// A decoded rumble update. `envelope` is `None` for a legacy 7-byte datagram (an old host, which
/// has no seq/ttl — the client applies its own staleness policy), `Some` for a v2 envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleUpdate {
pub pad: u16,
pub low: u16,
pub high: u16,
pub envelope: Option<RumbleEnvelope>,
}
/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) a v2 envelope tail.
/// `None` on bad tag/length. Kept for callers that only need the level (the probe, the loopback
/// assertions); clients that honor TTL use [`decode_rumble_envelope`].
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
Some((u16at(1), u16at(3), u16at(5)))
}
/// Parse a rumble datagram → [`RumbleUpdate`], detecting the v2 envelope tail by length. A
/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a 7..RUMBLE_V2_LEN buffer is a legacy level
/// (`envelope: None`) — the same tolerance as an old client would apply, so a torn/short tail
/// degrades to a level rather than dropping. `None` on bad tag/length.
pub fn decode_rumble_envelope(b: &[u8]) -> Option<RumbleUpdate> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let envelope = (b.len() >= RUMBLE_V2_LEN).then(|| RumbleEnvelope {
seq: b[7],
ttl_ms: u16::from_le_bytes([b[8], b[9]]),
});
Some(RumbleUpdate {
pad: u16at(1),
low: u16at(3),
high: u16at(5),
envelope,
})
}
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(MIC_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a mic datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != MIC_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
pub(super) const RICH_MOTION: u8 = 0x02;
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
/// `[0xCC][kind][fields…]` — variable-length and kind-tagged (forward-compatible: an unknown
/// kind decodes to `None` and is dropped).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RichInput {
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
/// the finger.
Touchpad {
pad: u8,
finger: u8,
active: bool,
x: u16,
y: u16,
},
/// Motion sensors: `gyro` (pitch/yaw/roll) + `accel`, raw signed-16 in the sensor's own
/// units — passed straight into the DualSense report.
Motion {
pad: u8,
gyro: [i16; 3],
accel: [i16; 3],
},
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
/// `TouchpadEx` (`0x03`) indefinitely.
TouchpadEx {
pad: u8,
surface: u8,
finger: u8,
touch: bool,
click: bool,
x: i16,
y: i16,
pressure: u16,
},
}
impl RichInput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![RICH_INPUT_MAGIC];
match *self {
RichInput::Touchpad {
pad,
finger,
active,
x,
y,
} => {
out.extend_from_slice(&[RICH_TOUCHPAD, pad, finger, active as u8]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
}
RichInput::Motion { pad, gyro, accel } => {
out.extend_from_slice(&[RICH_MOTION, pad]);
for v in gyro.iter().chain(accel.iter()) {
out.extend_from_slice(&v.to_le_bytes());
}
}
RichInput::TouchpadEx {
pad,
surface,
finger,
touch,
click,
x,
y,
pressure,
} => {
let state = (touch as u8) | ((click as u8) << 1);
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
out.extend_from_slice(&pressure.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<RichInput> {
if b.first() != Some(&RICH_INPUT_MAGIC) {
return None;
}
match *b.get(1)? {
RICH_TOUCHPAD if b.len() >= 9 => Some(RichInput::Touchpad {
pad: b[2],
finger: b[3],
active: b[4] != 0,
x: u16::from_le_bytes([b[5], b[6]]),
y: u16::from_le_bytes([b[7], b[8]]),
}),
RICH_MOTION if b.len() >= 15 => {
let i16at = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
Some(RichInput::Motion {
pad: b[2],
gyro: [i16at(3), i16at(5), i16at(7)],
accel: [i16at(9), i16at(11), i16at(13)],
})
}
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
pad: b[2],
surface: b[3],
finger: b[4],
touch: b[5] & 0x01 != 0,
click: b[5] & 0x02 != 0,
x: i16::from_le_bytes([b[6], b[7]]),
y: i16::from_le_bytes([b[8], b[9]]),
pressure: u16::from_le_bytes([b[10], b[11]]),
}),
_ => None,
}
}
}
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
/// rumble itself stays on [`RUMBLE_MAGIC`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HidOutput {
/// Lightbar RGB.
Led { pad: u8, r: u8, g: u8, b: u8 },
/// Player-indicator LEDs (low 5 bits).
PlayerLeds { pad: u8, bits: u8 },
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
/// trigger parameter block (mode + params) for the client to replay on a real controller.
Trigger { pad: u8, which: u8, effect: Vec<u8> },
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
TrackpadHaptic {
pad: u8,
side: u8,
amplitude: u16,
period: u16,
count: u16,
},
}
impl HidOutput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![HIDOUT_MAGIC];
match self {
HidOutput::Led { pad, r, g, b } => {
out.extend_from_slice(&[HIDOUT_LED, *pad, *r, *g, *b])
}
HidOutput::PlayerLeds { pad, bits } => {
out.extend_from_slice(&[HIDOUT_PLAYER_LEDS, *pad, *bits])
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
}
HidOutput::TrackpadHaptic {
pad,
side,
amplitude,
period,
count,
} => {
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
out.extend_from_slice(&amplitude.to_le_bytes());
out.extend_from_slice(&period.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<HidOutput> {
if b.first() != Some(&HIDOUT_MAGIC) {
return None;
}
match *b.get(1)? {
HIDOUT_LED if b.len() >= 6 => Some(HidOutput::Led {
pad: b[2],
r: b[3],
g: b[4],
b: b[5],
}),
HIDOUT_PLAYER_LEDS if b.len() >= 4 => Some(HidOutput::PlayerLeds {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
side: b[3],
amplitude: u16::from_le_bytes([b[4], b[5]]),
period: u16::from_le_bytes([b[6], b[7]]),
count: u16::from_le_bytes([b[8], b[9]]),
}),
_ => None,
}
}
}
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
/// is larger and can change mid-stream when the source's mastering intent changes; the host
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
/// for HLG (scene-referred — no mastering metadata).
///
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct HdrMeta {
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
/// is G, B, R).
pub display_primaries: [[u16; 2]; 3],
/// White point (x, y) in 1/50000 units.
pub white_point: [u16; 2],
/// Max display mastering luminance, 0.0001 cd/m² units.
pub max_display_mastering_luminance: u32,
/// Min display mastering luminance, 0.0001 cd/m² units.
pub min_display_mastering_luminance: u32,
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
pub max_cll: u16,
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
pub max_fall: u16,
}
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
pub const HDR_META_MAGIC: u8 = 0xCE;
/// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
/// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
/// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body).
pub const HDR_META_BODY_LEN: usize = 12 + 4 + 8 + 4;
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + body = 29 bytes.
const HDR_META_LEN: usize = 1 + HDR_META_BODY_LEN;
/// Append `m`'s [`HDR_META_BODY_LEN`]-byte wire body (LE, no tag byte) to `b`.
pub fn write_hdr_meta_body(m: &HdrMeta, b: &mut Vec<u8>) {
for p in m.display_primaries.iter() {
b.extend_from_slice(&p[0].to_le_bytes());
b.extend_from_slice(&p[1].to_le_bytes());
}
b.extend_from_slice(&m.white_point[0].to_le_bytes());
b.extend_from_slice(&m.white_point[1].to_le_bytes());
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.max_cll.to_le_bytes());
b.extend_from_slice(&m.max_fall.to_le_bytes());
}
/// Read an [`HdrMeta`] from its wire body (no tag byte). The caller guarantees `b` holds at least
/// [`HDR_META_BODY_LEN`] bytes (both callers slice with an exact-length, bounds-checked `get`).
pub fn read_hdr_meta_body(b: &[u8]) -> HdrMeta {
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
HdrMeta {
display_primaries: [
[u16at(0), u16at(2)],
[u16at(4), u16at(6)],
[u16at(8), u16at(10)],
],
white_point: [u16at(12), u16at(14)],
max_display_mastering_luminance: u32at(16),
min_display_mastering_luminance: u32at(20),
max_cll: u16at(24),
max_fall: u16at(26),
}
}
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
let mut b = Vec::with_capacity(HDR_META_LEN);
b.push(HDR_META_MAGIC);
write_hdr_meta_body(m, &mut b);
b
}
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
return None;
}
Some(read_hdr_meta_body(&b[1..]))
}
/// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
/// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
/// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
pub const HOST_TIMING_MAGIC: u8 = 0xCF;
/// One access unit's host-side processing time: capture → fully sent (the whole host pipeline —
/// capture read/convert, encode, FEC+seal, paced send). The client correlates it to the AU by
/// `pts_ns` (the AU's capture stamp, unique per frame) and derives
/// `network = (received + clock_offset pts_ns) host_us`, so the unified-stats equation's
/// `host+network` stage splits into two per-frame-tiling terms. Best-effort like every side-plane
/// datagram: a lost 0xCF just means that frame contributes no host/network sample.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HostTiming {
/// The AU's capture stamp (host capture clock — matches the AU's `pts_ns` exactly).
pub pts_ns: u64,
/// Host capture→sent duration, µs (saturated at `u32::MAX` ≈ 71 min — far past the 10 s
/// client-side sanity clamp anyway).
pub host_us: u32,
}
/// Wire length of a [`HOST_TIMING_MAGIC`] datagram: tag + u64 pts + u32 µs = 13 bytes.
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram.
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
let mut b = Vec::with_capacity(HOST_TIMING_LEN);
b.push(HOST_TIMING_MAGIC);
b.extend_from_slice(&t.pts_ns.to_le_bytes());
b.extend_from_slice(&t.host_us.to_le_bytes());
b
}
/// Parse a [`HOST_TIMING_MAGIC`] datagram → [`HostTiming`]. `None` on bad tag or a short buffer
/// (the fixed length bounds every read before it happens).
pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
if b.len() < HOST_TIMING_LEN || b[0] != HOST_TIMING_MAGIC {
return None;
}
Some(HostTiming {
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
})
}
+362
View File
@@ -0,0 +1,362 @@
use std::sync::{Arc, Mutex};
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
/// idle timer run out and quinn closes the session — surfacing to the embedder as
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
fn stream_transport() -> Arc<quinn::TransportConfig> {
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
}
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
/// the keep-alive holds it open through quiet control periods.
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
use std::time::Duration;
let idle = idle.max(Duration::from_secs(1));
let keep_alive = (idle / 2).min(Duration::from_secs(4));
let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
));
t.keep_alive_interval(Some(keep_alive));
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
// standing delay that never drains while video stays live. Capping the buffer makes the
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
Arc::new(t)
}
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
/// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
}
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
pub fn server_with_identity(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
) -> anyhow_result::Result<quinn::Endpoint> {
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
}
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
/// keep-alive scales with it so a live session never false-closes.
pub fn server_with_identity_idle(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
server_from_der(cert_der, key_der, addr, idle)
}
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
/// together (acceptable while the protocol/ABI is still evolving).
const QUIC_ALPN: &[u8] = b"pkf1";
fn server_from_der(
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
// pairing is required).
let mut rustls_cfg = rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
.with_single_cert(vec![cert_der], key_der)
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
Ok(quinn::Endpoint::server(server_config, addr)?)
}
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
/// client persists once and presents on every connect so hosts can recognize it.
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
}
/// Fingerprint of the client certificate a connection presented (host side), if any.
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
let identity = conn.peer_identity()?;
let certs = identity
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
.ok()?;
certs.first().map(|c| cert_fingerprint(c.as_ref()))
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(cert_der).into()
}
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
/// must match what the client's verifier computes from the DER on the wire).
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
Ok(cert_fingerprint(der.as_ref()))
}
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
/// observed fingerprint off the slot and pin it on the next connect).
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
client_pinned(None).0
}
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
/// observed host fingerprint into during the handshake.
pub type PinnedClient = (
anyhow_result::Result<quinn::Endpoint>,
Arc<Mutex<Option<[u8; 32]>>>,
);
/// Client endpoint that verifies the host by certificate fingerprint.
///
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
client_pinned_with_identity(pin, None)
}
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
/// key) via TLS client auth — how a paired client identifies itself to the host.
pub fn client_pinned_with_identity(
pin: Option<[u8; 32]>,
identity: Option<(&str, &str)>,
) -> PinnedClient {
let observed = Arc::new(Mutex::new(None));
let ep = (|| {
let _ = rustls::crypto::ring::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify {
pin,
observed: observed.clone(),
}));
let mut rustls_cfg = match identity {
None => builder.with_no_client_auth(),
Some((cert_pem, key_pem)) => {
use rustls::pki_types::pem::PemObject;
let cert =
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("client cert pem: {e}")))?;
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
builder
.with_client_auth_cert(vec![cert], key)
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
}
};
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
ep.set_default_client_config(client_cfg);
Ok(ep)
})();
(ep, observed)
}
/// Minimal error plumbing without pulling anyhow into punktfunk-core's public API.
pub mod anyhow_result {
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl Error {
pub fn msg(s: String) -> Self {
Error(s)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error(e.to_string())
}
}
}
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
/// verify the handshake signature for real — possession of the presented cert's key is
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
/// Authorization (is this fingerprint paired?) happens at the application layer.
#[derive(Debug)]
struct AcceptAnyClientCert;
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&[]
}
fn client_auth_mandatory(&self) -> bool {
false // unpaired/legacy clients still connect; gating is per-feature
}
fn verify_client_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
Ok(rustls::server::danger::ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
observed: Arc<Mutex<Option<[u8; 32]>>>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let fp = cert_fingerprint(end_entity.as_ref());
*self.observed.lock().unwrap() = Some(fp);
if let Some(expected) = self.pin {
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
// The handshake signatures MUST be verified for real even though we pin the cert:
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
// skip it and an active MITM can replay the host's (public) certificate, match the
// pin, and complete the handshake with its own key.
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2];
recv.read_exact(&mut len)
.await
.map_err(std::io::Error::other)?;
let n = u16::from_le_bytes(len) as usize;
let mut buf = vec![0u8; n];
recv.read_exact(&mut buf)
.await
.map_err(std::io::Error::other)?;
Ok(buf)
}
/// Write one framed message.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload))
.await
.map_err(std::io::Error::other)
}
+64
View File
@@ -0,0 +1,64 @@
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
//!
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
//! length-prefixed binary handshake on one bidirectional stream:
//!
//! ```text
//! client → host Hello { abi_version }
//! host → client Welcome { abi_version, session: full data-plane Config + mode + UDP port }
//! client → host Start { client_udp_port }
//! ```
//!
//! after which both sides bring up a [`crate::session::Session`] over a plain
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
//! plane is exactly the hardened core `Session`.
//!
//! Transport security: the host presents a long-lived self-signed certificate
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
//! reported back for persisting). The data plane adds AES-GCM on top.
//! All integers little-endian; every message is `u16 length || payload`.
//!
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
//! 0xC90xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
//! existing `crate::quic::X` paths compile unchanged.
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
pub const MAGIC: &[u8; 4] = b"PKF1";
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
/// byte sits where a type byte would) can never be misparsed as a control message, and
/// vice-versa, regardless of field values.
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
mod clock;
mod datagram;
mod msgs;
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
pub mod endpoint;
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
pub mod io;
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
/// the identities are ordered; each side binds **both** certificate fingerprints as the
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
/// cannot reach a shared key).
pub mod pake;
pub use clock::*;
pub use datagram::*;
pub use msgs::*;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
use crate::error::{PunktfunkError, Result};
use hmac::{Hmac, Mac};
use spake2::{Ed25519Group, Identity, Password, Spake2};
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
pub struct PairingPake {
state: Spake2<Ed25519Group>,
transcript: Vec<u8>,
}
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
/// client passes what it observed via TOFU; the host passes its own + the client's
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
pub fn start(
is_client: bool,
pin: &str,
client_fp: &[u8; 32],
host_fp: &[u8; 32],
) -> (PairingPake, Vec<u8>) {
let pw = Password::new(pin.as_bytes());
let id_client = Identity::new(client_fp);
let id_host = Identity::new(host_fp);
let (state, msg) = if is_client {
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
} else {
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
};
let mut transcript = Vec::with_capacity(64);
transcript.extend_from_slice(client_fp);
transcript.extend_from_slice(host_fp);
(PairingPake { state, transcript }, msg)
}
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
let mut mac =
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
mac.update(label);
mac.update(transcript);
mac.finalize().into_bytes().into()
}
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
/// our recomputed tag the same way.
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
pub struct Confirmations {
/// MAC the host sends (client verifies).
pub host: [u8; 32],
/// MAC the client sends (host verifies).
pub client: [u8; 32],
}
impl PairingPake {
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
/// *different* key, so the confirmation MACs simply won't match).
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
let key = self
.state
.finish(peer_msg)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(Confirmations {
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
})
}
}
/// Constant-time tag comparison for the confirmation step.
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
ct_eq(expected, got)
}
File diff suppressed because it is too large Load Diff
+173 -21
View File
@@ -16,6 +16,7 @@ use crate::input::InputEvent;
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES}; use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
use crate::stats::{Stats, StatsCounters}; use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport; use crate::transport::Transport;
use zerocopy::IntoBytes;
/// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder. /// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder.
pub struct Frame { pub struct Frame {
@@ -166,18 +167,57 @@ impl Session {
"seal_frame called on a client session", "seal_frame called on a client session",
)); ));
} }
let packets = self // Packetize straight into the pooled wire buffers (reused across frames via
.packetizer // `reclaim_wires`) and seal each in place: the plaintext `header ++ shard` is written
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?; // once, at its final wire offset — no intermediate per-packet Vec at all. Byte-identical
StatsCounters::add(&self.stats.frames_submitted, 1); // to the wrapper (`packetize` + seal) path: same plaintext, same emission order, and the
// Reuse the wire-buffer pool the caller returns via `reclaim_wires`: one buffer per packet, // nonce counter advances per emitted packet exactly as before (pinned by the
// sealed in place — after warmup there is no per-packet ciphertext/wire allocation. (`wires` // wire-equivalence tests below). Destructure into disjoint field borrows first — the
// is a local, so `seal_into`'s `&mut self` doesn't alias the `&mut` iteration over it.) // emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
let mut wires = std::mem::take(&mut self.wire_pool); let Session {
wires.resize_with(packets.len(), Vec::new); packetizer,
for (wire, pkt) in wires.iter_mut().zip(packets.iter()) { coder,
self.seal_into(pkt, wire)?; crypto,
next_seq,
wire_pool,
..
} = self;
let mut wires = std::mem::take(wire_pool);
let mut used = 0usize;
let result = packetizer.packetize_each(data, pts_ns, user_flags, coder.as_ref(), {
let wires = &mut wires;
let used = &mut used;
move |hdr, body| {
if *used == wires.len() {
wires.push(Vec::new());
} }
let wire = &mut wires[*used];
*used += 1;
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
wire.clear();
match crypto {
Some(c) => {
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
c.seal_in_place(seq, &mut wire[8..])?;
}
None => {
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
}
}
Ok(())
}
});
result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did.
wires.truncate(used);
StatsCounters::add(&self.stats.frames_submitted, 1);
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum(); let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64); StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
StatsCounters::add(&self.stats.bytes_sent, bytes); StatsCounters::add(&self.stats.bytes_sent, bytes);
@@ -296,24 +336,42 @@ impl Session {
if len > MAX_DATAGRAM_BYTES { if len > MAX_DATAGRAM_BYTES {
continue; continue;
} }
let pkt = match self.open_from_wire(&self.recv_scratch[i][..len]) { // Open in place inside the ring buffer — no per-datagram allocation at line rate
Ok(p) => p, // (~125k pkt/s at 1 Gbps; the recv ring killed the recv alloc, this kills the decrypt
Err(_) => continue, // one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
// `recv_scratch` alive across the replay/reassembler calls below.
let (pkt_range, seq) = match &self.crypto {
Some(c) => {
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
if len < 8 + crate::crypto::TAG_LEN {
continue;
}
let seq = u64::from_be_bytes(self.recv_scratch[i][..8].try_into().unwrap());
match c.open_in_place(seq, &mut self.recv_scratch[i][8..len]) {
Ok(n) => (8..8 + n, Some(seq)),
Err(_) => continue, // undecryptable noise — drop, keep draining
}
}
None => (0..len, None),
}; };
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated // Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
// sequence was already seen. Video also dedups per-frame downstream, but filtering here // sequence was already seen. Video also dedups per-frame downstream, but filtering here
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded. // is uniform and cheap.
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) { if let (Some(w), Some(seq)) = (self.replay.as_mut(), seq) {
if !w.accept(seq) {
StatsCounters::add(&self.stats.packets_dropped, 1); StatsCounters::add(&self.stats.packets_dropped, 1);
continue; continue;
} }
}
let pkt = &self.recv_scratch[i][pkt_range];
StatsCounters::add(&self.stats.packets_received, 1); StatsCounters::add(&self.stats.packets_received, 1);
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64); StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
// The reassembler validates the packet via its parsed header (`magic`), // The reassembler validates the packet via its parsed header (`magic`),
// ignoring anything that isn't a well-formed video packet. // ignoring anything that isn't a well-formed video packet.
if let Some(frame) = self if let Some(frame) = self
.reassembler .reassembler
.push(&pkt, self.coder.as_ref(), &self.stats)? .push(pkt, self.coder.as_ref(), &self.stats)?
{ {
StatsCounters::add(&self.stats.frames_completed, 1); StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(frame); return Ok(frame);
@@ -387,10 +445,14 @@ fn seq_of(wire: &[u8]) -> u64 {
} }
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per /// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the /// datagram, so this must cover the reassembler's 120 ms loss window
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded /// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
/// for the sparse input stream — while bounding how far back a replay could hide. /// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
const REPLAY_WINDOW: u64 = 4096; /// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
/// could hide; the bitmap costs 4 KiB per session.
const REPLAY_WINDOW: u64 = 32768;
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize; const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts /// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
@@ -469,6 +531,96 @@ impl ReplayWindow {
} }
} }
#[cfg(test)]
mod wire_equivalence_tests {
use super::*;
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
use crate::transport::loopback_pair;
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
Config {
role: Role::Host,
phase: match scheme {
FecScheme::Gf8 => ProtocolPhase::P1GameStream,
FecScheme::Gf16 => ProtocolPhase::P2Punktfunk,
},
fec: FecConfig {
scheme,
fec_percent,
max_data_per_block: 8,
},
shard_payload: 64,
max_frame_bytes: 8 * 1024 * 1024,
encrypt,
key: [7u8; 16],
salt: [3, 1, 4, 1],
loopback_drop_period: 0,
}
}
fn host_session(cfg: Config) -> Session {
let (h, _c) = loopback_pair(0, 0);
Session::new(cfg, Box::new(h)).unwrap()
}
/// The reference wire path: build owned packets via the `packetize` wrapper, then seal
/// each into its own buffer — the pre-zero-copy implementation of `seal_frame`, spelled
/// out with the session's own private pieces so the two paths share nothing but state.
fn seal_via_wrapper(sess: &mut Session, frame: &[u8], pts_ns: u64, flags: u32) -> Vec<Vec<u8>> {
let packets = sess
.packetizer
.packetize(frame, pts_ns, flags, sess.coder.as_ref())
.unwrap();
let mut wires = Vec::new();
for pkt in &packets {
let mut wire = Vec::new();
sess.seal_into(pkt, &mut wire).unwrap();
wires.push(wire);
}
wires
}
/// `seal_frame`'s packetize-straight-into-the-wire-pool path must produce byte-identical
/// sealed output to the wrapper path (same plaintext = header ++ shard, same nonce
/// sequence) — for multi-block frames, partial tail shards, exact-multiple frames, the
/// empty frame, fec 0%/50%, both schemes, crypto on and off (plan §1.4).
#[test]
fn zero_copy_seal_matches_wrapper_path() {
for scheme in [FecScheme::Gf8, FecScheme::Gf16] {
for fec_percent in [0u8, 50] {
for encrypt in [true, false] {
let mut opt = host_session(host_cfg(scheme, fec_percent, encrypt));
let mut refr = host_session(host_cfg(scheme, fec_percent, encrypt));
// shard_payload 64 × max_data_per_block 8: >512 bytes spans FEC blocks.
let frames: Vec<Vec<u8>> = vec![
pattern(3000), // multi-block + partial tail shard
pattern(1024), // exact multiple (2 full blocks)
pattern(100), // single block, partial tail
Vec::new(), // empty frame → 1 zeroed shard
pattern(64), // exactly one full shard
];
for (i, frame) in frames.iter().enumerate() {
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
let want = seal_via_wrapper(&mut refr, frame, 1000 * i as u64, i as u32);
assert_eq!(
got, want,
"wire mismatch: scheme={scheme:?} fec={fec_percent}% encrypt={encrypt} frame#{i}"
);
// Return the buffers so later frames exercise the pooled-reuse path
// (including a bigger frame after a smaller one and vice versa).
opt.reclaim_wires(got);
}
}
}
}
}
fn pattern(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 31 + 7) as u8).collect()
}
}
#[cfg(test)] #[cfg(test)]
mod replay_tests { mod replay_tests {
use super::*; use super::*;
+3 -1
View File
@@ -3,10 +3,12 @@
mod loopback; mod loopback;
mod qos; mod qos;
#[cfg(windows)]
mod qos_windows;
mod udp; mod udp;
pub use loopback::{loopback_pair, LoopbackTransport}; pub use loopback::{loopback_pair, LoopbackTransport};
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass}; pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass, QosFlow};
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected /// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
/// socket (the GameStream video sender) rather than going through [`UdpTransport`]. /// socket (the GameStream video sender) rather than going through [`UdpTransport`].
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+34 -10
View File
@@ -8,9 +8,10 @@
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it //! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It //! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client //! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on //! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the //! ISPs/routers. On Windows a plain `IP_TOS` is silently stripped from the wire, so the marking
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows). //! goes through qWAVE flows instead (see [`super::qos_windows`]) — the caller holds the returned
//! [`QosFlow`] guard for as long as the socket sends media.
use std::net::UdpSocket; use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -60,7 +61,7 @@ pub enum MediaClass {
impl MediaClass { impl MediaClass {
/// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte). /// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte).
const fn dscp(self) -> u32 { pub(super) const fn dscp(self) -> u32 {
match self { match self {
MediaClass::Video => 40, // CS5 MediaClass::Video => 40, // CS5
MediaClass::Audio => 48, // CS6 MediaClass::Audio => 48, // CS6
@@ -92,20 +93,43 @@ pub(crate) fn dscp_enabled() -> bool {
} }
} }
/// RAII token for a socket's QoS marking. On Windows it is the qWAVE flow membership
/// ([`super::qos_windows::QosFlow`]) — dropping it removes the marking, so hold it for as long
/// as the socket sends media. Elsewhere DSCP rides the socket option itself and the token is
/// inert (and never constructed — [`set_media_qos`] returns `None`).
#[cfg(windows)]
pub use super::qos_windows::QosFlow;
#[cfg(not(windows))]
pub struct QosFlow {
_never: std::convert::Infallible,
}
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op /// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS /// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
/// is a nicety, not required for correctness. /// is a nicety, not required for correctness.
/// ///
/// IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't tagged. On Windows /// The socket must already be `connect`ed (Windows derives the qWAVE flow from the connected
/// the `IP_TOS` set succeeds but the OS doesn't tag the wire without a qWAVE policy (follow-up). /// 5-tuple). IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) { /// tagged. Returns the [`QosFlow`] guard on Windows — keep it alive with the socket; `None`
if dscp_enabled() { /// elsewhere (the marking is a plain socket option) and whenever a step refused.
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
if !dscp_enabled() {
return None;
}
#[cfg(windows)]
{
super::qos_windows::add_media_flow(socket, class)
}
#[cfg(not(windows))]
{
apply_media_qos(socket, class); apply_media_qos(socket, class);
None
} }
} }
/// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable /// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable
/// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues). /// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues).
#[cfg_attr(windows, allow(dead_code))]
fn apply_media_qos(socket: &UdpSocket, class: MediaClass) { fn apply_media_qos(socket: &UdpSocket, class: MediaClass) {
let sock = socket2::SockRef::from(socket); let sock = socket2::SockRef::from(socket);
// DSCP occupies the high 6 bits of the TOS byte → shift left 2. // DSCP occupies the high 6 bits of the TOS byte → shift left 2.
@@ -143,8 +167,8 @@ mod tests {
fn qos_and_buffer_growth_are_best_effort_and_never_panic() { fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless. // No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
set_media_qos(&sock, MediaClass::Video); assert!(set_media_qos(&sock, MediaClass::Video).is_none());
set_media_qos(&sock, MediaClass::Audio); assert!(set_media_qos(&sock, MediaClass::Audio).is_none());
grow_socket_buffers(&sock); grow_socket_buffers(&sock);
} }
@@ -0,0 +1,135 @@
//! qWAVE (qos2.h) DSCP marking — the Windows path of [`super::qos::set_media_qos`].
//!
//! On Windows a plain `IP_TOS` setsockopt succeeds but the stack strips the mark from the wire:
//! marking requires membership in a qWAVE flow, which is how Apollo/Sunshine tag (qwave.dll).
//! [`QOSAddSocketToFlow`] with a traffic type yields the OS default marking (AudioVideo → DSCP
//! 40, Voice → 56, both WMM-mapped); the follow-up `QOSSetFlow(QOSSetOutgoingDSCPValue)` pins
//! the exact CS5/CS6 code points the other platforms mark. The pin needs an elevated process or
//! the "allow non-admin DSCP" group policy and silently keeps the traffic-type default
//! otherwise — the host runs as the SYSTEM service (`PunktfunkHost`), so the exact-DSCP path
//! applies exactly where it matters (the video egress); user-mode clients keep traffic-type
//! defaults (still WMM-useful).
//!
//! Same contract as the rest of [`super::qos`]: opt-in (`dscp_enabled`), and every step
//! debug-logs and continues — QoS is a nicety, never required for correctness.
use super::qos::MediaClass;
use std::net::UdpSocket;
use std::os::windows::io::AsRawSocket;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{GetLastError, HANDLE};
use windows_sys::Win32::NetworkManagement::QoS::{
QOSAddSocketToFlow, QOSCreateHandle, QOSRemoveSocketFromFlow, QOSSetFlow,
QOSSetOutgoingDSCPValue, QOSTrafficTypeAudioVideo, QOSTrafficTypeVoice, QOS_NON_ADAPTIVE_FLOW,
QOS_VERSION,
};
/// The process-wide qWAVE handle (`QOSCreateHandle` once, cached). `None` = qWAVE unavailable
/// (e.g. the QWAVE service is disabled) — every flow request then no-ops. Deliberately never
/// closed: it lives as long as the process, like the media sockets whose flows it carries.
fn qos_handle() -> Option<HANDLE> {
static SLOT: OnceLock<Option<usize>> = OnceLock::new();
SLOT.get_or_init(|| {
let version = QOS_VERSION {
MajorVersion: 1,
MinorVersion: 0,
};
let mut handle: HANDLE = std::ptr::null_mut();
// SAFETY: both pointers are valid for the duration of the synchronous call.
if unsafe { QOSCreateHandle(&version, &mut handle) } == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
"QOSCreateHandle failed — qWAVE DSCP marking unavailable"
);
None
} else {
Some(handle as usize)
}
})
.map(|h| h as HANDLE)
}
/// RAII qWAVE flow membership: while held, the socket's egress carries the flow's marking;
/// dropping removes the socket from the flow. (Closing the socket also removes it implicitly —
/// the guard makes teardown explicit and ordered, and must outlive the socket's traffic.)
pub struct QosFlow {
/// Raw `SOCKET` value only — never dereferenced; qWAVE tolerates an already-closed socket
/// on remove (the error is deliberately ignored).
socket: u64,
flow_id: u32,
}
impl Drop for QosFlow {
fn drop(&mut self) {
if let Some(handle) = qos_handle() {
// SAFETY: handle/flow_id came from the successful add; a stale socket just errors.
unsafe { QOSRemoveSocketFromFlow(handle, self.socket as _, self.flow_id, 0) };
}
}
}
/// Put a **connected** media socket on a qWAVE flow of its class (video →
/// `QOSTrafficTypeAudioVideo`, audio → `QOSTrafficTypeVoice`), then best-effort pin the exact
/// DSCP the other platforms mark (CS5 = 40 / CS6 = 48). Returns the flow guard, or `None` when
/// a required step refused (logged at debug).
pub(super) fn add_media_flow(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
let handle = qos_handle()?;
let traffic_type = match class {
MediaClass::Video => QOSTrafficTypeAudioVideo,
MediaClass::Audio => QOSTrafficTypeVoice,
};
let raw = socket.as_raw_socket();
let mut flow_id = 0u32;
// NULL destination = derive the flow's 5-tuple from the connected socket (every media
// socket is `connect`ed before it is tagged).
// SAFETY: the socket is live for the call; `flow_id` is a valid out-pointer.
let ok = unsafe {
QOSAddSocketToFlow(
handle,
raw as _,
std::ptr::null(),
traffic_type,
QOS_NON_ADAPTIVE_FLOW,
&mut flow_id,
)
};
if ok == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
?class,
"QOSAddSocketToFlow failed — DSCP marking skipped"
);
return None;
}
// Construct the guard FIRST so an early return below still removes the flow membership.
// (`raw` is already `u64` — std's `RawSocket` — so no cast; win64 clippy denies same-type casts.)
let flow = QosFlow {
socket: raw,
flow_id,
};
// Pin the exact code point. Succeeds for elevated processes or under the "allow non-admin
// DSCP" policy; otherwise the traffic-type default marking stands (40 / 56 — WMM-useful).
let dscp: u32 = class.dscp();
// SAFETY: `buffer` points at 4 valid bytes for the synchronous (no OVERLAPPED) call.
let ok = unsafe {
QOSSetFlow(
handle,
flow_id,
QOSSetOutgoingDSCPValue,
4,
&dscp as *const u32 as *const _,
0,
std::ptr::null_mut(),
)
};
if ok == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
?class,
"QOSSetFlow(OutgoingDSCPValue) refused — traffic-type default marking stands"
);
} else {
tracing::debug!(?class, dscp, flow_id, "qWAVE flow pinned to exact DSCP");
}
Some(flow)
}
+18 -5
View File
@@ -446,6 +446,9 @@ pub fn spawn_data_punch(sock: UdpSocket, stop: std::sync::Arc<std::sync::atomic:
} }
pub struct UdpTransport { pub struct UdpTransport {
/// qWAVE flow guard (Windows, opt-in DSCP): declared before `socket` so drop order removes
/// the flow membership before the socket closes. Always `None` off-Windows.
_qos_flow: Option<super::qos::QosFlow>,
socket: UdpSocket, socket: UdpSocket,
} }
@@ -464,10 +467,14 @@ impl UdpTransport {
socket.connect(peer)?; socket.connect(peer)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
// The native data plane is video-dominant — tag it as the video class (opt-in via // The native data plane is video-dominant — tag it as the video class (opt-in via
// PUNKTFUNK_DSCP). Each end marks its own egress. // PUNKTFUNK_DSCP). Each end marks its own egress; the socket is connected by now, as
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video); // the Windows qWAVE flow requires.
let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
socket.set_nonblocking(true)?; socket.set_nonblocking(true)?;
Ok(UdpTransport { socket }) Ok(UdpTransport {
_qos_flow: qos_flow,
socket,
})
} }
/// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN /// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN
@@ -524,9 +531,15 @@ impl UdpTransport {
socket.connect(target.as_deref().unwrap_or(fallback_peer))?; socket.connect(target.as_deref().unwrap_or(fallback_peer))?;
socket.set_read_timeout(None)?; socket.set_read_timeout(None)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video); let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
socket.set_nonblocking(true)?; socket.set_nonblocking(true)?;
Ok((UdpTransport { socket }, punched)) Ok((
UdpTransport {
_qos_flow: qos_flow,
socket,
},
punched,
))
} }
/// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`]) /// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`])
+2 -1
View File
@@ -205,7 +205,8 @@ proptest! {
let data: Vec<Vec<u8>> = (0..k) let data: Vec<Vec<u8>> = (0..k)
.map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect()) .map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect())
.collect(); .collect();
let recovery = coder.encode(&data, m).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = coder.encode(&refs, m).unwrap();
let mut received: Vec<Option<Vec<u8>>> = let mut received: Vec<Option<Vec<u8>>> =
data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect(); data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect();
+33 -16
View File
@@ -37,12 +37,19 @@ pub enum PixelFormat {
/// `P010` (DXGI `P010`): 10-bit BT.2020 PQ limited-range YUV 4:2:0. HDR analogue of [`Nv12`]: /// `P010` (DXGI `P010`): 10-bit BT.2020 PQ limited-range YUV 4:2:0. HDR analogue of [`Nv12`]:
/// video-processor output for HEVC Main10 / HDR10, handed to NVENC as `YUV420_10BIT`. /// video-processor output for HEVC Main10 / HDR10, handed to NVENC as `YUV420_10BIT`.
P010, P010,
/// Planar 8-bit YUV **4:4:4** (BT.709; range per `PUNKTFUNK_444_FULLRANGE`). Produced by the
/// Linux zero-copy worker's GPU convert for a 4:4:4 session ([`FramePayload::Cuda`] with
/// `DeviceBuffer::yuv444` — three full-res planes stacked in one allocation); NVENC encodes
/// it natively under the Range-Extensions profile. Never a CPU payload.
Yuv444,
} }
impl PixelFormat { impl PixelFormat {
pub fn bytes_per_pixel(self) -> usize { pub fn bytes_per_pixel(self) -> usize {
match self { match self {
PixelFormat::Rgb | PixelFormat::Bgr => 3, PixelFormat::Rgb | PixelFormat::Bgr => 3,
// Three full-res 1-byte planes (GPU-resident only; no CPU payload carries this).
PixelFormat::Yuv444 => 3,
_ => 4, _ => 4,
} }
} }
@@ -62,10 +69,13 @@ pub struct OutputFormat {
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source). /// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
/// `false` = 8-bit SDR. /// `false` = 8-bit SDR.
pub hdr: bool, pub hdr: bool,
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB** /// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
/// (`Bgra` / `Rgb10a2`), NOT the subsampled `Nv12`/`P010` the Windows video-engine path produces by /// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
/// default — because 4:4:4 can only be recovered from a full-chroma source. NVENC then does the /// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
/// RGB→YUV444 CSC at encode (chroma_format_idc=3). `false` on every 4:2:0 session. /// on-glass (RTX 5070 Ti): ARGB + `chromaFormatIDC=3` yields TRUE 4:4:4 and the conversion
/// follows the configured VUI matrix (BT.709 limited since the VUI is always written). On
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
/// 4:2:0 session.
pub chroma_444: bool, pub chroma_444: bool,
} }
@@ -378,11 +388,12 @@ pub fn capture_virtual_output(
_capture: crate::session_plan::CaptureBackend, _capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own pixel // The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own pixel
// format, so only `want.gpu` is honored here: it gates GPU zero-copy capture (the capture backend // format, so `want.gpu` gates GPU zero-copy capture (the capture backend is always the portal —
// is always the portal — the `CaptureBackend` arg is a Windows-only dispatch). `gpu = false` // the `CaptureBackend` arg is a Windows-only dispatch) and `want.chroma_444` selects the
// (a 4:4:4 NVENC session) forces the CPU mmap path so the encoder gets CPU-resident RGB to swscale // worker's planar-YUV444 GPU convert for tiled dmabufs (the 4:4:4 zero-copy path). `gpu =
// into YUV444P — otherwise it would receive CUDA frames and bail. // false` (4:4:4 without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident
linux::PortalCapturer::from_virtual_output(vout, want.gpu) // RGB to swscale into YUV444P.
linux::PortalCapturer::from_virtual_output(vout, want.gpu, want.chroma_444)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
@@ -404,10 +415,11 @@ pub fn capture_virtual_output(
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's // Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display // swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and // is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the // selects the per-frame conversion (FP16 → P010 vs BGRA → NV12, or BGRA → AYUV for a
// keepalive (it owns the virtual display). There is NO fallback (DDA + the WGC relay were removed): // `want.chroma_444` SDR session). `IddPushCapturer` takes the keepalive (it owns the virtual
// if it can't open or the driver doesn't attach, the session fails cleanly and the client reconnects. // display). There is NO fallback (DDA + the WGC relay were removed): if it can't open or the
idd_push::IddPushCapturer::open(target, pref, want.hdr, keep) // driver doesn't attach, the session fails cleanly and the client reconnects.
idd_push::IddPushCapturer::open(target, pref, want.hdr, want.chroma_444, keep)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
} }
@@ -422,9 +434,14 @@ pub(crate) fn capturer_supports_444() -> bool {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444() -> bool {
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows // IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0. // VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4
false // (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
// display can't be known here (the virtual display's mode settles after the Welcome); that
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444() -> bool {
+54 -20
View File
@@ -101,30 +101,37 @@ impl PortalCapturer {
"ScreenCast portal session started; connecting PipeWire" "ScreenCast portal session started; connecting PipeWire"
); );
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before. // This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
Ok(spawn_pipewire(Some(fd), node_id, None, true)?.into_capturer(node_id, None)) Ok(spawn_pipewire(Some(fd), node_id, None, true, false)?.into_capturer(node_id, None))
} }
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]): /// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
/// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and /// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and
/// take ownership of its keepalive so the output lives exactly as long as this capturer. This /// take ownership of its keepalive so the output lives exactly as long as this capturer. This
/// is how the client's requested resolution becomes the captured resolution without scaling. /// is how the client's requested resolution becomes the captured resolution without scaling.
/// `allow_zerocopy` mirrors [`OutputFormat::gpu`](crate::capture::OutputFormat): `false` forces the /// `allow_zerocopy` mirrors [`OutputFormat::gpu`](crate::capture::OutputFormat): `false` forces
/// CPU mmap path (a 4:4:4 NVENC session needs CPU-resident RGB), `true` keeps the GPU zero-copy /// the CPU mmap path, `true` keeps the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`.
/// path subject to `PUNKTFUNK_ZEROCOPY`. /// `want_444` (a 4:4:4 session) makes the zero-copy worker convert tiled dmabufs to planar
/// YUV444 on the GPU instead of NV12/RGB.
pub fn from_virtual_output( pub fn from_virtual_output(
vout: crate::vdisplay::VirtualOutput, vout: crate::vdisplay::VirtualOutput,
allow_zerocopy: bool, allow_zerocopy: bool,
want_444: bool,
) -> Result<PortalCapturer> { ) -> Result<PortalCapturer> {
tracing::info!( tracing::info!(
node_id = vout.node_id, node_id = vout.node_id,
allow_zerocopy, allow_zerocopy,
want_444,
"connecting PipeWire to virtual output" "connecting PipeWire to virtual output"
); );
let node_id = vout.node_id; let node_id = vout.node_id;
Ok( Ok(spawn_pipewire(
spawn_pipewire(vout.remote_fd, node_id, vout.preferred_mode, allow_zerocopy)? vout.remote_fd,
.into_capturer(node_id, Some(vout.keepalive)), node_id,
) vout.preferred_mode,
allow_zerocopy,
want_444,
)?
.into_capturer(node_id, Some(vout.keepalive)))
} }
} }
@@ -173,11 +180,12 @@ fn spawn_pipewire(
node_id: u32, node_id: u32,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
// Allow GPU zero-copy capture (dmabuf→CUDA/VA). `false` forces the CPU mmap path even when // Allow GPU zero-copy capture (dmabuf→CUDA/VA). `false` forces the CPU mmap path even when
// `PUNKTFUNK_ZEROCOPY` is set — a 4:4:4 NVENC session needs CPU-resident RGB (the encoder // `PUNKTFUNK_ZEROCOPY` is set (the session plan passes `gpu = false` when 4:4:4 has no
// swscales RGB→YUV444P; `hevc_nvenc` can't 4:4:4 from a CUDA RGB surface), so the session plan // zero-copy convert available — see `SessionPlan::output_format`).
// passes `gpu = false` for it. Without this, a 4:4:4 session under `PUNKTFUNK_ZEROCOPY=1` would
// get CUDA frames and the encoder would bail (`want_444 && cuda`).
allow_zerocopy: bool, allow_zerocopy: bool,
// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
want_444: bool,
) -> Result<PwHandles> { ) -> Result<PwHandles> {
// Frames flow from the pipewire thread over a small bounded channel. // Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8); let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
@@ -212,6 +220,7 @@ fn spawn_pipewire(
streaming_cb, streaming_cb,
broken_cb, broken_cb,
zerocopy, zerocopy,
want_444,
preferred, preferred,
quit_rx, quit_rx,
) { ) {
@@ -609,6 +618,10 @@ mod pipewire {
/// `PUNKTFUNK_NV12`: on the tiled EGL/GL zero-copy path, convert to NV12 on the GPU and feed /// `PUNKTFUNK_NV12`: on the tiled EGL/GL zero-copy path, convert to NV12 on the GPU and feed
/// NVENC native YUV (Tier 2A). Off ⇒ the BGRx path is unchanged. /// NVENC native YUV (Tier 2A). Off ⇒ the BGRx path is unchanged.
nv12: bool, nv12: bool,
/// 4:4:4 session: on the tiled EGL/GL zero-copy path, convert to planar YUV444 on the GPU
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
/// `nv12` (a 4:4:4 session must never subsample).
yuv444: bool,
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`). /// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
dbg_log_n: u64, dbg_log_n: u64,
} }
@@ -1012,13 +1025,19 @@ mod pipewire {
// sample LINEAR). // sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier); let modifier = (ud.modifier != 0).then_some(ud.modifier);
if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) { if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) {
// NV12 convert (Tier 2A) only on the tiled EGL/GL path (`modifier.is_some()`): // GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4
// produce native YUV so NVENC skips its internal RGB→YUV CSC. The LINEAR/Vulkan // session gets the planar-YUV444 convert (full chroma, takes precedence over
// (gamescope) path stays RGB — its convert isn't wired here. When NV12 is // NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12
// produced the frame's format is reported as `Nv12` so the encoder opens native. // both feed NVENC native YUV so it skips its internal RGB→YUV CSC. The
let nv12 = ud.nv12 && modifier.is_some(); // LINEAR/Vulkan (gamescope) path stays RGB — its converts aren't wired here;
// a 4:4:4 session on LINEAR frames falls to the encoder's clear-error path
// (`want_444` with an RGB CUDA payload) rather than silently subsampling.
let yuv444 = ud.yuv444 && modifier.is_some();
let nv12 = ud.nv12 && !yuv444 && modifier.is_some();
let imported = if let Some(m) = modifier { let imported = if let Some(m) = modifier {
if nv12 { if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
} else if nv12 {
importer.import_nv12(&plane, w as u32, h as u32, fourcc, Some(m)) importer.import_nv12(&plane, w as u32, h as u32, fourcc, Some(m))
} else { } else {
importer.import(&plane, w as u32, h as u32, fourcc, Some(m)) importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
@@ -1038,6 +1057,7 @@ mod pipewire {
h, h,
modifier = ud.modifier, modifier = ud.modifier,
nv12, nv12,
yuv444,
"zero-copy: dmabuf imported to CUDA (no CPU copy)" "zero-copy: dmabuf imported to CUDA (no CPU copy)"
); );
} }
@@ -1049,7 +1069,13 @@ mod pipewire {
width: w as u32, width: w as u32,
height: h as u32, height: h as u32,
pts_ns, pts_ns,
format: if nv12 { PixelFormat::Nv12 } else { fmt }, format: if yuv444 {
PixelFormat::Yuv444
} else if nv12 {
PixelFormat::Nv12
} else {
fmt
},
payload: FramePayload::Cuda(devbuf), payload: FramePayload::Cuda(devbuf),
}); });
return; return;
@@ -1225,6 +1251,8 @@ mod pipewire {
streaming: Arc<AtomicBool>, streaming: Arc<AtomicBool>,
broken: Arc<AtomicBool>, broken: Arc<AtomicBool>,
zerocopy: bool, zerocopy: bool,
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
want_444: bool,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
quit_rx: pw::channel::Receiver<()>, quit_rx: pw::channel::Receiver<()>,
) -> Result<()> { ) -> Result<()> {
@@ -1332,7 +1360,12 @@ mod pipewire {
} }
); );
} }
if want_dmabuf && !vaapi_passthrough && crate::zerocopy::nv12_enabled() { if want_dmabuf && !vaapi_passthrough && want_444 {
tracing::info!(
"4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \
NVENC fed native full-chroma YUV, no CPU pixel path"
);
} else if want_dmabuf && !vaapi_passthrough && crate::zerocopy::nv12_enabled() {
tracing::info!( tracing::info!(
"PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \ "PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \
fed native YUV (no internal RGBYUV CSC)" fed native YUV (no internal RGBYUV CSC)"
@@ -1352,6 +1385,7 @@ mod pipewire {
importer, importer,
vaapi_passthrough, vaapi_passthrough,
nv12: crate::zerocopy::nv12_enabled(), nv12: crate::zerocopy::nv12_enabled(),
yuv444: want_444,
dbg_log_n: 0, dbg_log_n: 0,
}; };
@@ -464,21 +464,25 @@ float main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
} }
"; ";
/// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV). Averages /// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV).
/// the 2x2 scRGB source footprint of this chroma sample (box filter) IN scRGB-linear space before the /// **Left-cosited** (H.273 chroma_loc type 0 — the default every decoder infers when
/// PQ encode, then forms Cb/Cr from the averaged-then-PQ-encoded RGB. `inv_src` = (1/srcW, 1/srcH). /// chroma_loc_info is unsignaled, and what the clients' sampling corrections assume): the chroma
/// sample sits ON the even luma column, vertically centered between its two rows — so the filter
/// is the 2-row average of that ONE column, IN scRGB-linear space before the PQ encode, then
/// Cb/Cr from the averaged-then-PQ-encoded RGB. (The old 2×2 box was CENTER-sited — a
/// half-luma-pixel chroma shift against what decoders reconstruct; the narrow column decimation
/// also keeps desktop text/edge chroma crisp, and block-uniform inputs stay exact for
/// `hdr_p010_selftest`.) `inv_src` = (1/srcW, 1/srcH).
const HDR_P010_UV_PS: &str = r" const HDR_P010_UV_PS: &str = r"
#include_common #include_common
cbuffer C : register(b0) { float2 inv_src; float2 pad; }; cbuffer C : register(b0) { float2 inv_src; float2 pad; };
float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET { float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
// `uv` is the chroma-sample centre in [0,1]; the 4 co-sited luma texels sit at uv ± half a luma // `uv` is the chroma RT texel centre = the middle of the 2x2 luma block; the left-cosited
// texel in each axis. Average their scRGB (linear) values, then run the SAME PQ/CSC as the Y pass. // target is the block's LEFT column, whose two texel centres sit at uv + (-h.x, ±h.y).
float2 h = inv_src * 0.5; float2 h = inv_src * 0.5;
float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0); float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0);
float3 b = max(tx.Sample(sm, uv + float2( h.x, -h.y)).rgb, 0.0); float3 b = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0);
float3 c = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0); float3 scrgb = (a + b) * 0.5;
float3 d = max(tx.Sample(sm, uv + float2( h.x, h.y)).rgb, 0.0);
float3 scrgb = (a + b + c + d) * 0.25;
float3 nits = scrgb * 80.0; float3 nits = scrgb * 80.0;
float3 lin2020 = mul(BT709_TO_BT2020, nits); float3 lin2020 = mul(BT709_TO_BT2020, nits);
float3 pq = pq_oetf(lin2020 / 10000.0); float3 pq = pq_oetf(lin2020 / 10000.0);

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