57 Commits

Author SHA1 Message Date
enricobuehler f01c5e210c feat(resize/apple): resize overlay — blur + spinner during mid-stream resize
Make a Match-window resize deliberate instead of a stutter: blur the live
stream and show a spinner while the host rebuilds its virtual display +
encoder and VideoToolbox re-inits on the new-mode IDR. No new protocol —
driven entirely by existing client signals.

- ResizeIndicator (pure core, unit-tested): START = follower steering,
  END = a decoded frame at the target size, TIMEOUT = 2.5s safety net for a
  rejected/capped switch that never yields a new-size frame; re-arms only on
  a CHANGED target, not a repeated same-size drag.
- MatchWindowFollower.onResizeTarget fires the instant the window differs
  from the live mode (deduped via lastSteered); a new onDecodedSize callback
  threads each new-mode IDR's coded dims through StreamPump/Stage2Pipeline →
  SessionPresenter → both stream views.
- SessionModel gains @Published resizing (+ resizeTargeted/resizeDecoded, a
  tick on the 1 Hz stats timer, reset on disconnect); ContentView blurs the
  stream 16px and overlays ResizeIndicatorView while resizing (the 32px
  trust-prompt blur is unchanged and takes precedence).

tvOS declares the props but never fires the follower (it drives modes via
AVDisplayManager), so the overlay stays dormant there. Pure core verified on
the Linux toolchain; full AppKit/UIKit build pending on a Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:58:14 +02:00
enricobuehler d294b3923c test(resize): extract + unit-test the live-reconfigure gate (H1 gamescope / H5 per-client-mode)
The Linux §6 on-glass matrix can validate the gamescope must-REJECT behavior only on
native-gamescope hardware (the NVIDIA dev box fails headless GBM allocation — a nested
Hyprland/sway/gamescope output comes up 0×0), so pin the gate down deterministically
instead: extract the inline `live_reconfig_ok` decision into a pure
`reconfig_allowed(compositor, per_client_mode)` and test it — gamescope rejects in every
identity mode, a per-client-mode policy rejects on every backend, and all other
compositors (plus the synthetic protocol-test source) with the default identity accept.

Also fmt-normalizes the re_add block from the prior commit (whitespace only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:56:59 +02:00
enricobuehler 4c18bb80ca feat(resize/win): mid-stream resize on-glass fixes — corrective-ack actual res + monitor re-arrival
On-glass validation on the .173 Windows IDD-push host confirmed the Reconfigure
protocol + host rebuild work end-to-end and genuinely change pixels for an
advertised mode (1920x1080 -> 1280x720: two SPS/IDR sets, ffprobe both res). It
also surfaced two gaps for out-of-EDID-list target modes, both fixed here.

Fix 2 (corrective ack carries the ACTUAL resolution): the H2/H3 corrective ack
recovered only the achieved REFRESH (interval_hz), taking width/height straight
from the request — so when a backend delivered a different RESOLUTION (Windows
pf-vdisplay falling back to its advertised mode) the client was told it got a
size it never received, and by the D2 discipline never re-asked. New
`delivered_mode(frame.{w,h}, interval)` derives the ack from the captured frame's
real dims (what the encoder opened at / the client decodes) in both the success
and rollback branches. Unit-tested.

Fix 1 (reach arbitrary mid-stream modes via monitor RE-ARRIVAL): the pf-vdisplay
driver freezes a monitor's advertised mode list at IOCTL_ADD, and IddCx exposes
no live update-modes DDI, so an in-place ChangeDisplaySettingsExW to a mode not
advertised at arrival returns DISP_CHANGE_BADMODE. The manager's mid-stream
reconfigure now REMOVEs + re-ADDs the driver monitor at the exact new mode,
reusing the slot's stable per-client id (EDID serial / ContainerId) so the OS
keeps identity + saved DPI. The rebuilt Monitor PRESERVES gen (lease/refcount
continuity) and the group restore snapshot; reisolate_after_swap re-isolates the
new target without recapturing it. Host-only — no driver change. One monitor
hotplug per switch (the design's accepted "re-arrival for everything").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:34:22 +02:00
enricobuehler 89ff326ebf feat(resize/apple): Match-window mid-stream resize trigger + settings (C3)
design/midstream-resolution-resize.md Phase 2, Apple client. The stream mode
follows the session window/scene: a windowed macOS window resize or an iPad
Stage Manager / Split View scene change renegotiates the host's virtual
display + encoder via the existing PunktfunkConnection.requestMode, so a
windowed session streams native-resolution pixels instead of scaling.
Decode/present need nothing — VideoToolbox recreates its session on the
keyframe-derived format-description change (§1 table).

- MatchWindowFollower (new): the shared D2 trigger discipline — physical
  pixels even-floored + clamped ≥320×200, debounce to resize-end, ≥1 s
  between requests, skip a size equal to the live mode, request each distinct
  size at most once (stops re-asking a rejected size / looping on a host
  rollback). Pure normalize/request core is unit-tested (MatchWindowTests).
- macOS StreamLayerView: fed from layoutPresenter() (bounds → convertToBacking),
  guarded to once-in-a-window.
- iOS StreamViewController: fed from viewDidLayoutSubviews (bounds × render
  scale); iOS-only (iPhone fullscreen no-ops, tvOS uses AVDisplayManager).
- Settings: "Match window" toggle in the Stream mode section (iOS + macOS),
  DefaultsKey.matchWindow, read per session by the follower.

Verified on a Linux Swift 6.1.2 toolchain (the app target needs AppKit/UIKit,
unavailable here): the real MatchWindowFollower.swift type-checks in Swift 5
mode against a connection stub, and the pure discipline + the follower's
decision path pass a standalone harness (drag-settle + grow → exactly two
switches, refresh preserved, no re-request loop). A full build + on-device run
(macOS window, iPad Stage Manager) remains for a Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:36:03 +02:00
enricobuehler d0d9bd5bfb feat(resize): mid-stream resolution resize — host hardening (H1–H5) + session-binary Match window (C1)
design/midstream-resolution-resize.md Phase 0 + Phase 1.

Host (Phase 0):
- H1/H5: per-backend Reconfigure acceptance gate — reject for gamescope
  (all sub-modes; a resize must never relaunch the title) and under the
  per-client-mode identity policy (a resize would resolve a different
  display slot). Synthetic stays reconfigurable on purpose (the protocol
  test source; the C-ABI roundtrip test rides it). Plus a 500 ms host-side
  min-interval backstop against Reconfigure spam.
- H2: rollback/corrective ack — the data plane reports the mode actually
  live after a failed rebuild (or a refresh the backend capped) through a
  reconfig_result channel; the control task forwards it as a second
  accepted Reconfigured so the client's mode slot self-corrects.
- H3: live stats mode — SendStats reads a packed AtomicU64 (w|h|hz)
  updated on every switch instead of latching the session-start mode.
- H4: registry::retire(gen) — a mode-switch rebuild force-releases the
  superseded Linux display, so linger/forever keep-alive policies don't
  accumulate kept monitors at stale modes. VirtualOutput carries pool_gen
  (fresh AND reused) and the Pipeline tuple threads it to the switch arm.

Client (Phase 1, default off):
- Settings: match_window policy + persisted last window size; exposed as
  the Resolution tri-state (Native / Match window / explicit) in the Skia
  console, GTK and WinUI settings pages.
- pf-presenter: window opens at the persisted size; Hello mode follows the
  window's pixel size; D2 trigger discipline (400 ms debounce to
  resize-end, ≥1 s spacing, even-floor + ≥320×200 clamp, each distinct
  size requested at most once — covers rejects and host rollbacks) as a
  pure, unit-tested decision; HUD line + title refresh on a switch.
- Session binary wires both --connect and --browse paths; the WinUI shell
  is session-always, so this covers Windows too.

Verified: workspace tests + clippy green; synthetic --remode end-to-end;
live session-binary run (window at persisted 1000×600 → Hello 1000×600@60).
On-glass per-backend matrix (Mutter/KWin/gamescope-reject, keep-alive
accumulation) still pending before any default flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:53:47 +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
enricobuehler aaed4380e5 chore(release): bump workspace version to 0.9.1
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 55s
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 2m41s
ci / bench (push) Successful in 5m39s
android-screenshots / screenshots (push) Successful in 3m3s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m15s
decky / build-publish (push) Successful in 17s
android / android (push) Successful in 12m47s
arch / build-publish (push) Successful in 11m58s
windows-host / package (push) Successful in 8m53s
deb / build-publish (push) Successful in 12m37s
ci / rust (push) Successful in 24m59s
release / apple (push) Successful in 12m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
web-screenshots / screenshots (push) Successful in 2m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m18s
linux-client-screenshots / screenshots (push) Successful in 8m34s
apple / screenshots (push) Successful in 5m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m19s
docker / deploy-docs (push) Successful in 22s
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 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
flatpak / build-publish (push) Failing after 2m18s
Release 0.9.1 — a patch release led by three field-reported crash/brick
fixes, a security-review hardening pass, and the tiered stats overlay
landing on every platform.

The regression that forces the release: 0.9.0's stats-HUD display stage
hard-linked an API-33 NDK symbol believed API-26, so the native library
failed to load on every Android < 13 device — "Identity unavailable:
…NativeBridge", dead pair button, no discovery. The callback is now
dlsym-resolved (pre-13 devices simply lose that one HUD row), and a new CI
guard fails the APK build if the native lib ever imports a symbol beyond
the API-28 floor.

Two more crash-class host fixes ride along. GNOME: one console Approve
admitted every parked knock a retrying client had stacked up — three
instant Mutter virtual monitors segfaulted gnome-shell down to the GDM
greeter; knock generations now admit exactly one, Mutter topology
mutations are serialized process-wide, stale session-env is cleared, and
an opt-in PUNKTFUNK_RECOVER_SESSION_CMD hook can revive a dead session on
the next connect. Windows: twin identical GPUs died in a TEX_FAIL retry
loop when the IDD-push ring opened on the wrong twin — the ring now
rebinds once onto the render LUID the driver reports, and GPU inventory
ordering is LUID-sorted and stable for the boot.

Input: held gamepad state is rock-solid — Android pins one qualified pad
device (motion-sensor siblings zeroed axes on every event interleave), and
pad state now travels as sequence-gated idempotent snapshots
(HOST_CAP_GAMEPAD_STATE, negotiated; either end older falls back to the
per-transition path).

Security review findings addressed: sliding-window anti-replay on the
AEAD-authenticated data-plane sequence, 0600 client key + 0700 config dir
(existing stores re-locked), the web console's post-login open redirect
closed, the Windows installer's FFmpeg DLLs SHA-256-pinned, and a fork
guard on PR jobs sharing signing-runner labels.

Features: the 3-tier stats overlay (Compact/Normal/Detailed) ships on
every platform with cycle shortcuts and settings pickers; Apple gains the
experimental stage-3 glass-gated presenter (fixes the hidden 29-30 ms
display-stage queue on 120 Hz ProMotion; stage 2 stays default) and a
morphing, device-corner-concentric overlay card. Plus repo-wide docs sync
and the flatpak CI network hardening.

The [workspace.package] version (inherited by every crate via
version.workspace) is the release being cut; the 14 workspace entries in
Cargo.lock and the api/openapi.json info.version are refreshed to match
(CI builds --locked). Canary derives from the tag as minor+1 of the latest
stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:49:10 +02:00
enricobuehler 3d6c06bb06 fix(ci): flatpak job rides out the runner's network flake — retry every single-shot fetch
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m8s
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 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 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 6m13s
windows-host / package (push) Successful in 7m50s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m0s
docker / deploy-docs (push) Successful in 1m16s
arch / build-publish (push) Successful in 11m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m19s
release / apple (push) Successful in 11m7s
android / android (push) Successful in 13m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m18s
deb / build-publish (push) Successful in 14m52s
apple / screenshots (push) Successful in 5m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m13s
ci / rust (push) Successful in 22m12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m18s
flatpak / build-publish (push) Successful in 5m18s
The flatpak run dies most pushes at `flatpak remote-add` with an instant
"[6] Could not resolve hostname" (runs 8755/8702/8689), 25ms after dnf pulled
329 packages fine — and deploy-docs separately hits "dial tcp: i/o timeout"
to unom-1 (8716/8679). The busy runner drops UDP DNS + TCP dials under
parallel-job load; tools with built-in retries (dnf) ride it out, single-shot
fetches killed the whole 2h build slot.

* scripts/ci/retry.sh: linear-backoff wrapper (5 tries, attempt*10s); stdout
  passes through untouched so $(…) capture works.
* Tooling: retry the flathub remote-add.
* Build split: a retried prefetch phase (--install-deps-only, then
  --download-only for every crate in cargo-sources.json) warms the state dir;
  the long compile then runs with no network left to flake on.
* Seed step: probe (retried) whether the server repo exists — ONLY first
  publish may continue fresh. The old blanket `rsync || warn` swallowed
  transient failures too, producing the single-branch summary that clobbers
  the other channel (the exact bug the step's comment documents).
* Deploy: retry the idempotent ssh/rsync calls to unom-1.
* raw.githubusercontent curl: --retry 5 --retry-all-errors.

docker.yml's deploy-docs has the same disease but sits on drone-ssh; left
for a follow-up. Real root cause is runner-host networking (conntrack /
upstream DNS under burst load) — retries make the jobs indifferent to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:20:40 +02:00
enricobuehler f5e5297a2a style: cargo fmt — settle comment/assert layout the last two fixes left unformatted
`cargo fmt --all --check` on main flags decode.rs (android dlsym fix),
probe/main.rs (0600 key fix), and session.rs (anti-replay tests). The probe
one is restructured rather than machine-formatted: rustfmt wanted the key-
permissions comment gutter-aligned to the trailing `// the certificate is
public` comment, so fold both into one block comment above the write instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:20:40 +02:00
enricobuehler 8fa12167af fix(android): client loads again on Android < 13 — dlsym the API-33 render callback
ci / rust (push) Failing after 41s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 53s
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 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
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 7s
apple / swift (push) Successful in 1m11s
ci / bench (push) Successful in 6m46s
apple / screenshots (push) Successful in 5m45s
android / android (push) Successful in 12m38s
deb / build-publish (push) Successful in 11m58s
arch / build-publish (push) Successful in 14m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m33s
docker / deploy-docs (push) Successful in 7s
0.9.0's HUD display stage hard-linked AMediaCodec_setOnFrameRenderedCallback via
ndk-sys believing it API 26; the symbol is API 33 ("Available since Android T").
A cdylib links fine with the dangling import, so it only exploded at
System.loadLibrary on every pre-13 device: UnsatisfiedLinkError, then every
NativeBridge touch throws NoClassDefFoundError — surfacing as "Identity
unavailable: io.unom.punktfunk.kit.NativeBridge", a dead pair button, and no
discovery (reported on a Y700 / Android 12; 13+ devices unaffected).

- decode::install_render_callback now dlsym-resolves the entry point from
  libmediandk.so, mirroring try_set_frame_rate; on API < 33 the HUD simply has
  no display stage (the pre-0.9.0 behaviour) and the .so loads.
- New scripts/ci/check-android-jni-imports.sh, wired as checkJniImports* gradle
  tasks the APK build depends on: fails the build if libpunktfunk_android.so
  imports any symbol absent from the NDK's API-28 stubs — `--platform 28` never
  enforced this (cdylib links permit undefined symbols), despite the old
  comment's claim. Verified: 3 ABIs clean at the 28 floor, and the check flags
  the known API-28 symbols when pointed at a 27 floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:06:31 +02:00
enricobuehler 21be4fc620 feat(apple): morph + scale-up the stats overlay, inset it to the device corner
windows-drivers / probe-and-proto (push) Successful in 23s
ci / rust (push) Failing after 49s
ci / docs-site (push) Successful in 50s
ci / web (push) Successful in 53s
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 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
apple / swift (push) Successful in 1m13s
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 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 45s
windows-drivers / driver-build (push) Successful in 1m28s
flatpak / build-publish (push) Failing after 56s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (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
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
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Remove the "releases mouse/keyboard" shortcut hint from the stats overlay
  (kept the capture hint). The release shortcut still lives on the Stream menu
  and, on macOS, the start-of-stream banner.
- Animate the overlay: one shared glass card now wraps the tier content, so a
  verbosity change MORPHS the card's frame/shape in place instead of
  cross-fading a fresh card; the enter/exit transition is a scale-up from the
  HUD's own corner. Driven by .animation(.smooth, value: statsVerbosity).
- iOS: give the card a corner radius concentric with the physical display
  (displayCornerRadius - inset, continuous curve) so it no longer cuts into the
  very rounded device corner. Reads _displayCornerRadius via KVC with a safe
  fallback (note: private API — App Store consideration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:04:31 +02:00
enricobuehler e707a962b6 fix(security): anti-replay, 0600 client key, open redirect, supply-chain
Address findings from a repo security review:

- core: add a sliding-window anti-replay filter over the AEAD-authenticated
  sequence in Session (poll_input/poll_frame), closing the input-replay gap the
  data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
  unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
  on Unix (it was world-readable at the umask default), re-locking existing
  stores on load. pf-client-core::trust plus the probe's own identity writer.
  Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
  stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
  `!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
  (were fetched from the rolling `latest` tag unverified); fails closed on a
  re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
  share runner labels with the signing jobs. Definitive fix stays server-side
  (Gitea outside-collaborator approval / isolated PR runners) — see the notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:04:23 +02:00
enricobuehler ef39050dbc docs: repo-wide housekeeping — sync README & docs with the code as shipped
windows-drivers / probe-and-proto (push) Successful in 47s
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m13s
decky / build-publish (push) Successful in 16s
ci / docs-site (push) Successful in 1m19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
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 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
windows-drivers / driver-build (push) Successful in 1m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
ci / bench (push) Successful in 7m18s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 25s
windows-host / package (push) Successful in 8m31s
release / apple (push) Successful in 11m41s
android / android (push) Successful in 13m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m57s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m16s
arch / build-publish (push) Successful in 16m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m6s
deb / build-publish (push) Successful in 17m54s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m27s
ci / rust (push) Successful in 18m23s
apple / screenshots (push) Successful in 5m58s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m24s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m43s
Six parallel audits swept the root docs, docs-site, every per-directory
README, and the packaging docs; every claim below was verified against
the source before editing.

- README: Layout gains the six missing crates (pf-client-core,
  pf-presenter, pf-console-ui, pf-ffvk, pf-driver-proto, punktfunk-tray),
  clients/session, api/ and ci/; Linux/Windows client rows reflect the
  shell + Vulkan-session split and the Vulkan Video -> VAAPI/D3D11VA ->
  software decode chains; the "every client over a C ABI" claim is
  corrected (Rust clients link the core directly); tiered stats overlay
  + console shell noted; Apple row mentions AV1.
- CONTRIBUTING: drop the dead CLAUDE.md link (deliberately untracked);
  point at the README's build/invariants sections. SECURITY: 0.9.0.
- host-cli/pairing: --allow-pairing/--require-pairing are no-op legacy
  names — pairing is required by default, --allow-tofu is the real flag;
  document --data-port and --idle-timeout-ms.
- configuration: document PUNKTFUNK_RECOVER_SESSION_CMD (session-crash
  recovery hook), PUNKTFUNK_MDNS, PUNKTFUNK_DATA_PORT.
- virtual-displays/gnome: GNOME per-client scaling shipped (host-
  persisted) — flip the  to  and describe how it works.
- stats: new "Detail levels" section (Off/Compact/Normal/Detailed +
  per-platform cycle gestures); retire the GTK hand-off note.
- clients/install-client/status/roadmap: decode chains, Windows client
  validation narrowed to HDR-only pending, adaptive bitrate, console
  shell, Apple AV1, Windows host vendor list.
- Sub-READMEs: clients/linux rewritten for the re-architecture; session
  Windows decode rung + d3d11va knob; Windows tiered overlay; Android
  minSdk 28; decky file table; host zerocopy/ path; scripts port
  47992 and steamos-host.md; pf-dualsense source path.
- packaging: canary version bases are tag-derived (<next-minor> via
  pf-version.sh/.ps1), codecs-extra not ffmpeg-full, document the
  pinned offline-Skia tarball + SKIA_BINARIES_URL and vulkan-headers.
- Convert 15 dangling design/*.md links to the punktfunk-planning
  prose convention (those docs live in the private planning repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:13:42 +02:00
enricobuehler b7cb75a48a fix(host): dual identical GPUs stream again — self-heal the IDD-push ring onto the driver's real render adapter
ci / web (push) Successful in 50s
apple / swift (push) Successful in 1m13s
ci / docs-site (push) Successful in 1m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
decky / build-publish (push) Successful in 15s
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 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 20s
apple / screenshots (push) Successful in 5m35s
windows-host / package (push) Successful in 7m57s
deb / build-publish (push) Successful in 12m0s
arch / build-publish (push) Successful in 14m51s
android / android (push) Successful in 16m21s
ci / rust (push) Successful in 17m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m0s
A box with two identical GPUs (twin RTX 4090s in the field report) died out
of pipeline retries with driver_status=2 (DRV_STATUS_TEX_FAIL, detail
0x80070057): SET_RENDER_ADAPTER pinned the driver to one twin at monitor ADD,
but the ring open re-ran the GPU picker, whose max-VRAM tie is settled by
DXGI enumeration order — which follows the primary display that the vdisplay
flow itself moves mid-session. Ring on one twin, swap-chain on the other →
the driver can't open the shared textures, and every retry repeats the same
wrong pick.

- wait_for_attach: TEX_FAIL becomes a typed AttachTexFail carrying the render
  LUID the driver reports in the shared frame header (written before the
  texture opens, so valid on failure) — the error now names both adapters
  instead of guessing "mismatch?".
- open_inner: on AttachTexFail with a different, non-zero driver LUID, rebind
  the ring there and reopen ONCE. Both candidates are real GPU adapters, so
  NVENC keeps getting a device it accepts.
- gpu::enumerate (Windows): sort the inventory by LUID so the max-VRAM tie,
  the env-substring first match, and twin occurrence numbering are stable for
  the boot instead of tracking the primary display.
- manager: record the ADD-time render pin on Monitor and warn on reuse when
  the current pick has moved away from it (the pick only takes effect on the
  next monitor create; /display/release forces one).
- pf_vdisplay: drop the "ADD render adapter DIFFERS from pinned" warn — the
  ADD reply carries IDARG_OUT_MONITORARRIVAL.OsAdapterLuid (the IddCx DISPLAY
  adapter, verified on-glass), not the render GPU, so the check compared
  unrelated LUIDs and fired on every ADD.

Verified on the RTX 4090 box: single-GPU streaming is unchanged (IddPush +
NVENC AV1 session, no new warnings). The rebind path needs a twin-GPU box —
until validated there, /display/release remains the manual recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:19:07 +02:00
enricobuehler 08694b4026 feat(apple): stage-3 presenter — glass-gated present pacing as a live A/B
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m2s
decky / build-publish (push) Successful in 14s
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 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / bench (push) Successful in 5m47s
docker / deploy-docs (push) Failing after 37s
release / apple (push) Successful in 8m15s
android / android (push) Successful in 12m57s
arch / build-publish (push) Successful in 13m28s
deb / build-publish (push) Successful in 12m15s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m24s
Stage-2's present-on-arrival saturates CAMetalLayer's FIFO image queue
whenever the stream rate runs at the panel's refresh (iOS always
vsync-latches; the macOS 26 compositor latch-paces our out-of-band
presents the same way): one early burst fills the queue to
maximumDrawableCount and — arrivals then matching latches one-for-one —
it never drains. That sticky depth is the measured 29-30 ms display
stage on the 120 Hz ProMotion devices, and the full-queue regime is
where host<->panel clock drift turns into the reported fixed-interval
repeats/drops. The 240 Hz Studio never saturates, which is why it never
showed either symptom.

Stage-3 is the same pipeline with a PresentGate: at most ONE
presented-but-undisplayed drawable in flight; the drawable's presented
handler reopens the gate and re-signals the render thread, so the next
present always takes the freshest newest-wins ring frame — the hidden
queue latency becomes explicit, correct frame drops. A 100 ms stale
fallback force-opens a gate whose handler never fires (the "presents
aren't damage" hazard class) so a pathological system degrades visibly
instead of freezing; the PUNKTFUNK_PRESENT_DEBUG `forced` counter
exposes it (0 on healthy systems).

Selection: the Settings > Display presenter picker now ships in release
builds (stage 2 default / stage 3 experimental; the freeze-prone
stage-1 diagnostic stays DEBUG-only), resolved per session with a
PUNKTFUNK_PRESENTER=stage1|stage2|stage3 env override for CLI A/B. The
pf-present debug line gains gated/forced/inflightMax — inflightMax is
the direct image-queue-depth measurement for the A/B.

Live-verified both ways against a real host at 1080p120: stage-3 holds
120/120 fps with inflightMax=1, forced=0, glass deltas p50 8.33 ms;
stage-2 is behaviorally unchanged (120/120 fps, inflightMax=2 even on
the wired 240 Hz setup — the saturation signal in miniature). Unit
tests cover the gate (one-in-flight, stale force-open, idempotent
release) and the presenter resolution (env override, release stage-1
gating); macOS tests green, iOS/tvOS xcodebuild clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:04:42 +02:00
enricobuehler 38b9f310e2 feat(clients): tiered stats overlay everywhere — Compact/Normal/Detailed on every platform
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
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 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
release / apple (push) Successful in 8m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 13m3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m39s
docker / deploy-docs (push) Successful in 18s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 14m54s
ci / rust (push) Successful in 23m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
flatpak / build-publish (push) Failing after 35s
Ship the Android client's 3-tier stats-overlay semantics in every other client
(design/stats-unification.md vocabulary): Off → Compact (one line: fps · e2e ms ·
Mb/s + loss flag) → Normal (mode + e2e p50/p95 + loss counters) → Detailed
(decoder path, HDR tag, per-stage latency equation).

Apple: new StatsVerbosity in PunktfunkKit persisted under punktfunk.statsVerbosity
(migrates the legacy hudEnabled bool: explicit off → Off, else Normal). The
existing three-finger tap (TouchMouse, trackpad/pointer modes only — touch
passthrough untouched) now cycles the tiers instead of toggling, matching
Android; ⌃⌥⇧S (menu + captured-state monitor) cycles the same ladder. Tiered
StreamHUDView (compact glass pill / headline HUD / full equation HUD); the iOS
corner disconnect also shows in Compact (the pill carries no button). Tier
pickers on iOS, macOS, tvOS and the gamepad settings UI.

Session stack (Linux + Windows + Deck share punktfunk-session): shared
pf_client_core::trust::StatsVerbosity; Settings grows stats_verbosity with a
show_stats fallback, and writes keep the legacy bool in sync so pre-tier
binaries reading the same JSON agree on off vs on. Ctrl+Alt+Shift+S cycles the
tier and re-renders the OSD immediately from the last stats window; the stdout
stats: line always carries the full Detailed text so the shell status card and
scripts keep a stable shape; --stats bumps Off → Normal without demoting a
richer tier. Tier pickers in the GTK dialog, the WinUI settings page and the
console-UI settings row; shortcut copy updated (GTK shortcuts window, Windows
help, session README). The Windows legacy builtin path keeps its bool HUD.

Tests: tier migration/round-trip in trust.rs, tiered stats_text output in
pf-presenter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:42:46 +02:00
enricobuehler 7b25868a19 fix(input): rock-solid held gamepad state — Android device pinning + seq'd snapshots
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 8m54s
windows-host / package (push) Successful in 7m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m49s
ci / web (push) Successful in 1m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m8s
ci / docs-site (push) Successful in 1m21s
android / android (push) Successful in 12m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 56s
decky / build-publish (push) Successful in 18s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m45s
arch / build-publish (push) Successful in 11m9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 5m26s
flatpak / build-publish (push) Failing after 33s
ci / rust (push) Successful in 17m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m39s
docker / deploy-docs (push) Successful in 22s
deb / build-publish (push) Successful in 11m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m8s
Two causes behind one field report (a held trigger jittering mid-game,
Android client → Windows host):

Android folded joystick ACTION_MOVEs from EVERY device into one axis
state. A controller's joystick-classified sibling node (DualSense/DS4
motion sensors) or a second/drifting pad reports every pad axis as 0,
so a held trigger flapped value→0→value on each event interleave. The
mapper now qualifies the source DEVICE (its source classes must include
GAMEPAD — a joystick event's own source is always plain JOYSTICK), pins
to one deviceId until that device disconnects, and merges LTRIGGER/BRAKE
(and RTRIGGER/GAS) with max, the same fold as the Controllers probe.

Underneath, gamepad input rode per-transition events over unreliable,
unordered QUIC datagrams — no sequence numbers, sharing the 4 KiB
oldest-first-shed send buffer — so one dropped or reordered event
corrupted held pad state until the NEXT change. Gamepad state now
travels the way rumble already does: idempotent state, refreshed.
InputKind::GamepadState packs the whole pad + a wrapping u8 seq into
the existing 18-byte layout; the host advertises HOST_CAP_GAMEPAD_STATE
(Welcome trailing byte, offset 67) and applies snapshots through a
per-pad stale-seq gate, skipping frame emits for unchanged refreshes;
the client folds embedder events into snapshots inside NativeClient's
input task (send on change + 100 ms refresh of touched pads), so the
SDL clients (Linux/Windows/session), Android, and Apple (C ABI) are all
covered with zero capture-code changes. Either end older ⇒ the legacy
per-transition path runs unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:11:38 +02:00
enricobuehler 47d22b6082 fix(host): admit exactly ONE parked knock per Approve — stop crashing gnome-shell, and self-heal dead sessions
ci / docs-site (push) Successful in 55s
ci / web (push) Successful in 1m11s
apple / swift (push) Successful in 1m12s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
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 6s
ci / bench (push) Successful in 7m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m57s
windows-host / package (push) Successful in 8m20s
arch / build-publish (push) Successful in 11m48s
deb / build-publish (push) Successful in 12m57s
apple / screenshots (push) Successful in 5m42s
android / android (push) Successful in 17m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
ci / rust (push) Successful in 25m57s
docker / deploy-docs (push) Failing after 5m44s
A retrying unpaired client parks one QUIC connection per knock, but the
delegated-approval waiters were keyed on fingerprint alone — one console
Approve resolved ALL of them. Observed live (2026-07-10): an iPad knocked
3x, one Approve admitted three full sessions, three Mutter virtual
monitors were created within ~200us plus an ApplyMonitorsConfig, and
gnome-shell SIGSEGV'd inside meta_monitor_manager_rebuild — dropping the
box to the GDM greeter, unreachable until reboot (GDM auto-login runs
once per boot) while the lingering host spammed "RemoteDesktop ... not
activatable" libei errors.

Four fixes, outermost symptom inward:

- Knock generations (native_pairing): note_pending returns a per-knock
  generation; a re-knock bumps it and wakes the previous waiter, which
  resolves the new PairingDecision::Superseded (connection closes; the
  console list is unchanged). An approval records WHICH generation it
  admitted, so a stale waiter polling only after the pending entry is
  cleared still loses the tie — exactly one admission, no matter the
  interleaving.

- TOPOLOGY_LOCK (vdisplay/mutter): every Mutter monitor mutation
  (pre-snapshot -> RecordVirtual -> ApplyMonitorsConfig, and the
  teardown Stop) is serialized process-wide. Concurrent rebuilds are
  what segfault the shell (second on-glass crash of this class — the
  teardown race is already documented in-file), and serialization also
  keeps wait_virtual_connector's snapshot diff from attributing a
  sibling's connector. Create timeout 20s -> 45s for lock queueing.

- Session-env hygiene (vdisplay): when detection finds NOTHING live,
  clear the previous connect's XDG_CURRENT_DESKTOP/WAYLAND_DISPLAY
  retarget. A stale GNOME value kept mutter::is_available() true after
  the crash, routing explicit-backend connects into the dead session
  (create timeouts + libei error loops) instead of the crisp "no live
  graphical session" handshake error.

- Opt-in recovery hook (config + punktfunk1): PUNKTFUNK_RECOVER_SESSION_CMD
  fires (detached via sh -c, debounced to one launch/min) when a client
  connects while no graphical session is live — e.g. `sudo -n systemctl
  restart gdm` re-runs auto-login and the client's retry lands in the
  recovered desktop. The handshake error tells the client to retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 01:10:26 +02:00
enricobuehler c86da1a1ff chore(android): silence the two vendored-ndk warnings on every native build
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m38s
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 50s
windows-host / package (push) Successful in 8m30s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m17s
decky / build-publish (push) Successful in 16s
ci / bench (push) Successful in 6m46s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
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 11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m21s
android / android (push) Successful in 12m38s
arch / build-publish (push) Successful in 14m7s
deb / build-publish (push) Successful in 15m11s
docker / deploy-docs (push) Successful in 9s
ci / rust (push) Successful in 22m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
The vendored ndk 0.9.0 warned twice per Android build: an unnecessary
`std::ptr::` qualification in input_queue.rs (the crate opts into
unused_qualifications) and an unused `Result` import in media_format.rs —
unused only because its sole consumers are #[cfg(feature = "api-level-29")]
methods our default-feature build compiles out, so the import is now gated
the same way (both feature configurations stay warning-free). Vendor-patch
inventory in the workspace Cargo.toml updated to stay honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:13:36 +02:00
228 changed files with 17005 additions and 5599 deletions
+9
View File
@@ -16,8 +16,17 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
swift: swift:
runs-on: macos-arm64 runs-on: macos-arm64
if: >-
gitea.event_name != 'pull_request' ||
gitea.event.pull_request.head.repo.fork != true
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+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
+82 -16
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,8 +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.
flatpak remote-add --user --if-not-exists flathub \ #
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
# 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"
@@ -108,7 +145,7 @@ jobs:
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is # device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles. # untouched; cargo --offline only needs sources for the crates it compiles.
run: | run: |
curl -fsSL -o /tmp/flatpak-cargo-generator.py \ curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \ python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
@@ -142,18 +179,45 @@ jobs:
DEST="${DEPLOY_USER}@${DEPLOY_HOST}" DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
mkdir -p "$PWD/repo" mkdir -p "$PWD/repo"
# Pull the currently-published repo (all channels' objects + refs) into the repo the build # Pull the currently-published repo (all channels' objects + refs) into the repo the build
# will extend. No --delete: the local repo starts empty, so this only ADDS. A missing # will extend. No --delete: the local repo starts empty, so this only ADDS.
# server repo (very first publish) is fine — we continue with a fresh repo. # Probe first (retried) whether a published repo exists at all: ONLY that case may
rsync -az --info=stats1 -e "$SSH" "$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/" \ # continue with a fresh repo. A transient network failure must FAIL the job instead —
|| echo "::warning::no published repo to seed (first publish?) — continuing fresh" # a blanket `rsync || continue` here is exactly how a flaky link produces the
# single-branch summary that clobbers the other channel (the bug described above).
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
if [ "$PRESENT" = present ]; then
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
else
echo "::warning::no published repo on the server (first publish) — continuing fresh"
fi
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)" echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
- name: Build the flatpak (install deps from Flathub, offline build) - name: Prefetch deps + sources (retried — the network phase, split off the build)
run: | run: |
# --install-deps-from=flathub pulls everything the manifest declares: the GNOME 50 # All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
# runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus # or TCP dial costs a backoff-retry instead of the whole (long) compile:
# the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the # 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# container-safe path (no FUSE). # GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions, plus the runtime's auto codecs-extra (HEVC libavcodec).
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no 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 \
"$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only \
"$PWD/build-dir" "$MANIFEST"
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch> # --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# (canary or stable) so the matching hosted .flatpakref resolves deterministically # (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch). # (manifest sets no branch).
@@ -272,11 +336,13 @@ jobs:
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new" SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}" DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
$SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo" # All idempotent — retried because the runner's link to unom-1 drops TCP dials under
rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/" # load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/" bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo"
rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/" bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/"
$SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d" bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref" echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
- name: Attach bundle to the Gitea release (stable tags only) - name: Attach bundle to the Gitea release (stable tags only)
+10
View File
@@ -30,8 +30,15 @@ on:
# scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present. # scripts/ci/ensure-windows-toolchain.ps1, a fast no-op once already present.
jobs: jobs:
# SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with
# the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full
# rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR
# runners); the `if:` is a fail-open backstop that never blocks internal PR CI.
probe-and-proto: probe-and-proto:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 30 timeout-minutes: 30
defaults: defaults:
run: run:
@@ -108,6 +115,9 @@ jobs:
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question. # DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
driver-build: driver-build:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 45 timeout-minutes: 45
defaults: defaults:
run: run:
+12
View File
@@ -68,8 +68,20 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the
# host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml /
# windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted
# PR code could therefore persist on that machine or harvest signing material a later job exposes.
# The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to
# run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral
# runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag,
# and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it
# never blocks internal PR CI.
build: build:
runs-on: windows-amd64 runs-on: windows-amd64
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork != true
timeout-minutes: 90 timeout-minutes: 90
strategy: strategy:
fail-fast: false fail-fast: false
+3 -1
View File
@@ -40,4 +40,6 @@ Generated artifacts are checked in and CI fails on drift: `include/punktfunk_cor
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment `api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`). density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
See [`CLAUDE.md`](CLAUDE.md) for the full build/test/run guide and design invariants. See the [README's Build & test section](README.md#build--test-from-source) and
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
Generated
+14 -14
View File
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.9.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" 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.0" version = "0.9.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+3 -2
View File
@@ -26,7 +26,8 @@ exclude = [
"clients/android/native/vendor/ndk", "clients/android/native/vendor/ndk",
] ]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change: `MediaCodec::as_ptr` made public # ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
# unnecessary `std::` qualification and a feature-gated `Result` import): `MediaCodec::as_ptr` made public
# (upstream keeps it private and exposes no frame-rendered binding), so the Android client can # (upstream keeps it private and exposes no frame-rendered binding), so the Android client can
# call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage # call `AMediaCodec_setOnFrameRenderedCallback` via ndk-sys for the HUD's `display` stage
# (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback. # (design/stats-unification.md). Drop the patch when upstream exposes the pointer or the callback.
@@ -34,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.0" 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"
+20 -6
View File
@@ -22,7 +22,8 @@ punktfunk pairs a **virtual-display streaming host** with native clients on ever
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the **GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
protocol, FEC, and crypto, linked into the host and every client over a stable C ABI. protocol, FEC, and crypto, linked into the host and every native client — directly as a Rust crate
on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
## What makes it different ## What makes it different
@@ -58,12 +59,16 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending | | **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs | | **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
shell** (`pf-console-ui`): host list, PIN pairing, settings, and an on-screen keyboard.
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio, (RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin, and **video at the client's exact resolution and refresh** via a per-session virtual output (KWin,
@@ -117,7 +122,7 @@ Each client discovers hosts on the network automatically and does a one-time
For development, or as an install fallback where no package is available: For development, or as an install fallback where no package is available:
```sh ```sh
cargo build --workspace # the Rust core, host, Linux client, and probe (Linux & macOS) cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check cargo fmt --all --check
@@ -137,18 +142,27 @@ and the [docs site](https://docs.punktfunk.unom.io).
crates/ crates/
punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib) punktfunk-core/ protocol · FEC · pacing · crypto · QUIC control plane — the C ABI (lib + cdylib + staticlib)
punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt punktfunk-host/ the host (Linux + Windows): virtual displays · capture · encode · input · GameStream · punktfunk/1 · mgmt
pf-client-core/ shared client plumbing (Linux + Windows): session pump · FFmpeg decode · audio · SDL3 gamepads · trust · discovery
pf-presenter/ Vulkan session presenter: SDL3 window · ash swapchain · frame present · input capture
pf-console-ui/ Skia console UI for the session client: gamepad shell · stats OSD · pairing · on-screen keyboard
pf-ffvk/ FFmpeg Vulkan hwcontext bindings (AVVkFrame) for Vulkan Video decode on the presenter's device
pf-driver-proto/ host ↔ pf-vdisplay driver contract: control IOCTLs + IDD-push frame transport (no_std)
punktfunk-tray/ host tray icon (Windows notification area / Linux StatusNotifierItem)
clients/ clients/
apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController) apple/ macOS / iOS / tvOS app (Swift · VideoToolbox · Metal · GameController)
linux/ Linux desktop app (Rust · GTK4/libadwaita · FFmpeg/VAAPI · PipeWire · SDL3) linux/ Linux launcher shell (Rust · relm4 / GTK4 / libadwaita) — spawns the session client to stream
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3) windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio) android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
probe/ headless reference / measurement client for punktfunk/1 probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
include/punktfunk_core.h cbindgen-generated C header (checked in) include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement) tools/ latency-probe · loss-harness (measurement)
ci/ CI container images (rust-ci · fedora-rpm)
``` ```
## Design invariants ## Design invariants
+1 -1
View File
@@ -14,7 +14,7 @@ exposes other users before a fix exists.
The more of this you can give us, the faster we can act: The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client). - The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account, - The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …). admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one. - Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
+9 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.9.0" "version": "0.9.1"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -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"
}, },
+1 -1
View File
@@ -58,7 +58,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 1721, not `build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 1721, not
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM `cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
2026.05.01 · compileSdk 37 · minSdk 31). 2026.05.01 · compileSdk 37 · minSdk 28).
**Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task **Android Studio:** open `clients/android` — it uses its bundled JBR 21, and the `cargoNdk*` task
builds the `.so` as part of the normal build. builds the `.so` as part of the normal build.
+29 -5
View File
@@ -91,9 +91,12 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
val cmd = mutableListOf( val cmd = mutableListOf(
"$cargoBin/cargo", "ndk", "$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64", "-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the // Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we // NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate). // which then fail at System.loadLibrary on every device below the symbol's API level
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
"--platform", "28", "--platform", "28",
"-o", file("src/main/jniLibs").absolutePath, "-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-client-android", "build", "-p", "punktfunk-client-android",
@@ -102,8 +105,28 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
commandLine(cmd) commandLine(cmd)
} }
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
tasks.register<Exec>(taskName) {
group = "rust"
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
dependsOn(cargoTask)
workingDir = repoRoot
commandLine(
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
"${androidSdkDir()}/ndk/$ndkVer",
file("src/main/jniLibs").absolutePath,
"28",
)
}
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false) val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true) val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
afterEvaluate { afterEvaluate {
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi // `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
@@ -120,8 +143,9 @@ afterEvaluate {
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native // debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust. // build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) { if (!project.hasProperty("skipRustBuild")) {
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease val debugRust =
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) } tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) } tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
} }
} }
@@ -175,6 +175,12 @@ object Gamepad {
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per * Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing * session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
* sticks on the host (which has no client-side held-state knowledge). * sticks on the host (which has no client-side held-state knowledge).
*
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
* sensors) and any second pad report every axis as 0, and folding them into the same state
* flapped a held trigger/stick between its value and 0 on every event interleave.
*/ */
class AxisMapper(private val handle: Long) { class AxisMapper(private val handle: Long) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity). // Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
@@ -182,10 +188,29 @@ object Gamepad {
private var hatX = 0 // -1 / 0 / +1 private var hatX = 0 // -1 / 0 / +1
private var hatY = 0 private var hatY = 0
/** deviceId of the controller pad 0 is pinned to; 1 until the first qualifying event. */
private var deviceId = -1
/** Returns true if this was a joystick ACTION_MOVE we consumed. */ /** Returns true if this was a joystick ACTION_MOVE we consumed. */
fun onMotion(event: MotionEvent): Boolean { fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
// joystick-class remotes don't — and those report every pad axis as 0 (see the
// class doc for the held-trigger flap this caused).
val dev = event.device ?: return false
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
// Single-pad model: pin to the first qualifying controller so a second pad (or its
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
if (deviceId != event.deviceId) {
if (deviceId != -1) {
if (InputDevice.getDevice(deviceId) != null) return false
reset() // the pinned pad is gone — lift its held state before adopting
}
deviceId = event.deviceId
}
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up. // Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X))) sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
@@ -193,9 +218,27 @@ object Gamepad {
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z))) sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ))) sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
// Triggers: LTRIGGER/RTRIGGER if present, else BRAKE/GAS; 0..1 float → 0..255. // Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
sendAxis(AXIS_LT, trigger(firstNonZero(event, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE))) // with max, the same fold as the Controllers screen probe, so a pad that reports
sendAxis(AXIS_RT, trigger(firstNonZero(event, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS))) // only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// HAT → dpad button transitions (track previous, emit only the deltas). // HAT → dpad button transitions (track previous, emit only the deltas).
val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X))
@@ -237,10 +280,5 @@ object Gamepad {
private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt() private fun trigger(v: Float): Int = (v.coerceIn(0f, 1f) * 255f).toInt()
private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0 private fun sign(v: Float): Int = if (v < -0.5f) -1 else if (v > 0.5f) 1 else 0
private fun firstNonZero(e: MotionEvent, a: Int, b: Int): Float {
val va = e.getAxisValue(a)
return if (va != 0f) va else e.getAxisValue(b)
}
} }
} }
@@ -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 ----
+13 -8
View File
@@ -43,15 +43,20 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode + # Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant). # audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and # api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make # ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not # ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
# linked, so the .so still loads on API 28/29. # (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] } ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks: # Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the # `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker). # HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored # Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml). # (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
# public (vendor/ndk, wired in the root Cargo.toml).
ndk-sys = { version = "0.6", features = ["media"] } ndk-sys = { version = "0.6", features = ["media"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority). # setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2" libc = "0.2"
+60 -35
View File
@@ -355,45 +355,70 @@ 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) => {
Ok(samples) => { // Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
let n = samples * channels; // arrived: empty input synthesizes `frame_samples` of interpolation per missing
for &s in &pcm[..n] { // packet — an inaudible fade instead of the click a hard gap makes in the ring.
window_peak = window_peak.max(s.abs()); 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
} }
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch) if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a let mut buf = free_rx
// future host frame-size change here in debug, not as a silent audio glitch. .try_recv()
debug_assert!( .unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
n <= 5 * ms, buf.clear();
"audio frame {n} f32 exceeds the 5 ms ring reserve" buf.extend_from_slice(&pcm[..samples * channels]);
); match tx.try_send(buf) {
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1; Ok(()) | Err(TrySendError::Full(_)) => {}
// Reuse a recycled buffer if the callback handed one back; only allocate when the Err(TrySendError::Disconnected(_)) => break 'pump,
// free-list is momentarily empty (startup / after a backpressure drop). }
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
buf.clear();
buf.extend_from_slice(&pcm[..n]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
Err(TrySendError::Disconnected(_)) => break,
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
);
window_peak = 0.0;
} }
} }
Err(e) => log::debug!("audio: opus decode: {e}"), match dec.decode_float(&pkt.data, &mut pcm, false) {
}, Ok(samples) => {
frame_samples = samples;
let n = samples * channels;
for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs());
}
// The ring's pre-reservation in `start` assumes the protocol's 5 ms (≤480-f32/ch)
// frames; a larger frame would force a one-time realloc on the RT thread. Catch a
// future host frame-size change here in debug, not as a silent audio glitch.
debug_assert!(
n <= 5 * ms,
"audio frame {n} f32 exceeds the 5 ms ring reserve"
);
let count = counters.opus_decoded.fetch_add(1, Ordering::Relaxed) + 1;
// Reuse a recycled buffer if the callback handed one back; only allocate when the
// free-list is momentarily empty (startup / after a backpressure drop).
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
buf.clear();
buf.extend_from_slice(&pcm[..n]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {} // drop-newest under backpressure
Err(TrySendError::Disconnected(_)) => break,
}
if count % 600 == 0 {
log::info!(
"audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}",
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
counters.ring_depth.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
}
Err(e) => log::debug!("audio: opus decode: {e}"),
}
}
Err(PunktfunkError::NoFrame) => {} // timeout Err(PunktfunkError::NoFrame) => {} // timeout
Err(_) => break, // session closed Err(_) => break, // session closed
} }
+120 -52
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,
@@ -449,26 +463,49 @@ impl DisplayTracker {
} }
} }
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 — /// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which /// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding /// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with /// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no /// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then /// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
/// simply has no `display` stage, exactly the pre-callback behaviour. /// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
fn install_render_callback( fn install_render_callback(
codec: &MediaCodec, codec: &MediaCodec,
tracker: &Arc<DisplayTracker>, tracker: &Arc<DisplayTracker>,
) -> Option<*const DisplayTracker> { ) -> Option<*const DisplayTracker> {
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
type SetOnFrameRenderedFn = unsafe extern "C" fn(
*mut ndk_sys::AMediaCodec,
ndk_sys::AMediaCodecOnFrameRendered,
*mut c_void,
) -> ndk_sys::media_status_t;
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
// returns null when the symbol is absent (device below API 33), checked before transmuting the
// non-null pointer to its fn-pointer type.
let set_on_frame_rendered = unsafe {
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
if sym.is_null() {
log::info!("decode: no render callback on this API level (<33) — no display stage");
return None;
}
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
};
let ud = Arc::into_raw(tracker.clone()); let ud = Arc::into_raw(tracker.clone());
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration // SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
// (reclaimed only after the codec is deleted, per this function's contract). // (reclaimed only after the codec is deleted, per this function's contract).
let status = unsafe { let status = unsafe {
ndk_sys::AMediaCodec_setOnFrameRenderedCallback( set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void)
codec.as_ptr(),
Some(on_frame_rendered),
ud as *mut c_void,
)
}; };
if status == ndk_sys::media_status_t::AMEDIA_OK { if status == ndk_sys::media_status_t::AMEDIA_OK {
Some(ud) Some(ud)
@@ -531,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);
@@ -804,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
@@ -819,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()
}; };
@@ -855,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
@@ -899,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,
@@ -976,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>,
) { ) {
@@ -987,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);
@@ -1066,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();
@@ -1082,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 {
@@ -1275,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,
);
}
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
}; };
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)
+1 -1
View File
@@ -7,7 +7,7 @@ in the workspace root.
**The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made **The only change** is in `src/media/media_codec.rs`: `MediaCodec::as_ptr` is made
`pub` (upstream keeps it private) so the Android client can register `pub` (upstream keeps it private) so the Android client can register
`AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp `AMediaCodec_setOnFrameRenderedCallback` through `ndk-sys` — the render-timestamp
callback behind the HUD's `display` stage (`design/stats-unification.md`), which the callback behind the HUD's `display` stage (punktfunk-planning: `stats-unification.md`), which the
wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it. wrapper doesn't expose. Grep for `punktfunk vendored patch` to find it.
Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered Drop this vendor copy when upstream exposes the raw pointer or a frame-rendered
+1 -1
View File
@@ -130,7 +130,7 @@ impl InputQueue {
looper.ptr().as_ptr(), looper.ptr().as_ptr(),
id, id,
None, None,
std::ptr::null_mut(), ptr::null_mut(),
) )
} }
} }
@@ -9,7 +9,11 @@ use std::{
slice, slice,
}; };
use crate::media_error::{MediaError, Result}; use crate::media_error::MediaError;
// `Result` is only referenced by the api-level-29 methods below; an ungated import warns
// (unused_imports) on every default-feature build.
#[cfg(feature = "api-level-29")]
use crate::media_error::Result;
/// A native [`AMediaFormat *`] /// A native [`AMediaFormat *`]
/// ///
@@ -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;
+2 -1
View File
@@ -122,7 +122,8 @@ PUNKTFUNK_AUTOCONNECT=<box-ip> PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli
- **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the - **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the
required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on
every main push. See the script header for details. every main push. See the script header for details.
- Deeper design notes live in [`design/apple-stage2-presenter.md`](../../design/apple-stage2-presenter.md). - Deeper design notes live in the internal planning repo (punktfunk-planning:
`apple-stage2-presenter.md`).
## Related ## Related
@@ -31,8 +31,15 @@ struct ContentView: View {
@AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The raw string is what @AppStorage observes (so cycles from any surface re-render this
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
private var statsVerbosity: StatsVerbosity {
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
}
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto). /// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
private var preferredCodecByte: UInt8 { private var preferredCodecByte: UInt8 {
switch codec { switch codec {
@@ -61,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 {
@@ -101,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"
@@ -271,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,
@@ -288,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
} }
@@ -308,12 +327,25 @@ struct ContentView: View {
}() }()
return ZStack { return ZStack {
stream(captureEnabled: pendingFingerprint == nil) stream(captureEnabled: pendingFingerprint == nil)
.blur(radius: pendingFingerprint != nil ? 32 : 0) // Blur the live stream during the trust prompt (heavy) and during a resize (lighter
// the deliberate "hold on" while the host rebuilds its pipeline and the decoder
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
// as before (its own overlay handles the transition).
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
.animation(.easeInOut(duration: 0.22), value: model.resizing)
.overlay { .overlay {
if pendingFingerprint != nil { if pendingFingerprint != nil {
Color.black.opacity(0.45) Color.black.opacity(0.45)
} }
} }
// The resize spinner rides over the (blurred) stream; suppressed under the trust
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
// steering and the next click still reaches the stream.
.overlay {
if pendingFingerprint == nil {
ResizeIndicatorView(active: model.resizing)
}
}
if let fp = pendingFingerprint { if let fp = pendingFingerprint {
TrustCardView( TrustCardView(
fingerprint: fp, fingerprint: fp,
@@ -355,11 +387,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
} }
@@ -388,26 +423,51 @@ struct ContentView: View {
onSessionEnd: { [weak model] in onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() } Task { @MainActor in model?.sessionEnded() }
}, },
// Resize overlay START the follower is main-actor, so this drives the blur
// + spinner synchronously the instant the window differs from the live mode.
onResizeTarget: { [weak model] w, h in
model?.resizeTargeted(width: w, height: h)
},
// Resize overlay END the coded dims of each new-mode IDR, reported from the
// decode pump thread; hop to the main actor to clear the overlay.
onDecodedSize: { [weak model] w, h in
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
},
endToEndMeter: model.endToEnd, endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage, decodeMeter: model.decodeStage,
displayMeter: model.displayStage displayMeter: model.displayStage
) )
.overlay(alignment: placement.alignment) { .overlay(alignment: placement.alignment) {
if captureEnabled && hudEnabled { // The stats overlay MORPHS between tiers and SCALES UP on enter. With no `.id`, a
StreamHUDView(model: model, connection: conn, placement: placement) // verbosity change keeps the same StreamHUDView identity, so its one shared glass
// card animates its frame/shape to the new tier (a morph) instead of cross-fading a
// fresh card in. The `.transition` therefore fires only on the offon boundary a
// scale-up (0.81) from the HUD's own corner. The ZStack is the stable host the
// `.animation` watches as the child enters/leaves and morphs.
ZStack {
if captureEnabled && statsVerbosity != .off {
StreamHUDView(
model: model, connection: conn, placement: placement,
verbosity: statsVerbosity)
.transition(
.scale(scale: 0.8, anchor: placement.unitPoint)
.combined(with: .opacity))
}
} }
.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)
@@ -422,17 +482,18 @@ struct ContentView: View {
} }
#endif #endif
#if os(iOS) #if os(iOS)
// Touch users have no menu / D, so when the HUD (and its Disconnect button) // Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// is hidden, keep a minimal always-reachable exit in a corner. It rides a // screen the overlay off, or the compact pill (which carries no button)
// material disc (like the HUD) so the glyph stays legible over a bright frame // keep a minimal always-reachable exit in a corner. It rides a material disc
// this is the sole touch disconnect path when stats are off. // (like the HUD) so the glyph stays legible over a bright frame this is the
// sole touch disconnect path in those tiers.
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if captureEnabled && !hudEnabled { if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
Button { model.disconnect() } label: { Button { model.disconnect() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
// Sole touch exit when the HUD is off a floating glass disc // Sole touch exit in the off/compact tiers a floating glass disc
// over the frame (26+, material fallback). interactive: the disc // over the frame (26+, material fallback). interactive: the disc
// IS the tap target, so the glass reacts to press. // IS the tap target, so the glass reacts to press.
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
@@ -450,6 +511,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)
ScrollView(.horizontal) { ScrollViewReader { proxy in
HStack(spacing: spacing) { ScrollView(.horizontal) {
ForEach(items) { item in HStack(spacing: spacing) {
card(item) ForEach(items) { item in
.frame(width: itemWidth) #if os(tvOS)
.contentShape(Rectangle()) // A focusable Button per card: the focus engine does the navigating
.onTapGesture { tap(item) } // (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)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
#endif
}
}
.frame(height: geo.size.height) // fill so shorter cards center vertically
.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)
.scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip.
.scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.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)
} }
} }
.frame(height: geo.size.height) // fill so shorter cards center vertically // The list changed under a stable focus (discovered hosts prepend tiles): the
.scrollTargetLayout() // 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
} }
.scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned)
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip.
.scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
} }
.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
@@ -23,6 +23,17 @@ enum HUDPlacement: String, CaseIterable, Identifiable {
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward. /// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing } var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
/// The corner as a `UnitPoint`, so a scale transition grows/shrinks the HUD out of its own
/// corner rather than its centre.
var unitPoint: UnitPoint {
switch self {
case .topLeading: return .topLeading
case .topTrailing: return .topTrailing
case .bottomLeading: return .bottomLeading
case .bottomTrailing: return .bottomTrailing
}
}
/// User-facing corner label. /// User-facing corner label.
var label: String { var label: String {
switch self { switch self {
@@ -0,0 +1,41 @@
// The resize overlay (design/midstream-resolution-resize.md client resize UX). A Match-window
// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox
// decoder on the first new-mode IDR an unavoidable sub-second gap where the last frame lingers,
// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter,
// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption
// acknowledges the transition. It clears the instant a frame at the requested size decodes (the
// `onDecodedSize` END signal) or on the follower's safety timeout see `SessionModel.resizing`.
//
// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a
// resize the user triggers by dragging the window never swallows their next click.
import PunktfunkKit
import SwiftUI
struct ResizeIndicatorView: View {
/// Mirrors `SessionModel.resizing`; the fade in/out is driven off this.
let active: Bool
var body: some View {
ZStack {
if active {
VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(.white)
Text("Resizing…")
.font(.geist(15, .medium, relativeTo: .callout))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 30)
.padding(.vertical, 24)
.glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.transition(.opacity.combined(with: .scale(scale: 0.92)))
}
}
.environment(\.colorScheme, .dark) // the spinner + glass read over any frame
.animation(.easeInOut(duration: 0.22), value: active)
.allowsHitTesting(false) // the stream keeps receiving input the whole time
}
}
@@ -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.
@@ -99,6 +109,16 @@ final class SessionModel: ObservableObject {
/// Mirrors StreamView's capture state (it owns the input capture; this drives the /// Mirrors StreamView's capture state (it owns the input capture; this drives the
/// HUD's "click to capture" / " releases" hint). /// HUD's "click to capture" / " releases" hint).
@Published var mouseCaptured = false @Published var mouseCaptured = false
/// Resize overlay (design/midstream-resolution-resize.md client resize UX): true from the
/// instant a Match-window resize starts steering toward a new size until a frame at that size
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
@Published private(set) var resizing = false
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
/// differently-sized frame. Ticked from the 1 Hz stats timer.
private var resizeIndicator = ResizeIndicator()
let meter = FrameMeter() let meter = FrameMeter()
/// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's /// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's
@@ -119,6 +139,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 +189,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 +338,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 {
@@ -332,6 +374,8 @@ final class SessionModel: ObservableObject {
lostFrames = 0 lostFrames = 0
lostPct = 0 lostPct = 0
mouseCaptured = false mouseCaptured = false
resizing = false
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
} }
/// Called (via the main actor) when the pump hits end-of-session. /// Called (via the main actor) when the pump hits end-of-session.
@@ -342,6 +386,23 @@ final class SessionModel: ObservableObject {
errorMessage = "Session ended by \(name)." errorMessage = "Session ended by \(name)."
} }
/// Resize overlay START (main actor from the Match-window follower's `onResizeTarget`): the
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
func resizeTargeted(width: UInt32, height: UInt32) {
resizeIndicator.steering(
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
resizing = resizeIndicator.active
}
/// Resize overlay END (main actor hopped from the decode pump's `onDecodedSize`): a new-mode
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
func resizeDecoded(width: Int, height: Int) {
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
resizing = resizeIndicator.active
}
private func beginStreaming() { private func beginStreaming() {
guard let conn = connection else { return } guard let conn = connection else { return }
// Input capture itself is owned by StreamView (engaged by the captureEnabled // Input capture itself is owned by StreamView (engaged by the captureEnabled
@@ -363,11 +424,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() {
@@ -376,6 +446,11 @@ final class SessionModel: ObservableObject {
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
// success; this only fires after the timeout.
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
self.resizing = self.resizeIndicator.active
let (frames, bytes, total) = self.meter.drain() let (frames, bytes, total) = self.meter.drain()
self.fps = frames self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000 self.mbps = Double(bytes) * 8 / 1_000_000
@@ -429,12 +504,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.
@@ -5,8 +5,9 @@
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While // (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows // input is CAPTURED these key equivalents never reach the menu (the stream view swallows
// keys); InputCapture's monitor detects the same combos there and performs the same actions // keys); InputCapture's monitor detects the same combos there and performs the same actions
// the menu covers the released state and discoverability. The stats toggle just flips the // the menu covers the released state and discoverability. The stats item cycles the shared
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts. // `statsVerbosity` tier (off compact normal detailed off); ContentView reads the same
// @AppStorage and reacts.
// //
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri // tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is // Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
@@ -36,12 +37,16 @@ extension FocusedValues {
struct StreamCommands: Commands { struct StreamCommands: Commands {
@FocusedValue(\.sessionFocus) private var session @FocusedValue(\.sessionFocus) private var session
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The raw string so @AppStorage observes the shared key; the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue
var body: some Commands { var body: some Commands {
CommandMenu("Stream") { CommandMenu("Stream") {
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") { Button("Cycle Statistics") {
hudEnabled.toggle() let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
statsVerbosityRaw = current.next().rawValue
} }
.keyboardShortcut("s", modifiers: [.control, .option, .shift]) .keyboardShortcut("s", modifiers: [.control, .option, .shift])
// Reaches the key window's stream view via NotificationCenter capture is view // Reaches the key window's stream view via NotificationCenter capture is view
@@ -1,17 +1,81 @@
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines // The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
// (design/stats-unification.md end-to-end headline + the stage equation under stage-2, the // * compact one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
// capturereceived headline under the stage-1 fallback), the loss counter, the platform input // * normal mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
// hint, and disconnect. // end-to-end under stage-2, capturereceived under the stage-1 fallback), the loss
// counter, a capture hint (shown until input is captured), and disconnect;
// * detailed everything normal has plus the stage equation line(s) under the headline.
// `.off` never reaches this view (ContentView gates the overlay on the tier).
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct StreamHUDView: View { struct StreamHUDView: View {
@ObservedObject var model: SessionModel @ObservedObject var model: SessionModel
let connection: PunktfunkConnection let connection: PunktfunkConnection
var placement: HUDPlacement = .topTrailing var placement: HUDPlacement = .topTrailing
let verbosity: StatsVerbosity
var body: some View { var body: some View {
// .off is gated upstream (ContentView only mounts the HUD when the tier is on)
// render nothing if it ever slips through.
if verbosity != .off {
// ONE shared glass card wraps the tier-dependent content, so a verbosity change MORPHS
// 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.
tierContent
.padding(cardPadding)
.glassBackground(cardShape)
.padding(edgeInset)
}
}
/// The tier-dependent content, unwrapped (the shared card in `body` supplies the padding +
/// glass background). Compact is a one-line pill; normal/detailed the full stack.
@ViewBuilder private var tierContent: some View {
if verbosity == .compact {
compactContent
} else {
fullContent
}
}
// MARK: - Compact tier
/// One line: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is the best available
/// latency headline (stage-2 end-to-end, else the stage-1 capturereceived) and is omitted until
/// either is valid. Loss appends in the same quiet styling the full HUD's lost line uses.
private var compactContent: some View {
HStack(spacing: 6) {
Circle()
.fill(Color.accentColor)
.frame(width: 7, height: 7)
Text(compactLine)
.font(.system(.caption, design: .monospaced))
if model.lostFrames > 0 {
Text("· lost \(model.lostFrames)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
private var compactLine: String {
var parts = ["\(model.fps) fps"]
if model.endToEndValid {
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
} else if model.hostNetworkValid {
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
}
parts.append(String(format: "%.1f Mb/s", model.mbps))
return parts.joined(separator: " · ")
}
// MARK: - Normal / detailed tiers
private var fullContent: some View {
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) { VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
HStack(spacing: 6) { HStack(spacing: 6) {
Circle() Circle()
@@ -26,11 +90,11 @@ struct StreamHUDView: View {
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")") Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// The equation: the stages tiling the headline interval (per-window p50s // The equation (detailed tier only): the stages tiling the headline interval
// they only approximately sum to the directly-measured total). With a host // (per-window p50s they only approximately sum to the directly-measured
// that reports per-AU timings (0xCF) the first term splits into host + network // total). With a host that reports per-AU timings (0xCF) the first term splits
// (phase 2); an old host keeps the combined term. // into host + network (phase 2); an old host keeps the combined term.
if model.hostNetworkValid && model.decodeValid && model.displayValid { if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
if model.splitValid { if model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")") Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
@@ -45,11 +109,12 @@ struct StreamHUDView: View {
// Stage-1 fallback presenter: the layer decodes + presents internally with no // Stage-1 fallback presenter: the layer decodes + presents internally with no
// per-frame stamp, so the honest headline ends at receipt. The host/network // per-frame stamp, so the honest headline ends at receipt. The host/network
// split still applies there (receipt is presenter-independent) it becomes the // split still applies there (receipt is presenter-independent) it becomes the
// only equation line; without it, host+network IS the whole measured interval. // only equation line (detailed tier); without it, host+network IS the whole
// measured interval.
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")") Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if model.splitValid { if verbosity == .detailed && model.splitValid {
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")") Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -63,47 +128,104 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// While captured the cursor is hidden+frozen, so the button is keyboard-only // Capture hint, shown only until input is captured how to grab it. The RELEASE
// (Q the cross-client Ctrl+Alt+Shift+Q or /Cmd+Tab release the cursor; // shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
// released, it's clickable again). // and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
#if os(macOS) #if os(macOS)
Text(model.mouseCaptured if !model.mouseCaptured {
? "⌃⌥⇧Q releases the mouse" Text("Click the stream to capture input")
: "Click the stream to capture input") .font(.geist(11, relativeTo: .caption2))
.font(.geist(11, relativeTo: .caption2)) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) }
#elseif os(iOS) #elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse. // Touch always plays directly; (hardware keyboard) captures kb/mouse.
Text(model.mouseCaptured if !model.mouseCaptured {
? "⌘⎋ releases keyboard & mouse" Text("⌘⎋ captures keyboard & mouse")
: "⌘⎋ captures keyboard & mouse") .font(.geist(11, relativeTo: .caption2))
.font(.geist(11, relativeTo: .caption2)) .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
} }
.padding(10) }
// Floating HUD over live video the canonical Liquid-Glass overlay surface (26+);
// falls back to .regularMaterial below 26 (see GlassStyle). // MARK: - Card metrics
.glassBackground(RoundedRectangle(cornerRadius: 10))
.padding(10) /// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
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 {
#if os(iOS)
return 14
#elseif os(tvOS)
return 24
#else
return 10
#endif
}
/// The card's corner radius. On iOS it's concentric with the physical display corner
/// `displayCornerRadius edgeInset`, so the gap to the screen edge stays uniform right around the
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
private var cardCornerRadius: CGFloat {
#if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else
return 10
#endif
}
/// The card background shape a continuous (squircle) rounded rectangle, matching the curve
/// Apple's hardware display corners use so the concentric inset actually reads as parallel.
private var cardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
} }
} }
#if os(iOS)
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
enum DeviceMetrics {
/// The physical display's corner radius. There's no public API for it, so read the private
/// `_displayCornerRadius` via KVC on the active window scene's screen, guarded by a fallback that
/// approximates a modern rounded device a future OS that hides the key just yields a slightly
/// less-perfect inset, never a crash. The key is assembled from parts so it isn't a plain literal
/// in the binary; note the App Store private-API consideration regardless.
static var displayCornerRadius: CGFloat {
let key = ["_display", "Corner", "Radius"].joined()
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.screen,
let radius = screen.value(forKey: key) as? NSNumber,
radius.doubleValue > 0
else { return 44 }
return CGFloat(radius.doubleValue)
}
}
#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,13 +26,17 @@ 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
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true // The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= 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)
@@ -44,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(
@@ -54,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)
@@ -71,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)
@@ -104,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))
Text(row.value) // Keyed by the value so a change slides the new option in instead of
.font(.geist(15, .medium, relativeTo: .callout)) // hard-swapping the string a QUIET horizontal slip following the user's
.foregroundStyle(focused ? .white : .white.opacity(0.6)) // motion (a right-step enters from the right), crossfading over ~14 pt.
.lineLimit(1) // 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)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6))
.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)
@@ -190,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()
} }
@@ -249,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",
@@ -271,10 +303,12 @@ struct GamepadSettingsView: View {
options: SettingsOptions.padTypes, current: gamepadType options: SettingsOptions.padTypes, current: gamepadType
) { gamepadType = $0 }, ) { gamepadType = $0 },
toggleRow( choiceRow(
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
detail: "Resolution, frame rate, throughput and latency while streaming.", detail: "How much to show while streaming — Compact is a one-line pill, "
value: $hudEnabled), + "Detailed adds the latency stage breakdown.",
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
) { statsVerbosityRaw = $0 },
choiceRow( choiceRow(
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
detail: "Which corner the statistics overlay sits in.", detail: "Which corner the statistics overlay sits in.",
@@ -282,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,34 @@ 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.
static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
/// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from. /// Video-codec preference (`DefaultsKey.codec`) a soft preference the host falls back from.
/// AV1 appears only on devices with an AV1 hardware decoder (the same /// AV1 appears only on devices with an AV1 hardware decoder (the same
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) elsewhere it would be a /// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) elsewhere it would be a
@@ -101,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)] = [
@@ -120,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)))]
@@ -141,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
@@ -151,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
} }
@@ -13,6 +13,11 @@ extension SettingsView {
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch). // failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var streamModeSection: some View { @ViewBuilder var streamModeSection: some View {
Section { Section {
#if os(iOS) || os(macOS)
// Match-window (design/midstream-resolution-resize.md D1): follow the session
// window/scene, renegotiating the host mode on a resize. Off the explicit mode below.
Toggle("Match window", isOn: $matchWindow)
#endif
#if os(iOS) #if os(iOS)
iosResolutionWheel iosResolutionWheel
iosRefreshRows iosRefreshRows
@@ -35,8 +40,11 @@ extension SettingsView {
} header: { } header: {
Text("Stream mode") Text("Stream mode")
} footer: { } footer: {
Text("The host creates a virtual output at exactly this mode — " Text(matchWindow
+ "native resolution, no scaling. \(Self.bitrateFooter)") ? "The stream follows this window — the host resizes its virtual output to match "
+ "as you resize, no scaling. \(Self.bitrateFooter)"
: "The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -337,28 +345,32 @@ extension SettingsView {
#endif #endif
} }
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter it // Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default;
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a // stage-3 is the same pipeline with glass-gated present pacing a user-visible A/B while the
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like // pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale).
// the controller test. Empty in release builds (no presenter UI; stage-2 always). // Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic it
// freezes hard on a lost HEVC reference.
@ViewBuilder var presenterSection: some View { @ViewBuilder var presenterSection: some View {
#if DEBUG
Section { Section {
Picker("Presenter", selection: $presenter) { Picker("Presenter", selection: $presenter) {
Text("Stage 2 (default)").tag("stage2") Text("Stage 2 (default)").tag("stage2")
Text("Stage 3 (experimental)").tag("stage3")
#if DEBUG
Text("Stage 1 (debug)").tag("stage1") Text("Stage 1 (debug)").tag("stage1")
#endif
} }
} header: { } header: {
Text("Video presenter · debug") Text("Video presenter")
} footer: { } footer: {
Text("Stage 2 (default): explicit decode + Metal present — full HUD latency " Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays "
+ "breakdown and self-recovery from decode stalls. Stage 1: compressed video " + "running near the stream's frame rate, queued frames can add two to three "
+ "straight to the system layer; freezes on a lost HEVC reference, so it's a " + "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "debug fallback only. Applies from the next session.") + "to the display — at most one undisplayed frame in flight, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif
} }
@ViewBuilder var hdrSection: some View { @ViewBuilder var hdrSection: some View {
@@ -376,8 +388,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)
} }
@@ -385,13 +398,17 @@ extension SettingsView {
@ViewBuilder var statisticsSection: some View { @ViewBuilder var statisticsSection: some View {
Section { Section {
Toggle("Show statistics overlay", isOn: $hudEnabled) Picker("Statistics overlay", selection: $statsVerbosityRaw) {
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
Text(tier.label).tag(tier.rawValue)
}
}
Picker("Position", selection: $hudPlacement) { Picker("Position", selection: $hudPlacement) {
ForEach(HUDPlacement.allCases) { placement in ForEach(HUDPlacement.allCases) { placement in
Text(placement.label).tag(placement.rawValue) Text(placement.label).tag(placement.rawValue)
} }
} }
.disabled(!hudEnabled) .disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
@@ -54,10 +54,14 @@ extension SettingsView {
// MARK: - Statistics // MARK: - Statistics
static var statisticsFooter: String { static var statisticsFooter: String {
let base = "Shows resolution, frame rate, throughput and latency in the chosen " let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
+ "corner while streaming." + "pill, Normal adds resolution and latency, Detailed adds the latency stage "
#if os(macOS) || os(iOS) + "breakdown."
return base + " Toggle it any time with ⌃⌥⇧S." #if os(macOS)
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
#elseif os(iOS)
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
+ "any time."
#else #else
return base return base
#endif #endif
@@ -21,10 +21,11 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.streamWidth) var width = 1920 @AppStorage(DefaultsKey.streamWidth) var width = 1920
@AppStorage(DefaultsKey.streamHeight) var height = 1080 @AppStorage(DefaultsKey.streamHeight) var height = 1080
@AppStorage(DefaultsKey.streamHz) var hz = 60 @AppStorage(DefaultsKey.streamHz) var hz = 60
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
@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,18 +33,18 @@ 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
@AppStorage(DefaultsKey.codec) var codec = "auto" @AppStorage(DefaultsKey.codec) var codec = "auto"
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true // The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
@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
@@ -294,14 +295,16 @@ struct SettingsView: View {
}) })
} }
private var hudEnabledTag: Binding<String> {
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
}
private var hdrEnabledTag: Binding<String> { private var hdrEnabledTag: Binding<String> {
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
@@ -327,32 +330,30 @@ 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)
} }
TVSelectionRow( TVSelectionRow(
title: "Compositor", options: SettingsOptions.compositors, title: "Compositor", options: SettingsOptions.compositors,
selection: $compositor) selection: $compositor)
#if DEBUG
TVSelectionRow( TVSelectionRow(
title: "Presenter (debug)", title: "Presenter",
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")], options: SettingsOptions.presenters,
selection: $presenter) selection: $presenter)
#endif
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
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)
TVSelectionRow( TVSelectionRow(
title: "Statistics overlay", title: "Statistics overlay",
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag) options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
TVSelectionRow( TVSelectionRow(
title: "Statistics position", options: SettingsOptions.hudPlacements, title: "Statistics position", options: SettingsOptions.hudPlacements,
selection: $hudPlacement) selection: $hudPlacement)
@@ -366,8 +367,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")
@@ -113,11 +113,11 @@ public final class InputCapture {
/// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which /// keyDown monitor only WHILE FORWARDING that's the state in which the app's menu (which
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the /// carries the same key equivalents for discoverability) can't see them, so the monitor is the
/// captured-state delivery path; released, the events pass through and the menu handles them. /// captured-state delivery path; released, the events pass through and the menu handles them.
/// Q releases the captured mouse/keyboard; D disconnects; S toggles the stats /// Q releases the captured mouse/keyboard; D disconnects; S cycles the stats
/// overlay. Main queue. /// overlay tier (off compact normal detailed). Main queue.
public var onReleaseCapture: (() -> Void)? public var onReleaseCapture: (() -> Void)?
public var onDisconnect: (() -> Void)? public var onDisconnect: (() -> Void)?
public var onToggleStats: (() -> Void)? public var onCycleStats: (() -> Void)?
/// Fired when a newer InputCapture takes the process-global GC handler slots (the /// Fired when a newer InputCapture takes the process-global GC handler slots (the
/// singletons hold ONE handler each): the preempted owner must drop its capture /// singletons hold ONE handler each): the preempted owner must drop its capture
@@ -246,7 +246,7 @@ public final class InputCapture {
return nil return nil
case 1 /* S */: case 1 /* S */:
self.suppressedVK = 0x53 self.suppressedVK = 0x53
self.onToggleStats?() self.onCycleStats?()
return nil return nil
default: default:
break break
@@ -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
@@ -2,7 +2,8 @@
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel // touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
// identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger // identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag // tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
// (text selection / window moves) · three-finger tap = stats-HUD toggle: // (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
// (off compact normal detailed, matching Android):
// //
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's // * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
// relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it // relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
@@ -164,7 +165,7 @@ final class TouchMouse {
} else if !moved { } else if !moved {
switch maxFingers { switch maxFingers {
case 3...: case 3...:
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android Self.cycleStats() // in-stream stats-tier cycle, same as Android
case 2: // two-finger tap right click case 2: // two-finger tap right click
send?(.mouseButton(Button.right, down: true)) send?(.mouseButton(Button.right, down: true))
send?(.mouseButton(Button.right, down: false)) send?(.mouseButton(Button.right, down: false))
@@ -274,12 +275,11 @@ final class TouchMouse {
} }
} }
/// Three-finger tap toggles the stats overlay through the shared `hudEnabled` default, /// Three-finger tap cycles the stats overlay tiers (off compact normal detailed)
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them). /// through the shared `statsVerbosity` default, which the app's HUD views observe via
private static func toggleHUD() { /// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
let defaults = UserDefaults.standard private static func cycleStats() {
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true StatsVerbosity.store(StatsVerbosity.current.next())
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
} }
} }
#endif #endif
@@ -11,6 +11,15 @@ public enum DefaultsKey {
public static let streamWidth = "punktfunk.width" public static let streamWidth = "punktfunk.width"
public static let streamHeight = "punktfunk.height" public static let streamHeight = "punktfunk.height"
public static let streamHz = "punktfunk.hz" public static let streamHz = "punktfunk.hz"
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): when on, the
/// stream mode FOLLOWS the session view the connect asks for the view's pixel size and a
/// mid-session resize (a windowed macOS window, an iPad Stage Manager / Split View scene)
/// renegotiates the host's virtual display + encoder (`PunktfunkConnection.requestMode`), so a
/// windowed session streams native-resolution pixels instead of scaling. Off (default): the
/// explicit `streamWidth`/`streamHeight` are used and never auto-resized (a fullscreen session
/// is native either way, so this degenerates to Auto-native there). Read per session by the
/// stream views' `MatchWindowFollower`.
public static let matchWindow = "punktfunk.matchWindow"
public static let compositor = "punktfunk.compositor" public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType" public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID" public static let gamepadID = "punktfunk.gamepadID"
@@ -30,6 +39,11 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic /// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices. /// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel" public static let micChannel = "punktfunk.micChannel"
/// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// system-layer fallback). Resolved once per session by SessionPresenter;
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
public static let presenter = "punktfunk.presenter" public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync /// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes /// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
@@ -67,9 +81,15 @@ public enum DefaultsKey {
public static let libraryEnabled = "punktfunk.libraryEnabled" public static let libraryEnabled = "punktfunk.libraryEnabled"
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle /// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
/// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard). /// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
/// written (absent-or-true .normal, explicit false .off). Never written anymore.
public static let hudEnabled = "punktfunk.hudEnabled" public static let hudEnabled = "punktfunk.hudEnabled"
/// The statistics overlay tier a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
/// "detailed"). Absent migrated from the legacy `hudEnabled` bool (see above). Cycle it
/// while streaming with S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
/// keyboard) or a three-finger tap (touch), matching the Android client.
public static let statsVerbosity = "punktfunk.statsVerbosity"
/// Which corner the statistics overlay sits in a `HUDPlacement` raw value /// Which corner the statistics overlay sits in a `HUDPlacement` raw value
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
public static let hudPlacement = "punktfunk.hudPlacement" public static let hudPlacement = "punktfunk.hudPlacement"
@@ -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,58 @@
// The stats overlay's verbosity tier a port of the Android client's 3-tier overlay semantics
// so the two clients feel identical: Off Compact (one-line pill) Normal (headline stats)
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
// the in-stream cycle surfaces (S, the three-finger tap) advance it with
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
//
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
// tap, InputCapture's captured-state S) cycle it directly.
import Foundation
/// How much of the streaming statistics overlay to show. The raw values are stable on disk
/// rename the cases freely, never the strings.
public enum StatsVerbosity: String, CaseIterable, Sendable {
case off, compact, normal, detailed
/// User-facing tier label (Settings pickers, the gamepad settings row).
public var label: String {
switch self {
case .off: return "Off"
case .compact: return "Compact"
case .normal: return "Normal"
case .detailed: return "Detailed"
}
}
/// The next tier in the cycle: off compact normal detailed off (wrapping)
/// the S / three-finger-tap order, same as Android.
public func next() -> StatsVerbosity {
switch self {
case .off: return .compact
case .compact: return .normal
case .normal: return .detailed
case .detailed: return .off
}
}
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true `.normal`
/// (the old "on" look minus the equation lines), explicit false `.off`.
public static var current: StatsVerbosity {
let defaults = UserDefaults.standard
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
let tier = StatsVerbosity(rawValue: raw) {
return tier
}
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
return .off
}
return .normal
}
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
/// same key via @AppStorage).
public static func store(_ tier: StatsVerbosity) {
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
}
}
@@ -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))
}
}
@@ -0,0 +1,153 @@
// Match-window resize follower (design/midstream-resolution-resize.md D1/D2, client C3).
//
// The presenting view feeds this its PHYSICAL-PIXEL size on every layout; it debounces to
// resize-end, spaces requests 1 s apart, and asks the connection to switch the host's virtual
// display + encoder to match (`PunktfunkConnection.requestMode`) so a windowed macOS session or
// an iPad Stage Manager / Split View scene streams native-resolution pixels instead of scaling.
// The decode/present side needs nothing: VideoToolbox recreates its session on the keyframe-derived
// format-description change (the first new-mode AU is an IDR with fresh parameter sets).
//
// The trigger discipline is the shared cross-client one (mirrors the session binary's
// `resize_decision`): physical pixels rounded DOWN to even (the host rejects odd dimensions) and
// clamped 320×200; debounce to resize-end; 1 s between requests; skip a size equal to the live
// mode; and request each distinct size at most once which both stops re-asking a rejected size
// and keeps a host-side rollback (accepted, rebuild failed, corrective ack restored the old mode)
// from looping request rollback request.
import Foundation
/// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
public enum MatchWindow {
/// Even-floor + clamp a physical-pixel size to a host-valid mode dimension: the host's
/// `validate_dimensions` rejects odd sizes, and we never ask below 320×200.
public static func normalize(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
let evenClamp: (Int, UInt32) -> UInt32 = { px, minimum in
let even = UInt32(max(px, 0)) / 2 * 2
return max(even, minimum)
}
return (evenClamp(widthPx, 320), evenClamp(heightPx, 200))
}
/// Whether to request `target` now (the debounce has already settled; spacing is the caller's
/// timer): `nil` to skip equal to the live mode, or already requested once (a rejected size /
/// a host rollback must not loop). `target` is expected already-[normalize]d.
public static func request(
target: (width: UInt32, height: UInt32),
current: (width: UInt32, height: UInt32),
lastRequested: (width: UInt32, height: UInt32)?
) -> (width: UInt32, height: UInt32)? {
if target.width == current.width, target.height == current.height { return nil }
if let lr = lastRequested, lr.width == target.width, lr.height == target.height { return nil }
return target
}
}
/// Owns the debounce timer + serialization state and drives `PunktfunkConnection.requestMode` from
/// the stream view's layout callbacks. Main-actor: the views feed it on the main thread and it reads
/// the connection's live mode there. Enabled per session from the `matchWindow` setting.
@MainActor
public final class MatchWindowFollower {
private weak var connection: PunktfunkConnection?
private let debounce: TimeInterval
private let minSpacing: TimeInterval
private var enabled: Bool
private var work: DispatchWorkItem?
private var pendingSize: (width: Int, height: Int)?
private var lastRequested: (width: UInt32, height: UInt32)?
private var lastRequestAt: Date?
/// The last size we reported via [`onResizeTarget`] dedups the per-layout stream of a drag so
/// the UI is notified once per distinct target, and reset to `nil` when the window is back in
/// sync with the live mode (so a later resize re-reports).
private var lastSteered: (width: UInt32, height: UInt32)?
/// Fired (on the main actor) the instant the window starts differing from the live mode i.e.
/// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the
/// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the
/// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target.
public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)?
/// `debounce` = quiet time after the last size event before requesting (Win32 gets
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
/// (a full host pipeline rebuild each). Defaults match the other clients.
public init(
connection: PunktfunkConnection,
enabled: Bool,
debounce: TimeInterval = 0.4,
minSpacing: TimeInterval = 1.0
) {
self.connection = connection
self.enabled = enabled
self.debounce = debounce
self.minSpacing = minSpacing
}
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
public func setEnabled(_ on: Bool) {
enabled = on
if !on {
work?.cancel()
work = nil
pendingSize = nil
lastSteered = nil
}
}
/// Feed the presenting view's current PHYSICAL-PIXEL size (its `bounds` × the backing/display
/// scale). Called from every layout pass; coalesced by the debounce so a drag-resize sends one
/// request at its end, never one per frame.
public func noteSize(widthPx: Int, heightPx: Int) {
guard enabled else { return }
pendingSize = (widthPx, heightPx)
schedule()
reportSteering(widthPx: widthPx, heightPx: heightPx)
}
/// Report the resize overlay's START signal (deduped): the moment the normalized window size
/// differs from the live mode we're steering toward a new size. No connection / no negotiated
/// mode yet nothing to compare against, skip.
private func reportSteering(widthPx: Int, heightPx: Int) {
guard let connection else { return }
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return }
if target.width == mode.width, target.height == mode.height {
lastSteered = nil // back in sync a later change re-reports
return
}
if lastSteered?.width == target.width, lastSteered?.height == target.height { return }
lastSteered = target
onResizeTarget?(target.width, target.height)
}
private func schedule() {
work?.cancel()
let item = DispatchWorkItem { [weak self] in self?.fire() }
work = item
DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item)
}
private func fire() {
guard enabled, let connection, let size = pendingSize else { return }
// 1 s spacing: a request went out recently re-arm the debounce and retry later rather
// than fire early (keeps at most ~one request outstanding the accept ack round-trips in
// milliseconds, ahead of the host's rebuild).
if let last = lastRequestAt, Date().timeIntervalSince(last) < minSpacing {
schedule()
return
}
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
let mode = connection.currentMode()
pendingSize = nil
guard let req = MatchWindow.request(
target: target,
current: (mode.width, mode.height),
lastRequested: lastRequested
) else { return }
// Keep the current refresh Match-window follows SIZE, not rate.
connection.requestMode(width: req.width, height: req.height, refreshHz: mode.refreshHz)
lastRequested = req
lastRequestAt = Date()
}
}
@@ -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 {
@@ -0,0 +1,63 @@
// Resize-in-progress indicator state (design/midstream-resolution-resize.md client UX).
//
// A mid-stream resize takes the host 0.32 s to rebuild its virtual display + encoder, and the
// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale
// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a
// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp
// new-resolution frame is on screen so the wait reads as intentional, not as lag.
//
// This is driven ENTIRELY by signals the client already has (no new protocol):
// * START the Match-window follower reports the size it is steering toward (instant, on the
// first resize layout, before the debounced request even leaves).
// * END the decode pipeline reports each new-mode IDR's dimensions; when they reach the target
// the new picture is here.
// * TIMEOUT the safety net for a switch that never delivers the exact target: the host rejected
// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size.
//
// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI
// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published.
import Foundation
/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes
/// `ProcessInfo.processInfo.systemUptime` or a test clock).
public struct ResizeIndicator {
/// Whether the blur + spinner should be shown.
public private(set) var active = false
/// The size the follower is steering toward cleared once a decoded frame reaches it.
private var target: (width: UInt32, height: UInt32)?
/// When the current `active` span began the timeout is measured from here.
private var since: TimeInterval?
/// How long to keep the overlay up if the target frame never arrives (rejected / capped switch).
public var timeout: TimeInterval
public init(timeout: TimeInterval = 2.5) { self.timeout = timeout }
/// The follower is steering toward `width`×`height` a resize is under way. Show the overlay now
/// (instant feedback). Called only for a genuine change (the follower skips a target equal to the
/// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the
/// target actually changes so a slow drag never trips it mid-gesture.
public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) {
if !active || target?.width != width || target?.height != height {
since = now
}
target = (width, height)
active = true
}
/// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it
/// matches the steered target the sharp new-resolution picture is on glass.
public mutating func decoded(width: UInt32, height: UInt32) {
guard active, let t = target, t.width == width, t.height == height else { return }
active = false
since = nil
}
/// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame
/// (a rejected or host-capped switch never delivers the exact target).
public mutating func tick(now: TimeInterval) {
guard active, let s = since, now - s >= timeout else { return }
active = false
since = nil
}
}
@@ -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
@@ -22,6 +25,45 @@ public final class DisplayLinkProxy: NSObject {
@objc public func tick(_ link: CADisplayLink) { onTick(link) } @objc public func tick(_ link: CADisplayLink) { onTick(link) }
} }
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// glass-gated present pacing (`PresentPacing` see Stage2Pipeline for the tradeoff, and why
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable {
case stage1
case stage2
case stage3
/// 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)
/// falls back to the platform default. `allowStage1` is false in release builds, where a
/// 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 {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2
case "stage3": return .stage3
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 {
private var pump: StreamPump? private var pump: StreamPump?
private var stage2: Stage2Pipeline? private var stage2: Stage2Pipeline?
@@ -43,25 +85,32 @@ final class SessionPresenter {
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
stop() stop()
self.connection = connection self.connection = connection
// Presenter choice stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a // Presenter choice stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a
// CAMetalLayer/display-link present): it can detect + recover a wedged decoder where // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where
// stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is
// reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1 // the same pipeline with glass-gated present pacing (the settings picker's live A/B see
// pump below stays the automatic fallback if Metal is missing). // PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it
// back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing).
#if DEBUG #if DEBUG
let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" let allowStage1 = true
#else #else
let forceStage1 = false let allowStage1 = false
#endif #endif
if !forceStage1, let choice = PresenterChoice.resolve(
setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter),
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
if choice != .stage1,
let pipeline = Stage2Pipeline( let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter) { displayMeter: displayMeter,
pacing: choice == .stage3 ? .glass : .arrival) {
let metal = pipeline.layer let metal = pipeline.layer
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which
// sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout().
@@ -80,12 +129,14 @@ final class SessionPresenter {
link.add(to: .main, forMode: .common) link.add(to: .main, forMode: .common)
stage2Link = link stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz) syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
} else { } else {
let pump = StreamPump() let pump = StreamPump()
pump.start( pump.start(
connection: connection, layer: baseLayer, connection: connection, layer: baseLayer,
onFrame: onFrame, onSessionEnd: onSessionEnd) onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump self.pump = pump
} }
} }
@@ -149,6 +200,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
@@ -18,6 +18,10 @@
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one // V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a // period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage. // schedule can never sit far in the future holding drawables hostage.
// Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents to ONE undisplayed drawable so the layer's
// FIFO image queue can never saturate see PresentPacing's doc for the full rationale.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI). // Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
// //
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and // The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
@@ -98,6 +102,79 @@ private final class VsyncClock: @unchecked Sendable {
} }
} }
/// When a ready frame is pushed to the layer the stage-2 vs stage-3 presenter split. Same decode
/// half, same newest-wins ring; only the present cadence differs.
///
/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while
/// the layer's image queue is shallow but that queue is FIFO and consumed at one drawable per
/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band
/// presents the same way when composited), so at stream rate refresh rate its depth is STICKY:
/// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and with
/// arrivals and latches then running at the same rate it never drains. Every later frame rides
/// ~23 refreshes of queue (the measured 2930 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports).
/// - `glass` (stage-3, experimental): at most ONE presented-but-undisplayed drawable in flight
/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the
/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
/// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops.
public enum PresentPacing: Sendable {
case arrival
case glass
}
/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render
/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and
/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never
/// fires (the macOS "out-of-band presents aren't damage" hazard class see MetalVideoPresenter's
/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a
/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0
/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded the
/// releaser runs on a Metal callback thread.
final class PresentGate: @unchecked Sendable {
/// How long one pending present may hold the gate before it's presumed lost.
static let staleAfter: CFTimeInterval = 0.1
private let lock = NSLock()
private var pending = false
private var armedAt: CFTimeInterval = 0
private var forced = 0
/// Arm the gate for one present. False = a present is already in flight (and not stale)
/// leave the frame in the ring; the presented handler's release/re-signal (or the next
/// display-link tick) retries with the freshest frame then.
func tryAcquire(now: CFTimeInterval) -> Bool {
lock.lock()
defer { lock.unlock() }
if pending {
guard now - armedAt > Self.staleAfter else { return false }
forced += 1 // presumed-lost present reopen rather than stall the stream
}
pending = true
armedAt = now
return true
}
/// The in-flight present reached glass (or was dropped, or its render failed before a present
/// was registered) reopen. Idempotent: a late stale-path double-release is harmless.
func release() {
lock.lock()
pending = false
lock.unlock()
}
/// Take-and-reset the force-open count (PUNKTFUNK_PRESENT_DEBUG's `forced` stat).
func drainForced() -> Int {
lock.lock()
defer { lock.unlock() }
let n = forced
forced = 0
return n
}
}
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with /// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
/// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas /// the decode rate, render outcomes, the slowest render call ( nextDrawable wait) and the deltas
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period /// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
@@ -105,22 +182,39 @@ private final class VsyncClock: @unchecked Sendable {
private final class PresentDebugStats: @unchecked Sendable { private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var last = CACurrentMediaTime() private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0 private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
private var maxRenderMs = 0.0 private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0 private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = [] private var glassDeltasMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 should peg it at 1).
private var inFlight = 0
private var maxInFlight = 0
func emptyWake() { lock.lock(); empty += 1; lock.unlock() } func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
/// A wake that found the stage-3 gate closed (a present still in flight) the frame stays in
/// the ring for the handler's re-signal. Includes display-link ticks while gated; a high count
/// is normal, it just shows the gate working.
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) { func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock() lock.lock()
if rendered { ok += 1 } else { failed += 1 } if rendered {
ok += 1
inFlight += 1
maxInFlight = max(maxInFlight, inFlight)
} else {
failed += 1
}
maxRenderMs = max(maxRenderMs, tookMs) maxRenderMs = max(maxRenderMs, tookMs)
lock.unlock() lock.unlock()
} }
func presented(atNs: Int64?) { func presented(atNs: Int64?) {
lock.lock() lock.lock()
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
if let atNs { if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) } if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs lastGlassNs = atNs
@@ -130,7 +224,7 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock() lock.unlock()
} }
func flushIfDue(ring: ReadyRing) { func flushIfDue(ring: ReadyRing, gate: PresentGate?) {
lock.lock() lock.lock()
let now = CACurrentMediaTime() let now = CACurrentMediaTime()
guard now - last >= 1 else { lock.unlock(); return } guard now - last >= 1 else { lock.unlock(); return }
@@ -139,12 +233,15 @@ private final class PresentDebugStats: @unchecked Sendable {
let deltas = glassDeltasMs.sorted() let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2] let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0 let dMax = deltas.last ?? 0
let inflightMax = maxInFlight
let line = String( let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d dropped=%d " format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
+ "maxRenderMs=%.1f glassDeltaMs p50=%.2f max=%.2f n=%d", + "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, dropped, maxRenderMs, p50, dMax, deltas.count) decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
ok = 0; failed = 0; empty = 0; dropped = 0 gate?.drainForced() ?? 0, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
maxRenderMs = 0 maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true) glassDeltasMs.removeAll(keepingCapacity: true)
lock.unlock() lock.unlock()
print(line) print(line)
@@ -156,6 +253,9 @@ public final class Stage2Pipeline {
private let ring = ReadyRing() private let ring = ReadyRing()
private let presenter: MetalVideoPresenter private let presenter: MetalVideoPresenter
private let decoder: VideoDecoder private let decoder: VideoDecoder
/// Present cadence `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for
/// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing).
private let pacing: PresentPacing
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
private let recovery = KeyframeRecovery() private let recovery = KeyframeRecovery()
@@ -190,14 +290,17 @@ public final class Stage2Pipeline {
/// (receiveddecoded); `displayMeter` the display stage (decodedon-glass, the ring wait + /// (receiveddecoded); `displayMeter` the display stage (decodedon-glass, the ring wait +
/// render + vsync the tail stage-2 exists to shorten). All optional: metering never gates /// render + vsync the tail stage-2 exists to shorten). All optional: metering never gates
/// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) caller /// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) caller
/// falls back to the stage-1 presenter. /// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3
/// (glass-gated) present cadence see PresentPacing.
public init?( public init?(
endToEndMeter: LatencyMeter?, endToEndMeter: LatencyMeter?,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil,
pacing: PresentPacing = .arrival
) { ) {
guard let presenter = MetalVideoPresenter.make() else { return nil } guard let presenter = MetalVideoPresenter.make() else { return nil }
self.presenter = presenter self.presenter = presenter
self.pacing = pacing
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
let ring = ring let ring = ring
@@ -226,7 +329,8 @@ public final class Stage2Pipeline {
public func start( public func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
offsetNs = connection.clockOffsetNs offsetNs = connection.clockOffsetNs
recovery.bind(connection) // arm host-keyframe recovery for this session recovery.bind(connection) // arm host-keyframe recovery for this session
@@ -247,6 +351,9 @@ public final class Stage2Pipeline {
let thread = Thread { let thread = Thread {
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset() defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
var format: CMVideoFormatDescription? var format: CMVideoFormatDescription?
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
// loss-recovery IDR at the same size (see StreamPump).
var lastDecodedDims: CMVideoDimensions?
var lastFramesDropped = connection.framesDropped() var lastFramesDropped = connection.framesDropped()
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale): // Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent. // keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
@@ -255,7 +362,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
@@ -275,13 +387,18 @@ 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)
let dims = CMVideoFormatDescriptionGetDimensions(f)
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
lastDecodedDims = dims
onDecodedSize?(Int(dims.width), Int(dims.height))
}
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 {
@@ -294,12 +411,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
}
} }
} }
} }
@@ -327,17 +446,34 @@ public final class Stage2Pipeline {
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync)) && UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock let vsyncClock = vsyncClock
// Stage-3's one-in-flight present gate; nil = stage-2's present-on-arrival. A local (like
// the ring) so neither the render thread nor the presented handlers capture `self`.
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) 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
// keep coalescing there (newest wins, the intended drop point) and the presented
// handler re-signals the moment the slot frees. Checked BEFORE the take so a gated
// frame is never bounced through putBack.
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
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
debugStats?.emptyWake() debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring) 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.
@@ -347,6 +483,12 @@ public final class Stage2Pipeline {
let rendered = presenter.render( let rendered = presenter.render(
frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt frame.pixelBuffer, isHDR: frame.isHDR, presentAtMediaTime: presentAt
) { presentedNs in ) { presentedNs in
// Stage-3: the flip reached glass (or was dropped) free the present slot,
// then re-signal so the freshest waiting ring frame goes out immediately.
if let gate {
gate.release()
renderSignal.signal()
}
// Fallback stamp for a dropped drawable (no system presentedTime): "now" on // Fallback stamp for a dropped drawable (no system presentedTime): "now" on
// the Metal callback, converted to the CLOCK_REALTIME the meters live in. // the Metal callback, converted to the CLOCK_REALTIME the meters live in.
let atNs = presentedNs let atNs = presentedNs
@@ -361,9 +503,12 @@ public final class Stage2Pipeline {
} }
debugStats?.renderReturned( debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000) ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered { ring.putBack(frame) } if !rendered {
debugStats?.flushIfDue(ring: ring) gate?.release() // no present registered its handler will never fire
} ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: gate)
} }
} }
renderThread.name = "punktfunk-stage2-render" renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive renderThread.qualityOfService = .userInteractive
@@ -387,6 +532,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).
@@ -21,7 +21,8 @@ final class StreamPump {
connection: PunktfunkConnection, connection: PunktfunkConnection,
layer: AVSampleBufferDisplayLayer, layer: AVSampleBufferDisplayLayer,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
let token = token let token = token
// Coalesced host keyframe requests (100 ms throttle see KeyframeRecovery). // Coalesced host keyframe requests (100 ms throttle see KeyframeRecovery).
@@ -35,6 +36,9 @@ final class StreamPump {
let thread = Thread { let thread = Thread {
var format: CMVideoFormatDescription? var format: CMVideoFormatDescription?
// Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR),
// not on every loss-recovery IDR at the same size so it fires once per real switch.
var lastDecodedDims: CMVideoDimensions?
var lastFramesDropped = connection.framesDropped() var lastFramesDropped = connection.framesDropped()
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a // Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a // decoder reset), retry the throttled request EVERY iteration, and clear it only when a
@@ -47,7 +51,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,11 +78,16 @@ 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 {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
let dims = CMVideoFormatDescriptionGetDimensions(f)
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
lastDecodedDims = dims
onDecodedSize?(Int(dims.width), Int(dims.height))
}
if awaitingIDR { if awaitingIDR {
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000) let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms") pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
@@ -97,13 +111,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
}
} }
} }
} }
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
private let onDisconnectRequest: (() -> Void)? private let onDisconnectRequest: (() -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
onDisconnectRequest: (() -> Void)? = nil, onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
self.onDisconnectRequest = onDisconnectRequest self.onDisconnectRequest = onDisconnectRequest
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view return view
} }
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the // SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed. // connection identity actually changed.
if view.connection !== connection { if view.connection !== connection {
@@ -165,6 +175,9 @@ public final class StreamLayerView: NSView {
/// 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()
public private(set) var connection: PunktfunkConnection? public private(set) var connection: PunktfunkConnection?
/// Match-window resize follower (C3) non-nil while a session is active AND the `matchWindow`
/// setting is on; fed the view's physical-pixel size on every relayout.
private var matchFollower: MatchWindowFollower?
private let cursorCapture = CursorCapture() private let cursorCapture = CursorCapture()
private var inputCapture: InputCapture? private var inputCapture: InputCapture?
private var appObservers: [NSObjectProtocol] = [] private var appObservers: [NSObjectProtocol] = []
@@ -201,6 +214,13 @@ public final class StreamLayerView: NSView {
/// view can't do that itself (the connection's owner disconnects). /// view can't do that itself (the connection's owner disconnects).
public var onDisconnectRequest: (() -> Void)? public var onDisconnectRequest: (() -> Void)?
/// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget`
/// (main thread, via the follower) fires the instant the window starts steering toward a new
/// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives
/// the blur+spinner from these set before `start()`.
public var onResizeTarget: ((UInt32, UInt32) -> Void)?
public var onDecodedSize: (@Sendable (Int, Int) -> Void)?
/// Main-thread only. False = input capture disabled outright (UI layered over the /// Main-thread only. False = input capture disabled outright (UI layered over the
/// stream); flipping to true auto-engages once. /// stream); flipping to true auto-engages once.
public var captureEnabled = true { public var captureEnabled = true {
@@ -594,13 +614,12 @@ public final class StreamLayerView: NSView {
guard let self, self.window?.isKeyWindow == true else { return } guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?() self.onDisconnectRequest?()
} }
capture.onToggleStats = { [weak self] in capture.onCycleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return } guard self?.window?.isKeyWindow == true else { return }
// Flip the shared setting directly every @AppStorage reader (the HUD's visibility, // Advance the shared tier setting directly every @AppStorage reader (the HUD's
// the menu item's title) observes UserDefaults, so this is the same as the menu path. // visibility/content, the Settings pickers) observes UserDefaults, so this is the
let defaults = UserDefaults.standard // same as the menu path.
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true StatsVerbosity.store(StatsVerbosity.current.next())
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
} }
capture.start() capture.start()
inputCapture = capture inputCapture = capture
@@ -627,15 +646,32 @@ public final class StreamLayerView: NSView {
displayMeter: displayMeter, displayMeter: displayMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) }, makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd) onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
// session start (mirrors the other clients); the first real `layout()` feeds the initial
// size, so the stream converges to the window even if the connect used the explicit mode.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower
layoutPresenter() layoutPresenter()
requestAutoCapture() // entering a session is the deliberate "capture me" moment requestAutoCapture() // entering a session is the deliberate "capture me" moment
} }
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a /// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
/// retinanon-retina move (see SessionPresenter.layout). /// retinanon-retina move (see SessionPresenter.layout). Also feeds the Match-window follower
/// the view's physical-pixel size (bounds backing), so a window resize / retina move follows.
private func layoutPresenter() { private func layoutPresenter() {
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
// Feed the follower only once in a window (backing scale is real then) and with real
// bounds a pre-window layout would report point-sized dimensions.
if window != nil, bounds.width > 0, bounds.height > 0 {
let px = convertToBacking(bounds).size
matchFollower?.noteSize(
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
}
} }
public override func viewDidChangeBackingProperties() { public override func viewDidChangeBackingProperties() {
@@ -651,6 +687,7 @@ public final class StreamLayerView: NSView {
inputCapture?.stop() inputCapture?.stop()
inputCapture = nil inputCapture = nil
presenter.stop() presenter.stop()
matchFollower = nil
connection = nil connection = nil
} }
@@ -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
@@ -50,6 +53,8 @@ public struct StreamView: UIViewControllerRepresentable {
private let onCaptureChange: ((Bool) -> Void)? private let onCaptureChange: ((Bool) -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)? private let onSessionEnd: (@Sendable () -> Void)?
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
private let endToEndMeter: LatencyMeter? private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
@@ -65,6 +70,8 @@ public struct StreamView: UIViewControllerRepresentable {
onDisconnectRequest: (() -> Void)? = nil, onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil
@@ -74,6 +81,8 @@ public struct StreamView: UIViewControllerRepresentable {
self.onCaptureChange = onCaptureChange self.onCaptureChange = onCaptureChange
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
@@ -86,6 +95,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return controller return controller
} }
@@ -96,6 +107,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
if controller.connection !== connection { if controller.connection !== connection {
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
} }
@@ -108,7 +121,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 +145,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
@@ -126,6 +157,11 @@ public final class StreamViewController: UIViewController {
/// Capture state at the last resign, restored on the next foreground otherwise the /// Capture state at the last resign, restored on the next foreground otherwise the
/// mouse/keyboard stay released after navigating out and nothing re-grabs them. /// mouse/keyboard stay released after navigating out and nothing re-grabs them.
private var wasCapturedOnResign = false private var wasCapturedOnResign = false
/// Match-window resize follower (C3) non-nil while a session is active AND the `matchWindow`
/// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad
/// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone
/// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead).
private var matchFollower: MatchWindowFollower?
#endif #endif
/// Reads whether the scene's pointer is actually locked right now; nil = state /// Reads whether the scene's pointer is actually locked right now; nil = state
@@ -140,6 +176,13 @@ public final class StreamViewController: UIViewController {
} }
var onCaptureChange: ((Bool) -> Void)? var onCaptureChange: ((Bool) -> Void)?
/// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the
/// blur+spinner the instant the window differs from the live mode (iOS only tvOS has no
/// follower). See `MatchWindowFollower.onResizeTarget`.
var onResizeTarget: ((UInt32, UInt32) -> Void)?
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
/// overlay clears when a frame at the requested size actually decodes.
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
var captureEnabled = true { var captureEnabled = true {
didSet { didSet {
@@ -157,6 +200,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 +279,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)?,
@@ -288,6 +349,14 @@ public final class StreamViewController: UIViewController {
} }
capture.start() capture.start()
inputCapture = capture inputCapture = capture
// Match-window (C3): follow the scene's pixel size when the setting is on. Latched at
// session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it covers
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
follower.onResizeTarget = onResizeTarget
matchFollower = follower
#endif #endif
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2 // Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
@@ -301,7 +370,8 @@ public final class StreamViewController: UIViewController {
displayMeter: displayMeter, displayMeter: displayMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd) onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
layoutMetalLayer() layoutMetalLayer()
#if os(iOS) #if os(iOS)
@@ -342,6 +412,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() {
@@ -359,6 +442,13 @@ public final class StreamViewController: UIViewController {
streamView.onPointerButton = nil streamView.onPointerButton = nil
streamView.onScroll = nil streamView.onScroll = nil
streamView.currentHostMode = nil streamView.currentHostMode = nil
matchFollower = nil
#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 #endif
presenter.stop() presenter.stop()
connection = nil connection = nil
@@ -367,8 +457,60 @@ public final class StreamViewController: UIViewController {
public override func viewDidLayoutSubviews() { public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
layoutMetalLayer() layoutMetalLayer()
#if os(iOS)
// Match-window (C3): feed the follower the view's physical-pixel size (points × scale).
let b = streamView.bounds
if b.width > 0, b.height > 0 {
let scale = renderScale
matchFollower?.noteSize(
widthPx: Int((b.width * scale).rounded()),
heightPx: Int((b.height * scale).rounded()))
}
#endif
#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)")
@@ -0,0 +1,43 @@
// The Match-window trigger discipline (design/midstream-resolution-resize.md D2), as pure
// functions the same rules the session binary's `resize_decision` unit-tests: physical pixels
// even-floored and clamped 320×200, skip a size equal to the live mode, and request each
// distinct size at most once (so a rejected size / a host rollback can't loop).
import XCTest
@testable import PunktfunkKit
final class MatchWindowTests: XCTestCase {
func testNormalizeEvenFloorsAndClamps() {
// Odd pixels floor to even (the host rejects odd dimensions).
let a = MatchWindow.normalize(widthPx: 1001, heightPx: 601)
XCTAssertEqual(a.width, 1000)
XCTAssertEqual(a.height, 600)
// Already-even sizes pass through.
let b = MatchWindow.normalize(widthPx: 2560, heightPx: 1440)
XCTAssertEqual(b.width, 2560)
XCTAssertEqual(b.height, 1440)
// Tiny / zero clamp to the host floor.
let c = MatchWindow.normalize(widthPx: 100, heightPx: 80)
XCTAssertEqual(c.width, 320)
XCTAssertEqual(c.height, 200)
let z = MatchWindow.normalize(widthPx: 0, heightPx: -4)
XCTAssertEqual(z.width, 320)
XCTAssertEqual(z.height, 200)
}
func testRequestSkipsEqualAndAlreadyRequested() {
// A new size (different from the live mode, not yet requested) request it.
let r = MatchWindow.request(
target: (1000, 600), current: (1280, 720), lastRequested: (800, 500))
XCTAssertEqual(r?.width, 1000)
XCTAssertEqual(r?.height, 600)
// Equal to the live mode nothing to do.
XCTAssertNil(MatchWindow.request(
target: (1280, 720), current: (1280, 720), lastRequested: nil))
// Already requested once don't re-ask (covers a rejected size AND a host rollback:
// accepted rebuild failed corrective ack restored the old mode must not loop).
XCTAssertNil(MatchWindow.request(
target: (1000, 600), current: (1280, 720), lastRequested: (1000, 600)))
}
}
@@ -0,0 +1,83 @@
import XCTest
#if canImport(Metal)
import QuartzCore
@testable import PunktfunkKit
/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice`
/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate).
final class PresentPacingTests: XCTestCase {
// MARK: - PresentGate
/// The core invariant: one present in flight. A second acquire while pending must fail (the
/// frame stays in the ring for the presented handler's re-signal); release reopens.
func testGateAdmitsOneInFlightPresent() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
XCTAssertFalse(gate.tryAcquire(now: 0.001), "a pending present must close the gate")
gate.release()
XCTAssertTrue(gate.tryAcquire(now: 0.002), "release must reopen the gate")
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
}
/// The lost-handler insurance: a present whose handler never fires (the macOS "presents
/// aren't damage" hazard class) must not freeze the stream past `staleAfter` the gate
/// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat.
func testGateForceOpensAfterStaleTimeout() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 10))
// Within the stale window the gate stays closed.
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
// Past it, the pending present is presumed lost: reopen, and count the force-clear.
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
XCTAssertEqual(gate.drainForced(), 1)
XCTAssertEqual(gate.drainForced(), 0, "drain resets the counter")
}
/// Release is idempotent (a late stale-path double-release must be harmless), and an
/// acquire-then-release with no present (empty ring after arming) leaves the gate clean.
func testGateReleaseIsIdempotent() {
let gate = PresentGate()
XCTAssertTrue(gate.tryAcquire(now: 0))
gate.release()
gate.release() // the stale-cleared present's handler firing late
XCTAssertTrue(gate.tryAcquire(now: 0.001))
XCTAssertEqual(gate.drainForced(), 0)
}
// MARK: - PresenterChoice
func testPresenterChoiceDefaultsToStage2() {
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
}
func testPresenterChoiceResolvesStage3FromSettingAndEnv() {
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: nil, allowStage1: true), .stage3)
// The env override wins over the persisted setting (A/B without touching settings)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: "stage3", allowStage1: true), .stage3)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: "stage2", allowStage1: true), .stage2)
// but an EMPTY env var is "unset", not an override.
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
}
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
/// builds); a leftover "stage1" value in a release build maps back to stage-2.
func testPresenterChoiceGatesStage1() {
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
}
}
#endif
@@ -0,0 +1,52 @@
import XCTest
@testable import PunktfunkKit
final class ResizeIndicatorTests: XCTestCase {
func testInactiveUntilSteered() {
var r = ResizeIndicator()
XCTAssertFalse(r.active)
// A decoded frame with nothing pending is a no-op (session start / steady state).
r.decoded(width: 1920, height: 1080)
XCTAssertFalse(r.active)
}
func testSteeringActivatesAndDecodedTargetClears() {
var r = ResizeIndicator()
r.steering(width: 2560, height: 1440, now: 0)
XCTAssertTrue(r.active)
// A frame at a DIFFERENT size (the old mode still draining) doesn't clear it.
r.decoded(width: 1920, height: 1080)
XCTAssertTrue(r.active)
// The target frame lands clear.
r.decoded(width: 2560, height: 1440)
XCTAssertFalse(r.active)
}
func testTimeoutClearsWhenTargetNeverArrives() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2560, height: 1440, now: 10)
r.tick(now: 12) // 2 s < timeout still up
XCTAssertTrue(r.active)
r.tick(now: 12.6) // 2.6 s timeout a rejected/capped switch clears
XCTAssertFalse(r.active)
}
func testDragReArmsTimeoutOnEachNewTarget() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2000, height: 1200, now: 0)
r.steering(width: 2200, height: 1200, now: 2) // target changed since re-armed to 2
r.tick(now: 4) // only 2 s since the last change still up (drag isn't a timeout)
XCTAssertTrue(r.active)
r.tick(now: 4.6) // 2.6 s since the last change clears
XCTAssertFalse(r.active)
}
func testSteadyDragDoesNotResetTimeout() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2560, height: 1440, now: 0)
r.steering(width: 2560, height: 1440, now: 1) // SAME target since stays 0
r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer clears (not reset by the repeat)
XCTAssertFalse(r.active)
}
}
@@ -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".
+2
View File
@@ -76,6 +76,8 @@ restart is required for an out-of-band install to appear.
| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. | | `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. | | `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. |
| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. | | `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. | | `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=<id>` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=<port>` (on-screen library); ids are validated space/quote-free at pin AND launch time. |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. |
+13 -10
View File
@@ -1,17 +1,20 @@
# punktfunk — Linux client # punktfunk — Linux client
The native **Linux** app for streaming a punktfunk host to your desktop, laptop, or Steam Deck. The native **Linux** app for streaming a punktfunk host to your desktop, laptop, or Steam Deck.
It's a clean GTK4/libadwaita app that finds hosts on your network, pairs with a PIN, and puts a It's a clean relm4/GTK4/libadwaita **shell** that finds hosts on your network, pairs with a PIN,
low-latency stream on glass at your display's own resolution and refresh rate. and manages your settings and library — the stream itself runs in the sibling
**`punktfunk-session`** Vulkan binary ([`clients/session`](../session/README.md)), which the shell
spawns, putting the picture on glass at your display's own resolution and refresh rate.
Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and speaks the fast Built in Rust end to end (no C ABI): the shell shares its plumbing with the session binary through
**`crates/pf-client-core`**, which links the **`punktfunk-core`** protocol crate and speaks the fast
**`punktfunk/1`** protocol — QUIC control plane, GF(2¹⁶) FEC + AES-GCM data plane. **`punktfunk/1`** protocol — QUIC control plane, GF(2¹⁶) FEC + AES-GCM data plane.
## Features ## Features
- **Zero-copy hardware decode**FFmpeg VAAPI decode → DRM-PRIME dmabuf → `GdkDmabufTexture` - **Zero-copy hardware decode**the session presenter decodes via **Vulkan Video** on every GPU
(Tier-1 zero-copy on Intel and AMD), with an automatic software-HEVC fallback on NVIDIA or when vendor (including NVIDIA), falling back to FFmpeg VAAPI → DRM-PRIME dmabuf and then software when
VAAPI is unavailable. Vulkan Video is unavailable.
- **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no - **Your display's native mode** — the host builds a virtual output at exactly your WxH@Hz; no
scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN. scaling, no letterboxing. Steady 60 fps at 1080p60, ~6 ms capture→decoded on the LAN.
- **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host. - **Audio both ways** — PipeWire playback with a jitter ring, plus mic uplink to the host.
@@ -26,10 +29,10 @@ Built in Rust, it links the shared **`punktfunk-core`** directly (no C ABI) and
shows its games (Steam + custom) as a poster grid; click one to launch it in the session. shows its games (Steam + custom) as a poster grid; click one to launch it in the session.
Fetched from the host's management API over mTLS — paired devices are authorized by their Fetched from the host's management API over mTLS — paired devices are authorized by their
certificate, no extra host setup. certificate, no extra host setup.
- **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven coverflow of - **Gamepad library launcher** (`--browse host`) — a console-style, controller-driven library view
a paired host's library (drifting aurora backdrop, center-focus posters, button hints): A plays of a paired host's games, rendered by the session binary's Skia console UI: A plays the focused
the focused title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; title, B quits, L1/R1 jump. Built for the Steam Deck plugin's "Open library" launch; session end
session end returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed). returns to the launcher. Arrow keys/Enter/Esc drive it too (no pad needed).
## Get it ## Get it
+3 -2
View File
@@ -496,7 +496,8 @@ 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, // launch: probe connect, no game None, // display_hdr: probe connect, nothing presents
None, // launch: probe connect, no game
pin, pin,
Some(identity), Some(identity),
std::time::Duration::from_secs(15), std::time::Duration::from_secs(15),
@@ -670,7 +671,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
</child> </child>
<child> <child>
<object class="GtkShortcutsShortcut"> <object class="GtkShortcutsShortcut">
<property name="title">Toggle statistics overlay</property> <property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
<property name="accelerator">&lt;Control&gt;&lt;Alt&gt;&lt;Shift&gt;s</property> <property name="accelerator">&lt;Control&gt;&lt;Alt&gt;&lt;Shift&gt;s</property>
</object> </object>
</child> </child>
+46 -23
View File
@@ -3,6 +3,7 @@
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use pf_client_core::trust::StatsVerbosity;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
@@ -263,21 +264,23 @@ pub fn show(
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build(); let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS // The D1 tri-state: Native, Match window (a virtual index 1, stored as the
.iter() // `match_window` flag), then the explicit sizes.
.map(|&(w, h)| { let res_names: Vec<String> = std::iter::once("Native display".to_string())
if w == 0 { .chain(std::iter::once("Match window".to_string()))
"Native display".to_string() .chain(
} else { RESOLUTIONS
format!("{w} × {h}") .iter()
} .skip(1)
}) .map(|&(w, h)| format!("{w} × {h}")),
)
.collect(); .collect();
let res_row = ChoiceRow::new( let res_row = ChoiceRow::new(
&dialog, &dialog,
inline, inline,
"Resolution", "Resolution",
"The host creates a virtual output at exactly this size", "The host creates a virtual output at exactly this size — Match window follows \
the stream window, including mid-stream resizes",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
let hz_names: Vec<String> = REFRESH let hz_names: Vec<String> = REFRESH
@@ -324,10 +327,13 @@ pub fn show(
"Software", "Software",
], ],
); );
let stats_row = adw::SwitchRow::builder() let stats_row = ChoiceRow::new(
.title("Show statistics overlay") &dialog,
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") inline,
.build(); "Statistics overlay",
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
&["Off", "Compact", "Normal", "Detailed"],
);
let fullscreen_row = adw::SwitchRow::builder() let fullscreen_row = adw::SwitchRow::builder()
.title("Start streams in fullscreen") .title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
@@ -338,7 +344,7 @@ pub fn show(
stream.add(compositor_row.widget()); stream.add(compositor_row.widget());
stream.add(decoder_row.widget()); stream.add(decoder_row.widget());
stream.add(&fullscreen_row); stream.add(&fullscreen_row);
stream.add(&stats_row); stream.add(stats_row.widget());
let input = adw::PreferencesGroup::builder().title("Input").build(); let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently connected // Which physical controller forwards as pad 0: automatic = the most recently connected
@@ -466,10 +472,15 @@ pub fn show(
// Seed from the current settings. // Seed from the current settings.
{ {
let s = settings.borrow(); let s = settings.borrow();
let res_i = RESOLUTIONS let res_i = if s.match_window {
.iter() 1
.position(|&(w, h)| w == s.width && h == s.height) } else {
.unwrap_or(0); RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.map(|i| if i == 0 { 0 } else { i + 1 })
.unwrap_or(0)
};
res_row.set_selected(res_i as u32); res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32); hz_row.set_selected(hz_i as u32);
@@ -483,7 +494,11 @@ pub fn show(
compositor_row.set_selected(comp_i as u32); compositor_row.set_selected(comp_i as u32);
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
decoder_row.set_selected(dec_i as u32); decoder_row.set_selected(dec_i as u32);
stats_row.set_active(s.show_stats); let stats_i = StatsVerbosity::ALL
.iter()
.position(|v| *v == s.stats_verbosity())
.unwrap_or(0);
stats_row.set_selected(stats_i as u32);
fullscreen_row.set_active(s.fullscreen_on_stream); fullscreen_row.set_active(s.fullscreen_on_stream);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled); mic_row.set_active(s.mic_enabled);
@@ -500,8 +515,14 @@ pub fn show(
dialog.add(&page); dialog.add(&page);
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut(); let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)]; // Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
(s.width, s.height) = (w, h); let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
s.match_window = res_i == 1;
(s.width, s.height) = if res_i <= 1 {
(0, 0)
} else {
RESOLUTIONS[res_i - 1]
};
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
@@ -509,7 +530,9 @@ pub fn show(
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
s.show_stats = stats_row.is_active(); s.set_stats_verbosity(
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
);
s.fullscreen_on_stream = fullscreen_row.is_active(); s.fullscreen_on_stream = fullscreen_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active(); s.mic_enabled = mic_row.is_active();
+113 -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> {
@@ -145,11 +150,35 @@ fn load_or_create_identity() -> Result<(String, String)> {
let dir = std::path::PathBuf::from(home).join(".config/punktfunk"); let dir = std::path::PathBuf::from(home).join(".config/punktfunk");
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
// Re-lock a store an older build left world-readable (this key is shared with the other
// clients' `~/.config/punktfunk/client-key.pem`); best-effort.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::set_permissions(&kp, std::fs::Permissions::from_mode(0o600));
}
return Ok((c, k)); return Ok((c, k));
} }
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
// The certificate is public; the key is the mTLS credential a paired host authorizes for full
// remote control, so it must not be world-readable — create it 0600 (a plain `fs::write`
// honors the umask → typically 0644).
std::fs::write(&cp, &c)?; std::fs::write(&cp, &c)?;
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&kp)?;
f.write_all(k.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(&kp, &k)?; std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity"); tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k)) Ok((c, k))
@@ -250,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"),
} }
} }
@@ -461,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(),
) )
@@ -499,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,
@@ -512,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.
@@ -936,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) =
@@ -947,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
@@ -955,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();
@@ -980,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
@@ -1221,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 {
+11 -7
View File
@@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
Stdout is the machine interface: `{"ready":true}` after the first presented frame, Stdout is the machine interface: `{"ready":true}` after the first presented frame,
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one `stats: …` once per second while the overlay tier isn't Off (always the full detailed
text, whatever the OSD shows; `--stats` forces the overlay on), one
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0` `{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
init failed. init failed.
@@ -31,21 +32,24 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller
chord (L1+R1+Start+Select, hold to disconnect) works the same. chord (L1+R1+Start+Select, hold to disconnect) works the same.
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror). hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one
line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder
path + per-stage latency equation); any tier but Off also emits the stdout mirror.
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout `--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
only, no Skia anywhere in the dependency tree. only, no Skia anywhere in the dependency tree.
Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software): Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software on Linux,
FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack Vulkan Video → D3D11VA → software on Windows): FFmpeg's Vulkan Video decoder runs on the
supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere; presenter's own device where the stack supports it (every vendor, zero copy); VAAPI
software is the universal fallback. 10-bit Main10 and HDR10 are advertised dmabufs import per-plane elsewhere (D3D11VA textures on Windows); software is the
universal fallback. 10-bit Main10 and HDR10 are advertised
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present (`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff, tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT` default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy. policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|software`, `PUNKTFUNK_PRESENT_MODE= Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike `PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
demotion to software on healthy hardware). demotion to software on healthy hardware).
+9 -1
View File
@@ -135,12 +135,20 @@ pub fn run(target: Option<&str>) -> u8 {
), ),
fullscreen: fullscreen_mode(), fullscreen: fullscreen_mode(),
window_pos: window_pos(), window_pos: window_pos(),
print_stats: settings_at_start.show_stats || arg_flag("--stats"), // `--stats` forces the overlay visible without demoting a richer chosen tier.
stats_verbosity: match settings_at_start.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
json_status, json_status,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
trust::touch_last_used(&trust::hex(&fingerprint)); trust::touch_last_used(&trust::hex(&fingerprint));
})), })),
overlay: Some(Box::new(overlay)), overlay: Some(Box::new(overlay)),
window_size: crate::session_main::window_size(&settings_at_start),
// Latched at console start (like the stats tier above): toggling Match window in
// the console's settings screen applies from the next console launch.
match_window: crate::session_main::match_window(&settings_at_start),
}; };
let result = let result =
+40 -1
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).
@@ -160,6 +164,34 @@ mod session_main {
} }
} }
/// The window's starting size under Match-window: the persisted last size, so the
/// first connect's mode already matches the glass; `None` (policy off / never
/// stored) = the 1280×720 default.
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
.then_some((settings.last_window_w, settings.last_window_h))
}
/// The Match-window policy hook for the presenter loop
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
/// logical window size (load-modify-save, like the console settings screen) so the
/// next launch opens at it.
pub(crate) fn match_window(
settings: &trust::Settings,
) -> Option<Box<dyn FnMut(u32, u32)>> {
settings.match_window.then(|| {
Box::new(|w: u32, h: u32| {
let mut s = trust::Settings::load();
if (s.last_window_w, s.last_window_h) != (w, h) {
s.last_window_w = w;
s.last_window_h = h;
s.save();
}
}) as Box<dyn FnMut(u32, u32)>
})
}
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via /// One JSON status line on stdout (the shell parses these; strings hand-escaped via
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its /// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
/// failure through the same contract when spawned with `--json-status`. /// failure through the same contract when spawned with `--json-status`.
@@ -322,7 +354,12 @@ mod session_main {
window_title: format!("Punktfunk · {title}"), window_title: format!("Punktfunk · {title}"),
fullscreen, fullscreen,
window_pos: window_pos(), window_pos: window_pos(),
print_stats: settings.show_stats || arg_flag("--stats"), // `--stats` forces the overlay visible (tooling/debug runs) without
// demoting an explicitly chosen richer tier.
stats_verbosity: match settings.stats_verbosity() {
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
v => v,
},
json_status: true, json_status: true,
on_connected: Some(Box::new(|fingerprint: [u8; 32]| { on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
// This host's card carries the accent bar in the desktop client now. // This host's card carries the accent bar in the desktop client now.
@@ -334,6 +371,8 @@ mod session_main {
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())), overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
#[cfg(not(feature = "ui"))] #[cfg(not(feature = "ui"))]
overlay: None, overlay: None,
window_size: window_size(&settings),
match_window: match_window(&settings),
}; };
let outcome = let outcome =
+2 -1
View File
@@ -26,7 +26,8 @@ the fast **`punktfunk/1`** protocol.
(probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**. (probe burst over the real data plane → recommended bitrate, applied in one tap) and **forget**.
- **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder / - **Polished shell** — host cards, settings (resolution / refresh / host compositor / decoder /
codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels / codec / bitrate / HDR / forwarded controller / gamepad type / system shortcuts / audio channels /
mic), a status-chip stream HUD, and the full trust surface. Stream input uses Win32 low-level mic / stats-overlay level), the tiered stats overlay (Off / Compact / Normal / Detailed —
Ctrl+Alt+Shift+S cycles it live in the session window), and the full trust surface. Stream input uses Win32 low-level
hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream hooks with Moonlight-style capture: Ctrl+Alt+Shift+Q releases the pointer, a click on the stream
re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host. re-captures it, and system shortcuts (Alt+Tab, Win, …) can act locally or forward to the host.
+4 -1
View File
@@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
"Release captured input (click the stream to recapture)", "Release captured input (click the stream to recapture)",
), ),
("Ctrl+Alt+Shift+D", "Disconnect"), ("Ctrl+Alt+Shift+D", "Disconnect"),
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), (
"Ctrl+Alt+Shift+S",
"Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)",
),
( (
"LB+RB+Start+Back", "LB+RB+Start+Back",
"Controller: release input / leave fullscreen \u{2014} hold to disconnect", "Controller: release input / leave fullscreen \u{2014} hold to disconnect",
+40 -24
View File
@@ -4,6 +4,7 @@
use super::style::*; use super::style::*;
use super::{AppCtx, Screen}; use super::{AppCtx, Screen};
use crate::trust::Settings; use crate::trust::Settings;
use pf_client_core::trust::StatsVerbosity;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
use std::sync::Arc; use std::sync::Arc;
use windows_reactor::*; use windows_reactor::*;
@@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[
("xboxone", "Xbox One"), ("xboxone", "Xbox One"),
("dualshock4", "DualShock 4"), ("dualshock4", "DualShock 4"),
]; ];
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
(StatsVerbosity::Off, "Off"),
(StatsVerbosity::Compact, "Compact"),
(StatsVerbosity::Normal, "Normal"),
(StatsVerbosity::Detailed, "Detailed"),
];
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to /// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host. /// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
const COMPOSITORS: &[(&str, &str)] = &[ const COMPOSITORS: &[(&str, &str)] = &[
@@ -127,29 +136,37 @@ pub(crate) fn settings_page(
let s = ctx.settings.lock().unwrap().clone(); let s = ctx.settings.lock().unwrap().clone();
// --- Display --------------------------------------------------------------------------- // --- Display ---------------------------------------------------------------------------
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes.
let (res_names, res_i) = { let (res_names, res_i) = {
let names: Vec<String> = RESOLUTIONS let names: Vec<String> = std::iter::once("Native display".to_string())
.iter() .chain(std::iter::once("Match window".to_string()))
.map(|&(w, h)| { .chain(
if w == 0 { RESOLUTIONS
"Native display".into() .iter()
} else { .skip(1)
format!("{w} \u{00D7} {h}") .map(|&(w, h)| format!("{w} \u{00D7} {h}")),
} )
})
.collect(); .collect();
let i = RESOLUTIONS let i = if s.match_window {
.iter() 1
.position(|&(w, h)| w == s.width && h == s.height) } else {
.unwrap_or(0); RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.map(|i| if i == 0 { 0 } else { i + 1 })
.unwrap_or(0)
};
(names, i) (names, i)
}; };
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| { let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
(s.width, s.height) = RESOLUTIONS[i]; s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
}) })
.tooltip( .tooltip(
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \ "The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
resolves to the monitor this window is on at connect.", resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
follows the stream window, including mid-stream resizes.",
); );
let (hz_names, hz_i) = { let (hz_names, hz_i) = {
let names: Vec<String> = REFRESH let names: Vec<String> = REFRESH
@@ -328,15 +345,14 @@ pub(crate) fn settings_page(
) )
.tooltip("Sends the default microphone to the host's virtual mic source."); .tooltip("Sends the default microphone to the host's virtual mic source.");
let hud_toggle = setting_toggle( let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
ctx, let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
"Show the stats overlay (HUD)", s.set_stats_verbosity(STATS_TIERS[i].0);
s.show_stats, })
|s, on| s.show_stats = on,
)
.tooltip( .tooltip(
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \ "How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
Ctrl+Alt+Shift+S toggles it live while streaming.", in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
); );
let licenses_button = { let licenses_button = {
@@ -368,7 +384,7 @@ pub(crate) fn settings_page(
codec_combo.into(), codec_combo.into(),
bitrate_box.into(), bitrate_box.into(),
hdr_toggle.into(), hdr_toggle.into(),
hud_toggle.into(), hud_combo.into(),
]); ]);
controls controls
}), }),
+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);
} }
+37 -14
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,25 +271,16 @@ 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)
None, // launch: the Windows client has no library picker yet display_hdr,
None, // launch: the Windows client has no library picker yet
params.pin, params.pin,
Some(params.identity), Some(params.identity),
params.connect_timeout, params.connect_timeout,
@@ -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);
+117 -18
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,32 +1250,101 @@ 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) {
@@ -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,
} }
} }
} }
+41 -10
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) => {
// `samples` is per-channel; the interleaved frame is `samples * channels`. // Conceal lost packets (a seq gap) with libopus PLC before decoding the one
Ok(samples) => { // that arrived: empty input synthesizes `frame_samples` of interpolation per
let n = samples * channels as usize; // missing packet — an inaudible fade instead of the click a hard gap makes.
let mut buf = player.take_buffer(); for _ in 0..gaps.missing_before(pkt.seq) {
buf.extend_from_slice(&pcm[..n]); let plc = frame_samples * channels as usize;
player.push(buf); 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);
}
} }
Err(e) => tracing::debug!(error = %e, "opus decode"), match dec.decode_float(&pkt.data, &mut pcm, false) {
}, // `samples` is per-channel; the interleaved frame is `samples * channels`.
Ok(samples) => {
frame_samples = samples;
let n = samples * channels as usize;
let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..n]);
player.push(buf);
}
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
} }
+154 -3
View File
@@ -34,16 +34,61 @@ pub fn load_or_create_identity() -> Result<(String, String)> {
let dir = config_dir()?; let dir = config_dir()?;
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
// An older build wrote the key with a plain `fs::write`, which honors the umask and
// typically lands 0644 — world-readable. Re-lock an existing store on load so upgrades
// get fixed, not just fresh installs. Best-effort (a read-only store keeps what it has).
#[cfg(unix)]
lock_identity_perms(&dir, &kp);
return Ok((c, k)); return Ok((c, k));
} }
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
// The private key authorizes this client for full remote control of a paired host, so it must
// never be world-readable: lock the dir to the owner (0700) and create the key 0600 from the
// start (`fs::write` alone honors the umask → typically 0644). The certificate is public. On
// non-Unix the %APPDATA% profile ACL already scopes the dir to the user, so std perms suffice.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
std::fs::write(&cp, &c)?; std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?; write_private_key(&kp, k.as_bytes())?;
tracing::info!(cert = %cp.display(), "generated client identity"); tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k)) Ok((c, k))
} }
/// Write the client's mTLS private key owner-only. On Unix the file is created with mode 0600 from
/// the outset — an `fs::write` + later `chmod` would briefly expose it at the umask default. On
/// other platforms std's default perms plus the %APPDATA% profile ACL scope it to the user.
fn write_private_key(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)?;
}
#[cfg(not(unix))]
std::fs::write(path, bytes)?;
Ok(())
}
/// Best-effort re-lock of an already-present identity (dir 0700, key 0600) — for stores written by
/// an older build that left the key world-readable. Errors are ignored: the worst case is the
/// pre-existing perms, which this never loosens.
#[cfg(unix)]
fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
}
pub fn hex(fp: &[u8; 32]) -> String { pub fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect() fp.iter().map(|b| format!("{b:02x}")).collect()
} }
@@ -254,6 +299,50 @@ pub fn probe_reachable_many(
.collect() .collect()
} }
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
/// across every client (design/stats-unification.md): each tier is a strict superset of
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StatsVerbosity {
Off,
/// One glanceable line: fps · end-to-end ms · Mb/s.
Compact,
/// Stream mode plus the end-to-end latency percentiles and loss counters.
Normal,
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
Detailed,
}
impl StatsVerbosity {
/// Cycle order (also the settings pickers' option order).
pub const ALL: [StatsVerbosity; 4] = [
StatsVerbosity::Off,
StatsVerbosity::Compact,
StatsVerbosity::Normal,
StatsVerbosity::Detailed,
];
/// The next tier in the live cycle, wrapping back to Off.
pub fn next(self) -> StatsVerbosity {
match self {
StatsVerbosity::Off => StatsVerbosity::Compact,
StatsVerbosity::Compact => StatsVerbosity::Normal,
StatsVerbosity::Normal => StatsVerbosity::Detailed,
StatsVerbosity::Detailed => StatsVerbosity::Off,
}
}
pub fn label(self) -> &'static str {
match self {
StatsVerbosity::Off => "Off",
StatsVerbosity::Compact => "Compact",
StatsVerbosity::Normal => "Normal",
StatsVerbosity::Detailed => "Detailed",
}
}
}
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
/// stays readable; parsed with `*Pref::from_name` at connect time. /// stays readable; parsed with `*Pref::from_name` at connect time.
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -301,16 +390,35 @@ pub struct Settings {
/// `default = true`: the Linux stores never carried this and always advertised. /// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub hdr_enabled: bool, pub hdr_enabled: bool,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). /// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`. /// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
/// this as `show_hud`.
#[serde(alias = "show_hud")] #[serde(alias = "show_hud")]
pub show_stats: bool, pub show_stats: bool,
/// Stats overlay tier. `None` = a pre-tier store; resolve through
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
#[serde(skip_serializing_if = "Option::is_none")]
pub stats_verbosity: Option<StatsVerbosity>,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge /// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless. /// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool, pub fullscreen_on_stream: bool,
/// Experimental: the game-library browser ("Browse library…" on saved cards) — /// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off. /// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool, pub library_enabled: bool,
/// Match-window resolution policy (design/midstream-resolution-resize.md D1): the
/// stream mode follows the session window — the connect asks for the window's pixel
/// size and a mid-session resize renegotiates the host's virtual display + encoder
/// (`Reconfigure`), so windowed sessions stream native-resolution pixels instead of
/// scaling. Overrides `width`/`height` while on; on fullscreen it degenerates to the
/// display's native mode. Default off (Auto-native stays the shipped default until
/// the per-backend validation matrix is green).
pub match_window: bool,
/// The session window's last logical size under `match_window`: the next launch
/// opens its window at this size, so the first connect's mode already matches what
/// the user will be looking at. `0` = never stored → the 1280×720 default.
pub last_window_w: u32,
pub last_window_h: u32,
} }
fn default_codec() -> String { fn default_codec() -> String {
@@ -322,6 +430,23 @@ fn default_true() -> bool {
} }
impl Settings { impl Settings {
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
pub fn stats_verbosity(&self) -> StatsVerbosity {
self.stats_verbosity.unwrap_or(if self.show_stats {
StatsVerbosity::Normal
} else {
StatsVerbosity::Off
})
}
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
/// binaries that read the same settings file.
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
self.stats_verbosity = Some(v);
self.show_stats = v != StatsVerbosity::Off;
}
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
pub fn preferred_codec(&self) -> u8 { pub fn preferred_codec(&self) -> u8 {
match self.codec.as_str() { match self.codec.as_str() {
@@ -351,8 +476,12 @@ impl Default for Settings {
adapter: String::new(), adapter: String::new(),
hdr_enabled: true, hdr_enabled: true,
show_stats: true, show_stats: true,
stats_verbosity: None,
fullscreen_on_stream: true, fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
match_window: false,
last_window_w: 0,
last_window_h: 0,
} }
} }
} }
@@ -432,6 +561,28 @@ mod tests {
assert!(!s.library_enabled); assert!(!s.library_enabled);
} }
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
#[test]
fn stats_verbosity_migrates_and_round_trips() {
let mut s: Settings = serde_json::from_str("{}").unwrap();
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
s.set_stats_verbosity(StatsVerbosity::Compact);
assert!(s.show_stats);
s.set_stats_verbosity(StatsVerbosity::Off);
assert!(!s.show_stats);
s.set_stats_verbosity(StatsVerbosity::Detailed);
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
// The tier serializes lowercase — the file stays human-readable.
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
}
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same /// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
/// filename, same directory, so on Windows the two clients genuinely share the store. /// filename, same directory, so on Windows the two clients genuinely share the store.
#[test] #[test]
+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.

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