21 Commits

Author SHA1 Message Date
enricobuehler d55cde61d3 style: cargo fmt — settle the CSC/tvOS changes' layout (CI Format gate)
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
android / android (push) Has started running
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
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has started running
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
flatpak / build-publish (push) Has started running
docker / deploy-docs (push) Waiting to run
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has started running
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
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
release / apple (push) Waiting to run
windows / build (aarch64-pc-windows-msvc) (push) Waiting to run
windows / build (x86_64-pc-windows-msvc) (push) Waiting to run
android / android (push) Has started running
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
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has started running
ci / bench (push) Successful in 5m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has started running
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
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has started running
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
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
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
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
106 changed files with 9620 additions and 4465 deletions
+8
View File
@@ -2517,6 +2517,10 @@
"type": "object", "type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": { "properties": {
"ddc_power_off": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
},
"game_session": { "game_session": {
"$ref": "#/components/schemas/GameSession", "$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched." "description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
@@ -2539,6 +2543,10 @@
"mode_conflict": { "mode_conflict": {
"$ref": "#/components/schemas/ModeConflict" "$ref": "#/components/schemas/ModeConflict"
}, },
"pnp_disable_monitors": {
"type": "boolean",
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
},
"preset": { "preset": {
"$ref": "#/components/schemas/Preset" "$ref": "#/components/schemas/Preset"
}, },
+28 -3
View File
@@ -355,10 +355,34 @@ fn decode_loop(
}; };
let mut pcm = vec![0f32; pcm_scratch]; let mut pcm = vec![0f32; pcm_scratch];
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
while !shutdown.load(Ordering::Relaxed) { let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
'pump: while !shutdown.load(Ordering::Relaxed) {
match client.next_audio(Duration::from_millis(5)) { match client.next_audio(Duration::from_millis(5)) {
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) { Ok(pkt) => {
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
for _ in 0..gaps.missing_before(pkt.seq) {
let plc = frame_samples * channels;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
}
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = free_rx
.try_recv()
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
buf.clear();
buf.extend_from_slice(&pcm[..samples * channels]);
match tx.try_send(buf) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break 'pump,
}
}
}
match dec.decode_float(&pkt.data, &mut pcm, false) {
Ok(samples) => { Ok(samples) => {
frame_samples = samples;
let n = samples * channels; let n = samples * channels;
for &s in &pcm[..n] { for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs()); window_peak = window_peak.max(s.abs());
@@ -393,7 +417,8 @@ fn decode_loop(
} }
} }
Err(e) => log::debug!("audio: opus decode: {e}"), Err(e) => log::debug!("audio: opus decode: {e}"),
}, }
}
Err(PunktfunkError::NoFrame) => {} // timeout Err(PunktfunkError::NoFrame) => {} // timeout
Err(_) => break, // session closed Err(_) => break, // session closed
} }
+84 -39
View File
@@ -18,7 +18,7 @@ use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Frame; use punktfunk_core::session::Frame;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void; use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{mpsc, Arc, Mutex}; use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -202,6 +202,8 @@ fn run_sync(
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input // The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe // pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
// round-trip) and we only pop the next one once it's queued. // round-trip) and we only pop the next one once it's queued.
@@ -213,12 +215,12 @@ fn run_sync(
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated // Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the // host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
// HUD flags it "(same-host clock)"). // HUD flags it "(same-host clock)").
let clock_offset = client.clock_offset_ns; let clock_offset = client.clock_offset_shared();
// Display stage (spec `display` + the capture→displayed headline): frames released with // Display stage (spec `display` + the capture→displayed headline): frames released with
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with // render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount, // SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below. // reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset); let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let render_cb = install_render_callback(&codec, &tracker); let render_cb = install_render_callback(&codec, &tracker);
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded // HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back // point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
@@ -256,6 +258,7 @@ fn run_sync(
// the output buffer) for the decoded-point pairing in `drain`. // the output buffer) for the decoded-point pairing in `drain`.
if stats.enabled() { if stats.enabled() {
let received_ns = now_realtime_ns(); let received_ns = now_realtime_ns();
let clock_offset = clock_offset.load(Ordering::Relaxed);
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128; let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000) let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
.then_some((lat_ns / 1000) as u64); .then_some((lat_ns / 1000) as u64);
@@ -295,7 +298,13 @@ fn run_sync(
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout. // and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
let work_t0 = Instant::now(); let work_t0 = Instant::now();
if let Some(frame) = pending.take() { if let Some(frame) = pending.take() {
if feed(&codec, &frame.data, frame.pts_ns / 1000) { if feed(
&codec,
&client,
&frame.data,
frame.pts_ns / 1000,
&mut oversized_dropped,
) {
fed += 1; fed += 1;
if fed % 300 == 0 { if fed % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}"); log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
@@ -320,7 +329,7 @@ fn run_sync(
wait, wait,
&stats, &stats,
&mut in_flight, &mut in_flight,
clock_offset, clock_offset.load(Ordering::Relaxed),
&tracker, &tracker,
); );
rendered += r; rendered += r;
@@ -418,8 +427,10 @@ fn now_monotonic_ns() -> i128 {
/// endpoint whenever the platform delivers render callbacks). /// endpoint whenever the platform delivers render callbacks).
struct DisplayTracker { struct DisplayTracker {
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample. /// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample
clock_offset: i64, /// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
/// keeps the leaked render-callback refcount from pinning the whole session alive.
clock_offset: Arc<AtomicI64>,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order, /// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the /// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden. /// callback early-outs) while the overlay is hidden.
@@ -427,7 +438,10 @@ struct DisplayTracker {
} }
impl DisplayTracker { impl DisplayTracker {
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> { fn new(
stats: Arc<crate::stats::VideoStats>,
clock_offset: Arc<AtomicI64>,
) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker { Arc::new(DisplayTracker {
stats, stats,
clock_offset, clock_offset,
@@ -554,7 +568,8 @@ unsafe extern "C" fn on_frame_rendered(
} }
} }
} }
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000; let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64); let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us); t.stats.note_displayed(e2e_us, display_us);
@@ -827,13 +842,13 @@ fn run_async(
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded // pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the // output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible. // HUD is visible.
let clock_offset = client.clock_offset_ns; let clock_offset = client.clock_offset_shared();
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new())); let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is // Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// parked in the tracker at release; the OnFrameRendered callback pairs it with // parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount, // SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below. // reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset); let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let render_cb = install_render_callback(&codec, &tracker); let render_cb = install_render_callback(&codec, &tracker);
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that // Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
@@ -842,19 +857,13 @@ fn run_async(
let client = client.clone(); let client = client.clone();
let stats = stats.clone(); let stats = stats.clone();
let in_flight = in_flight.clone(); let in_flight = in_flight.clone();
let clock_offset = clock_offset.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
let ev_tx = ev_tx.clone(); let ev_tx = ev_tx.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("pf-decode-feed".into()) .name("pf-decode-feed".into())
.spawn(move || { .spawn(move || {
feeder_loop( feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
client,
stats,
in_flight,
clock_offset as i128,
shutdown,
ev_tx,
);
}) })
.ok() .ok()
}; };
@@ -878,6 +887,8 @@ fn run_async(
let mut fed: u64 = 0; let mut fed: u64 = 0;
let mut rendered: u64 = 0; let mut rendered: u64 = 0;
let mut discarded: u64 = 0; let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
let mut last_dropped = client.frames_dropped(); let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None; let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is // Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
@@ -922,14 +933,21 @@ fn run_async(
if fmt_dirty { if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds); apply_hdr_dataspace(&codec, &window, &mut applied_ds);
} }
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed); feed_ready(
&codec,
&client,
&mut pending_aus,
&mut free_inputs,
&mut fed,
&mut oversized_dropped,
);
let had_output = !ready.is_empty(); let had_output = !ready.is_empty();
present_ready( present_ready(
&codec, &codec,
&mut ready, &mut ready,
&stats, &stats,
&in_flight, &in_flight,
clock_offset, clock_offset.load(Ordering::Relaxed),
&tracker, &tracker,
&mut rendered, &mut rendered,
&mut discarded, &mut discarded,
@@ -999,7 +1017,7 @@ fn feeder_loop(
client: Arc<NativeClient>, client: Arc<NativeClient>,
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>, in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
clock_offset: i128, clock_offset: Arc<AtomicI64>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
ev_tx: mpsc::Sender<DecodeEvent>, ev_tx: mpsc::Sender<DecodeEvent>,
) { ) {
@@ -1010,6 +1028,7 @@ fn feeder_loop(
Ok(frame) => { Ok(frame) => {
if stats.enabled() { if stats.enabled() {
let received_ns = now_realtime_ns(); let received_ns = now_realtime_ns();
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128; let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
let lat_us = let lat_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64); (lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
@@ -1089,12 +1108,15 @@ fn dispatch_event(
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from /// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and /// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
/// submitted; a too-large AU is truncated (logged) rather than dropped. /// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
fn feed_ready( fn feed_ready(
codec: &MediaCodec, codec: &MediaCodec,
client: &NativeClient,
pending_aus: &mut VecDeque<Frame>, pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>, free_inputs: &mut VecDeque<usize>,
fed: &mut u64, fed: &mut u64,
oversized_dropped: &mut u64,
) { ) {
while !pending_aus.is_empty() && !free_inputs.is_empty() { while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap(); let idx = free_inputs.pop_front().unwrap();
@@ -1105,14 +1127,20 @@ fn feed_ready(
continue; continue;
}; };
let au = &frame.data; let au = &frame.data;
let n = au.len().min(dst.len()); if au.len() > dst.len() {
if n < au.len() { // The slot was never queued, so it stays ours — recycle it for the next AU.
free_inputs.push_front(idx);
*oversized_dropped += 1;
log::warn!( log::warn!(
"decode: AU {} > input buffer {}, truncated", "decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
au.len(), au.len(),
dst.len() dst.len(),
*oversized_dropped
); );
let _ = client.request_keyframe();
continue;
} }
let n = au.len();
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid // SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n]. // for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
unsafe { unsafe {
@@ -1298,27 +1326,44 @@ fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bo
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns /// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and /// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and /// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
/// parking it forever would wedge the loop on a broken codec). /// parking it forever would wedge the loop on a broken codec). An AU larger than the input
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool { /// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
/// corrupt input the decoder chews on silently, poisoning the reference chain.
fn feed(
codec: &MediaCodec,
client: &NativeClient,
au: &[u8],
pts_us: u64,
oversized_dropped: &mut u64,
) -> bool {
match codec.dequeue_input_buffer(Duration::ZERO) { match codec.dequeue_input_buffer(Duration::ZERO) {
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => { Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
let n = { let n = {
let dst = buf.buffer_mut(); let dst = buf.buffer_mut();
let n = au.len().min(dst.len()); if au.len() > dst.len() {
if n < au.len() { *oversized_dropped += 1;
log::warn!( log::warn!(
"decode: AU {} > input buffer {}, truncated", "decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
au.len(), au.len(),
dst.len() dst.len(),
*oversized_dropped
);
let _ = client.request_keyframe();
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
} else {
let n = au.len();
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
// the cast write initializes exactly `dst[..n]`.
unsafe {
std::ptr::copy_nonoverlapping(
au.as_ptr(),
dst.as_mut_ptr().cast::<u8>(),
n,
); );
} }
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer), both
// valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so the cast
// write initializes exactly `dst[..n]`.
unsafe {
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
}
n n
}
}; };
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) { if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer: {e}"); log::warn!("decode: queue_input_buffer: {e}");
@@ -376,7 +376,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -412,7 +412,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
@@ -449,7 +449,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -490,7 +490,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -522,7 +522,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -552,7 +552,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.1; MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -68,25 +68,28 @@ struct ContentView: View {
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar /// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video. /// never covers the video.
@State private var isFullscreen = false @State private var isFullscreen = false
#endif
#if os(macOS) || os(tvOS)
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability /// Shows the start-of-stream shortcut banner (the Windows client's discoverability
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own /// pattern): raised on every transition to `.streaming`, dropped by the banner's own
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with /// 6-second task. Independent of the stats HUD so the keys are discoverable even with
/// statistics off. /// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
/// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false @State private var showShortcutHint = false
#endif #endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
#if os(iOS) || os(macOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for // A connected controller (+ the Settings toggle) swaps the whole home screen for
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI see `home` below. // GamepadHomeView instead of retrofitting HomeView's touch/desktop UI see `home` below.
// On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working;
// with no (extended) controller attached tvOS falls back to HomeView as before.
@ObservedObject private var gamepadManager = GamepadManager.shared @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive( GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
} }
#endif
var body: some View { var body: some View {
Group { Group {
@@ -108,7 +111,7 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in .onChange(of: model.phase) { _, phase in
switch phase { switch phase {
case .streaming: case .streaming:
#if os(macOS) #if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start showShortcutHint = true // the 6 s shortcut banner, per session start
#endif #endif
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
@@ -278,13 +281,30 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#elseif os(iOS) #else
Group { Group {
if gamepadUIActive { if gamepadUIActive {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0) }, connectDiscovered: connectDiscovered)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
// never double-present against HomeView's routes). Menu closes a cover the same
// way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony.
#if os(tvOS)
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
.onExitCommand { libraryTarget = nil }
}
#endif
} else { } else {
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
@@ -295,14 +315,6 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) }) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#else
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
#endif #endif
} }
@@ -362,11 +374,14 @@ struct ContentView: View {
#else #else
.background(Color.black) .background(Color.black)
.ignoresSafeArea() .ignoresSafeArea()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable // SWALLOW Menu/B during a session a game controller's B button ALSO surfaces as this
// disconnect control during play, the controller's buttons flow to the host instead of // UIKit menu press, so the old instant-disconnect here ended the session on every B
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the // press in gameplay. The button still reaches the host via GamepadCapture; the
// host as Start the Siri Remote is the intended disconnect path. // DELIBERATE exits are holding the remote's Back 1 s (SiriRemotePointer) and holding
.onExitCommand { model.disconnect() } // L1+R1+Start+Select 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by
// the start-of-stream banner. The empty handler is what keeps the press from bubbling
// out and suspending the app.
.onExitCommand {}
#endif #endif
} }
@@ -418,17 +433,18 @@ struct ContentView: View {
} }
.animation(.smooth(duration: 0.28), value: statsVerbosity) .animation(.smooth(duration: 0.28), value: statsVerbosity)
} }
#if os(macOS) #if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the full // The start-of-stream shortcut banner (Windows-client parity): the platform's
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of // reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
// every session independent of the stats HUD, so the keys are discoverable // every session independent of the stats HUD, so the keys are discoverable
// even with statistics off. The banner's own task drops it (cancelled cleanly // even with statistics off. The banner's own task drops it (cancelled cleanly
// if the session view goes away first). // if the session view goes away first). On tvOS it carries the ONLY exits
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
// session branch), so the hold gestures must be told to the user.
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if captureEnabled && showShortcutHint { if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · " Text(Self.shortcutHintText)
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats") .font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -472,6 +488,17 @@ struct ContentView: View {
} }
} }
#if os(macOS)
private static let shortcutHintText =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
private static let shortcutHintFont: CGFloat = 12
#elseif os(tvOS)
private static let shortcutHintText =
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
// MARK: - Connect // MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) { private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
@@ -1,13 +1,15 @@
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) the controller counterpart of // The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) the controller counterpart of
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address / // AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings; // port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end // A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the // without touching the screen. Field edits are live (the row shows every keystroke); B closes the
// keyboard first, then cancels the screen the same "back peels one layer" rule as a console UI. // keyboard first, then cancels the screen the same "back peels one layer" rule as a console UI.
// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike
// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View { struct GamepadAddHostView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
isActive: editing == nil isActive: editing == nil
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Add Host") Text("Add Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
if !compact { if !compact {
Text("Hosts on this network appear automatically — add one by address " Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.") + "for everything else.")
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 440) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
} }
} }
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in .onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) } if value.count > 5 { port = String(value.prefix(5)) }
} }
#if os(tvOS)
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
// tray the remote and the pad both drive it natively. Same `editing` state as the
// tray, just a different presentation; done (or Menu, edits-stick) commits and returns.
.fullScreenCover(isPresented: Binding(
get: { editing != nil },
set: { if !$0 { editing = nil } })
) {
if let field = editing {
TVTextEntry(
title: fieldTitle(field),
text: editingBinding(field).wrappedValue,
keyboardType: keyboardType(field)
) { value in
commitEntry(field, value)
editing = nil
}
}
}
#endif
} }
/// The keyboard tray while editing, the controls legend otherwise. /// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the
/// tray `editing` presents the system keyboard cover instead so it's legend-only there.)
@ViewBuilder private var bottomTray: some View { @ViewBuilder private var bottomTray: some View {
#if os(tvOS)
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
])
.frame(maxWidth: .infinity, alignment: .leading)
#else
if let editing { if let editing {
VStack(spacing: 10) { VStack(spacing: 10) {
GamepadKeyboard( GamepadKeyboard(
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
]) ])
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
#endif
} }
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc /// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
@@ -111,14 +142,16 @@ struct GamepadAddHostView: View {
private var closeButton: some View { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel") .accessibilityLabel("Cancel")
} }
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
} }
private func rowView(_ row: Row, focused: Bool) -> some View { private func rowView(_ row: Row, focused: Bool) -> some View {
HStack(spacing: 14) { let m = GamepadFormMetrics.self
return HStack(spacing: 14) {
if row.isAction { if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill") Label("Add Host", systemImage: "plus.circle.fill")
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
Spacer(minLength: 12) Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value) Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(15, .medium)) .font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) // keep the end of a long address visible while typing .truncationMode(.head) // keep the end of a long address visible while typing
@@ -162,20 +196,20 @@ struct GamepadAddHostView: View {
// The live-edit caret: this row is what the keyboard tray is typing into. // The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle() Rectangle()
.fill(Color.brand) .fill(Color.brand)
.frame(width: 2, height: 18) .frame(width: 2, height: m.labelFont + 2)
} }
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row // Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
// takes the brand wash, and the edited row keeps its brand caret border. // takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder( .strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1) lineWidth: 1)
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
default: return nil default: return nil
} }
} }
#if os(tvOS)
// MARK: - System keyboard plumbing (see the fullScreenCover on `body`)
private func fieldTitle(_ id: String) -> String {
switch id {
case "name": return "Name (optional)"
case "port": return "Port"
default: return "Address (IP or hostname)"
}
}
/// .URL for the address (dots on the primary layer, no autocapitalize) the closest tvOS
/// keyboard to "hostname or IP".
private func keyboardType(_ id: String) -> UIKeyboardType {
switch id {
case "port": return .numberPad
case "address": return .URL
default: return .default
}
}
/// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke
/// on the other platforms (the system keyboard will type anything).
private func commitEntry(_ id: String, _ value: String) {
switch id {
case "port":
editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5))
case "address":
editingBinding(id).wrappedValue = value
.replacingOccurrences(of: " ", with: "")
default:
editingBinding(id).wrappedValue = value
}
}
#endif
} }
#endif #endif
@@ -1,6 +1,11 @@
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the // The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely // library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
// by a controller (iOS/iPadOS/macOS). // by a controller (iOS/iPadOS/macOS) or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
// focusable Button, so the Siri Remote and a game controller both navigate through the system
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
// carries ONLY the Y/X actions (library/settings) buttons the focus engine has no concept of.
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
// //
// The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` // The scrolling is pure native SwiftUI `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live // snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
@@ -24,7 +29,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable { struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared)
#if os(tvOS)
/// tvOS: the focus engine is the navigation authority `cursor`/`scrolledID` chase this,
/// never the other way around (mirroring the poll's cursor-first discipline).
@FocusState private var focusedID: Item.ID?
#endif
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back /// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
/// while the gamepad is driving that's the whole desync fix. /// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0 @State private var cursor = 0
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2) let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollViewReader { proxy in
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: spacing) { HStack(spacing: spacing) {
ForEach(items) { item in ForEach(items) { item in
#if os(tvOS)
// A focusable Button per card: the focus engine does the navigating
// (remote swipes and pad dpad alike), select activates. The bare style
// below keeps the tile's own look the `.scrollTransition` center pop
// is the focus treatment, since focus and center track each other.
Button { activate(item) } label: {
card(item)
.frame(width: itemWidth)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
card(item) card(item)
.frame(width: itemWidth) .frame(width: itemWidth)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(item) } .onTapGesture { tap(item) }
#endif
} }
} }
.frame(height: geo.size.height) // fill so shorter cards center vertically .frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout() .scrollTargetLayout()
} }
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
// the poll's cursor design exists to avoid see the header), and on tvOS the
// focus engine's own reveal-scrolls are always in flight, so drops were routine
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
// below instead scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
#if !os(tvOS)
.scrollPosition(id: $scrolledID) .scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden // .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip. // and paints a scroller across the console strip.
.scrollIndicators(.never) .scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds .scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset) .safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset) .offset(x: bumpOffset)
#if os(tvOS)
// Land initial focus on the first card (the launcher's first host / the coverflow's
// first title) instead of wherever the engine guesses.
.defaultFocus($focusedID, items.first?.id)
// Focus moved (remote swipe / pad dpad) chase it: cursor, detail selection,
// controller detent, and an imperative center scroll.
.onChange(of: focusedID) { _, newValue in
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
lastNav = Date()
haptics.move()
selection = newValue
withAnimation(.easeOut(duration: scrollAnim)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
// The list changed under a stable focus (discovered hosts prepend tiles): the
// content shifted but no focus change fires above re-center the focused card.
.onChange(of: items.map(\.id)) { _, _ in
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
}
#endif
}
} }
.sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a // A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
// programmatic scroll is animating (its own intermediate id write-backs would regress the // programmatic scroll is animating (its own intermediate id write-backs would regress the
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag // cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
// which never sets `isScrolling` moves the cursor here. // which never sets `isScrolling` moves the cursor here. Not on tvOS: there is no touch
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
#if !os(tvOS)
.onChange(of: scrolledID) { _, newValue in .onChange(of: scrolledID) { _, newValue in
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return } guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return } guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx cursor = idx
selection = newValue selection = newValue
} }
#endif
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the // Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
// current `items` value (a plain array unlike an observed object it would otherwise go // current `items` value (a plain array unlike an observed object it would otherwise go
// stale in the closures stored on `input`). // stale in the closures stored on `input`).
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { private func wire() {
#if os(tvOS)
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
// working on this screen and what routes Menu through the system's back semantics).
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
input.onSecondary = onSecondary
input.onTertiary = onTertiary
#else
input.onMove = { move($0) } input.onMove = { move($0) }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onSecondary = onSecondary input.onSecondary = onSecondary
input.onTertiary = onTertiary input.onTertiary = onTertiary
input.onBack = onBack input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
#endif
} }
private func move(_ direction: GamepadMenuInput.Direction) { private func move(_ direction: GamepadMenuInput.Direction) {
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
private func activate() { private func activate() {
guard cursor >= 0, cursor < items.count else { return } guard cursor >= 0, cursor < items.count else { return }
activate(items[cursor])
}
/// Shared confirm tail the poll activates the cursor's item, a tvOS Button its own.
private func activate(_ item: Item) {
activateTick &+= 1 activateTick &+= 1
haptics.confirm() haptics.confirm()
onActivate(items[cursor]) onActivate(item)
} }
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping /// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
scrolledID = id scrolledID = id
selection = id selection = id
} }
#if os(tvOS)
// Keep real focus on the reconciled item when its old target vanished from the list
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
focusedID = items[cursor].id
}
#endif
} }
private func boundaryBump(forward: Bool) { private func boundaryBump(forward: Bool) {
@@ -2,11 +2,12 @@
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the // GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// controller-glyph hint bar, and the connected-controller status chip. One look across every // controller-glyph hint bar, and the connected-controller status chip. One look across every
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages. // screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead. // iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via /// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#endif #endif
} }
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
/// in-hand compact-aware sizes elsewhere.
func gamepadTitleSize(compact: Bool) -> CGFloat {
#if os(tvOS)
44
#else
compact ? 20 : 30
#endif
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
#endif
}
/// One glyph + label cell in a hint bar. /// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable { struct GamepadHint: Identifiable {
let glyph: String let glyph: String
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
struct GamepadHintBar: View { struct GamepadHintBar: View {
let hints: [GamepadHint] let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View { var body: some View {
HStack(spacing: 18) { HStack(spacing: 18) {
ForEach(hints) { hint in ForEach(hints) { hint in
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: hint.glyph) Image(systemName: hint.glyph)
.font(.system(size: 19)) .font(.system(size: Self.glyphFont))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(hint.text) Text(hint.text)
} }
.fixedSize() // keep glyph + label together; never truncate a hint mid-word .fixedSize() // keep glyph + label together; never truncate a hint mid-word
} }
} }
.font(.geist(14, .semibold, relativeTo: .subheadline)) .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85)) .foregroundStyle(.white.opacity(0.85))
.padding(13) .padding(Self.pad)
.consoleGlass(Capsule()) .consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
} }
@@ -297,30 +354,56 @@ struct GamepadFormBackground: View {
} }
} }
#if os(tvOS)
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
/// interactive-glass feel elsewhere.
struct ConsoleBareButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
}
}
#endif
/// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet /// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes. /// when the pad or its battery state changes.
struct ControllerStatusChip: View { struct ControllerStatusChip: View {
let controller: GamepadManager.DiscoveredController let controller: GamepadManager.DiscoveredController
// Legible from the couch on tvOS, quiet in hand elsewhere.
#if os(tvOS)
private static let font: CGFloat = 17
private static let hPad: CGFloat = 16
private static let vPad: CGFloat = 10
#else
private static let font: CGFloat = 12
private static let hPad: CGFloat = 12
private static let vPad: CGFloat = 7
#endif
var body: some View { var body: some View {
HStack(spacing: 7) { HStack(spacing: 7) {
Image(systemName: controller.hasTouchpadAndMotion Image(systemName: controller.hasTouchpadAndMotion
? "playstation.logo" : "gamecontroller.fill") ? "playstation.logo" : "gamecontroller.fill")
.font(.system(size: 12)) .font(.system(size: Self.font))
Text(controller.name) Text(controller.name)
.lineLimit(1) .lineLimit(1)
if let level = controller.batteryLevel { if let level = controller.batteryLevel {
Image(systemName: batterySymbol(level)) Image(systemName: batterySymbol(level))
.font(.system(size: 12)) .font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging .foregroundStyle(level <= 0.2 && !controller.isCharging
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
} }
} }
.font(.geist(12, .medium, relativeTo: .caption)) .font(.geist(Self.font, .medium, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 12) .padding(.horizontal, Self.hPad)
.padding(.vertical, 7) .padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08))) .background(Capsule().fill(.white.opacity(0.08)))
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
} }
@@ -1,4 +1,4 @@
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host // The gamepad-driven home screen: a distinct, "10-foot" console-style host
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built // launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active a separate screen built
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is // around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the // required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
@@ -14,11 +14,13 @@
// `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's // `.safeAreaInset` (top / bottom-leading) guaranteed inside the safe area and out of the carousel's
// vertical budget and the card is sized off the remaining height. macOS mounts it too (the // vertical budget and the card is sized off the remaining height. macOS mounts it too (the
// couch Mac-mini case) same screen, with the settings/add-host covers presented as sheets // couch Mac-mini case) same screen, with the settings/add-host covers presented as sheets
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead). // (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
// mirrors X for Settings since the focus engine has no concept of that button.
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host /// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
@@ -60,8 +62,9 @@ struct GamepadHomeView: View {
let connect: (StoredHost) -> Void let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses. /// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
#if os(iOS) #if os(iOS)
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits. /// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass @Environment(\.verticalSizeClass) private var vSizeClass
@@ -104,6 +107,12 @@ struct GamepadHomeView: View {
try? await Task.sleep(for: .seconds(10)) try? await Task.sleep(for: .seconds(10))
} }
} }
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
// X, and historically tvOS maps a pad's X to this same press the poll and this command
// double-firing just sets the same Bool twice.
#if os(tvOS)
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive` // The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). iOS presents them full screen the immersive console feel; macOS has no // gate above). iOS presents them full screen the immersive console feel; macOS has no
// fullScreenCover, so they become generously sized sheets over the dimmed launcher. // fullScreenCover, so they become generously sized sheets over the dimmed launcher.
@@ -128,11 +137,17 @@ struct GamepadHomeView: View {
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View { @ViewBuilder private func hero(for size: CGSize) -> some View {
#if os(tvOS)
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
let cardWidth = min(560, size.width * 0.34)
let cardHeight = min(350, max(240, size.height - 260))
#else
let cardWidth = min(340, size.width * 0.84) let cardWidth = min(340, size.width * 0.84)
// 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip // 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
// always fits the region the pinned title / hints safe-area insets leave. (The old detail // always fits the region the pinned title / hints safe-area insets leave. (The old detail
// line below the strip is gone it only re-printed what the centered card already shows.) // line below the strip is gone it only re-printed what the centered card already shows.)
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48)) let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
#endif
VStack(spacing: compact ? 8 : 10) { VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0) Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight) carousel(cardWidth: cardWidth, cardHeight: cardHeight)
@@ -145,7 +160,7 @@ struct GamepadHomeView: View {
private var titleBar: some View { private var titleBar: some View {
Text("Select a Host") Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
@@ -158,6 +173,14 @@ struct GamepadHomeView: View {
} }
} }
private var cardSpacing: CGFloat {
#if os(tvOS)
44
#else
30
#endif
}
// MARK: - Carousel // MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View { private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
@@ -165,7 +188,7 @@ struct GamepadHomeView: View {
items: tiles, items: tiles,
selection: $selection, selection: $selection,
itemWidth: cardWidth, itemWidth: cardWidth,
spacing: 30, spacing: cardSpacing,
onActivate: { $0.activate() }, onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() }, onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true }, onTertiary: { showSettings = true },
@@ -272,6 +295,31 @@ private struct GamepadHostTile: View {
let tile: HomeTile let tile: HomeTile
let size: CGSize let size: CGSize
// 10-foot metrics on tvOS, in-hand metrics elsewhere one tile, two viewing distances.
#if os(tvOS)
private static let titleFont: CGFloat = 33
private static let subtitleFont: CGFloat = 19
private static let statusFont: CGFloat = 15
private static let pipSide: CGFloat = 12
private static let badgeSide: CGFloat = 70
private static let badgeCorner: CGFloat = 19
private static let monogramFont: CGFloat = 34
private static let iconFont: CGFloat = 32
private static let pad: CGFloat = 28
private static let corner: CGFloat = 30
#else
private static let titleFont: CGFloat = 23
private static let subtitleFont: CGFloat = 13
private static let statusFont: CGFloat = 11
private static let pipSide: CGFloat = 9
private static let badgeSide: CGFloat = 52
private static let badgeCorner: CGFloat = 15
private static let monogramFont: CGFloat = 25
private static let iconFont: CGFloat = 24
private static let pad: CGFloat = 20
private static let corner: CGFloat = 26
#endif
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
@@ -282,38 +330,38 @@ private struct GamepadHostTile: View {
HStack(spacing: 7) { HStack(spacing: 7) {
if tile.isPaired { if tile.isPaired {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 11, weight: .semibold)) .font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(.white.opacity(0.5))
} }
if tile.isOnline { if tile.isOnline {
Circle() Circle()
.fill(Color.green) .fill(Color.green)
.frame(width: 9, height: 9) .frame(width: Self.pipSide, height: Self.pipSide)
.shadow(color: .green.opacity(0.7), radius: 5) .shadow(color: .green.opacity(0.7), radius: 5)
} }
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
Text(tile.title) Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
Text(tile.subtitle) Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption)) .font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(1) .lineLimit(1)
.padding(.top, 2) .padding(.top, 2)
} }
.padding(20) .padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading) .frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered / // Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 26, style: .continuous), RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil) tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous) RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder( .strokeBorder(
LinearGradient( LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)], colors: [.white.opacity(0.22), .white.opacity(0.04)],
@@ -324,7 +372,7 @@ private struct GamepadHostTile: View {
} }
private var monogramBadge: some View { private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous) let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
return ZStack { return ZStack {
shape.fill(tile.filled shape.fill(tile.filled
? AnyShapeStyle(LinearGradient( ? AnyShapeStyle(LinearGradient(
@@ -335,15 +383,15 @@ private struct GamepadHostTile: View {
ProgressView().tint(.white) ProgressView().tint(.white)
} else if let icon = tile.icon { } else if let icon = tile.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 24, weight: .semibold)) .font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand) .foregroundStyle(Color.brand)
} else { } else {
Text(monogram(tile.title)) Text(monogram(tile.title))
.font(.geistFixed(25, .bold)) .font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand) .foregroundStyle(tile.filled ? .white : Color.brand)
} }
} }
.frame(width: 52, height: 52) .frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay { .overlay {
if !tile.filled { if !tile.filled {
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
@@ -1,8 +1,14 @@
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for // The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a // for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs // a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus // backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
// cursor, controller polling, haptics, and keeping the focused row scrolled into view. // focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
//
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu via
// onExitCommand backs out). Left/right value-adjust isn't wired there; select cycles a value
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
// poll-driven behavior is untouched by the tvOS mode.
// //
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the // Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a // cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
@@ -16,7 +22,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable { struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
let items: [Item] let items: [Item]
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
@State private var input = GamepadMenuInput(manager: .shared) @State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared)
#if os(tvOS)
/// tvOS: the focus engine is the navigation authority for UP/DOWN `cursor` chases this, so
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware keyboard arrows were
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
@FocusState private var focusedID: Item.ID?
#endif
/// Authoritative focus cursor (index into `items`). /// Authoritative focus cursor (index into `items`).
@State private var cursor = 0 @State private var cursor = 0
/// A short vertical recoil when a move is refused at a list end. /// A short vertical recoil when a move is refused at a list end.
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
ScrollView(.vertical) { ScrollView(.vertical) {
LazyVStack(spacing: 6) { LazyVStack(spacing: 6) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
#if os(tvOS)
// A focusable Button per row: the engine moves between them, select
// activates (`tap` keeps the cursor in step before firing). The row's
// own `focused` styling is the focus treatment the bare style adds
// no system chrome on top of it.
Button { tap(idx) } label: {
row(item, focusedID == item.id)
}
.buttonStyle(ConsoleBareButtonStyle())
.focused($focusedID, equals: item.id)
.id(item.id)
#else
row(item, idx == cursor && isActive) row(item, idx == cursor && isActive)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { tap(idx) } .onTapGesture { tap(idx) }
.id(item.id) .id(item.id)
#endif
} }
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
} }
} }
} }
#if os(tvOS)
// Focus moved (remote swipe / pad dpad) keep the cursor, the caller's focusID mirror,
// and the controller detent in step. Menu = the list's back action (both tvOS callers
// pass one; the screen behind would otherwise catch the press and peel too far).
.onChange(of: focusedID) { _, newValue in
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
idx != cursor else { return }
cursor = idx
focusID = id
haptics.move()
}
.defaultFocus($focusedID, items.first?.id)
.onExitCommand { onBack?() }
#endif
.sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.selection, trigger: adjustTick) .sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
// MARK: - Input wiring // MARK: - Input wiring
private func wire() { private func wire() {
#if os(tvOS)
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) the
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
// are exactly the adjust feel the other platforms have, and where the focus engine has
// nothing to move to in a vertical list. Vertical poll directions are deliberately
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
// never reaches this poll no extended profile so remote users cycle values with
// select instead, which `activate` already does.)
input.onMove = { direction in
switch direction {
case .left: adjust(by: -1)
case .right: adjust(by: 1)
case .up, .down: break
}
}
#else
input.onMove = { direction in input.onMove = { direction in
switch direction { switch direction {
case .up: step(by: -1) case .up: step(by: -1)
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
} }
input.onConfirm = { activate() } input.onConfirm = { activate() }
input.onBack = onBack input.onBack = onBack
#endif
} }
private func step(by delta: Int) { private func step(by delta: Int) {
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
haptics.move() haptics.move()
} }
private func adjust(by delta: Int) { private func adjust(by delta: Int) {
guard let onAdjust, cursor >= 0, cursor < items.count else { return } guard let onAdjust, cursor >= 0, cursor < items.count else { return }
if onAdjust(items[cursor], delta) { if onAdjust(items[cursor], delta) {
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
cursor = min(max(cursor, 0), items.count - 1) cursor = min(max(cursor, 0), items.count - 1)
focusID = items[cursor].id focusID = items[cursor].id
} }
#if os(tvOS)
// Keep real focus on the reconciled row when its old target vanished from the list.
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
focusedID = items[cursor].id
}
#endif
} }
private func boundaryBump(forward: Bool) { private func boundaryBump(forward: Bool) {
@@ -29,8 +29,9 @@ struct HomeView: View {
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online /// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker. /// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void let wake: (StoredHost) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action. /// Game-library browser (default ON; the Settings toggle opts out) the host-card
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false /// "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet. /// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost? @State private var editTarget: StoredHost?
@@ -48,6 +49,13 @@ struct HomeView: View {
} }
} }
.padding() .padding()
// Mirror of the action row's focusSection below: an UPWARD move from
// the centered buttons must land back in the grid even when no card
// sits in the buttons' columns (a lone top-left card, say). The grid
// spans the row, so the section catches every upward ray.
#if os(tvOS)
.focusSection()
#endif
} }
if !discoveredUnsaved.isEmpty { if !discoveredUnsaved.isEmpty {
discoveredSection discoveredSection
@@ -67,6 +75,14 @@ struct HomeView: View {
} }
} }
.padding(.top, 24) .padding(.top, 24)
// One FULL-WIDTH focus target for any downward move out of the grid.
// focusSection alone is not enough: the engine tests the section's
// FRAME, and a content-hugging centered HStack only overlaps the middle
// columns a swipe down from an outer card dead-ends and the actions
// are unreachable by remote. Stretching the section across the row means
// every column's downward ray hits it.
.frame(maxWidth: .infinity)
.focusSection()
#endif #endif
} }
} }
@@ -198,6 +214,10 @@ struct HomeView: View {
} }
.padding([.horizontal, .bottom]) .padding([.horizontal, .bottom])
.padding(.top, store.hosts.isEmpty ? 0 : 8) .padding(.top, store.hosts.isEmpty ? 0 : 8)
// Same reachability contract as the saved grid above see its focusSection comment.
#if os(tvOS)
.focusSection()
#endif
} }
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` shared with the gamepad /// Discovered hosts not already saved (see `HostDiscovery.unsaved` shared with the gamepad
@@ -259,7 +279,9 @@ struct HomeView: View {
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)] [GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#elseif os(tvOS) #elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)] // Tracks CardMetrics' 10-foot sizes at the 30pt name a 320pt column truncates
// every hostname longer than ~10 characters.
[GridItem(.adaptive(minimum: 460), spacing: 48)]
#else #else
[GridItem(.adaptive(minimum: 280), spacing: 16)] [GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif #endif
@@ -22,8 +22,9 @@ private struct CardMetrics {
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11, CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12) padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS) #elseif os(tvOS)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14, // 10-foot sizes the 24pt-name tier read like a phone card from the couch.
padding: 18, spacing: 18, radius: 14) CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
padding: 24, spacing: 22, radius: 18)
#else #else
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5, CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
padding: 13, spacing: 12, radius: 10) padding: 13, spacing: 12, radius: 10)
@@ -1,4 +1,4 @@
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS see LibraryView's // The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS see LibraryView's
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the // `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card // scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and // (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
@@ -15,7 +15,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct LibraryCoverflowView: View { struct LibraryCoverflowView: View {
@@ -21,9 +21,9 @@ struct LibraryView: View {
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; /// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session. /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
@State private var imageSession: URLSession? @State private var imageSession: URLSession?
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing (iOS/iPadOS/macOS) see ContentView's identical gate. tvOS keeps // Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// its existing plain-grid presentation of this same view unchanged. // setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared @ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool { private var gamepadUIActive: Bool {
@@ -69,7 +69,6 @@ struct LibraryView: View {
} else if games.isEmpty { } else if games.isEmpty {
emptyState emptyState
} else { } else {
#if os(iOS) || os(macOS)
if gamepadUIActive { if gamepadUIActive {
LibraryCoverflowView( LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch, games: games, imageSession: imageSession, onLaunch: onLaunch,
@@ -77,9 +76,6 @@ struct LibraryView: View {
} else { } else {
grid grid
} }
#else
grid
#endif
} }
} }
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
ContentView() ContentView()
#endif #endif
} }
// NOT on tvOS: under the tvOS 26 glass button style a tinted UNFOCUSED control fills
// AND labels itself in the tint every plain Button/TextField renders as a blank
// brand-violet pill until focused. Untinted, tvOS keeps the system glass look
// (visible labels, white focus lift); brand color stays on explicit Color.brand uses.
#if !os(tvOS)
.tint(.brand) .tint(.brand)
#endif
// Geist Sans is the app's typeface. This sets the default for unstyled text and the // Geist Sans is the app's typeface. This sets the default for unstyled text and the
// form row labels; views that pick an explicit size/weight use `.geist()` directly. // form row labels; views that pick an explicit size/weight use `.geist()` directly.
// tvOS reads from across the room: its system body is 29pt, so pinning the phone's
// 17pt there shrank every unstyled control (rows, fields, buttons) to postage size.
#if os(tvOS)
.font(.geist(29, relativeTo: .body))
#else
.font(.geist(17, relativeTo: .body)) .font(.geist(17, relativeTo: .body))
#endif
} }
// The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S // The Stream menu (Release Mouse Q, Disconnect D, Show/Hide Statistics S
// the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard // the cross-client Ctrl+Alt+Shift set) a real menu bar on macOS, hardware-keyboard
@@ -2,6 +2,7 @@
// handshake phase, and the pump-thread main-actor stats relay. // handshake phase, and the pump-thread main-actor stats relay.
import Foundation import Foundation
import os
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
@@ -10,6 +11,15 @@ import SwiftUI
#elseif canImport(UIKit) #elseif canImport(UIKit)
import UIKit import UIKit
#endif #endif
#if os(tvOS)
import AVFoundation // AVPlayer.eligibleForHDRPlayback the TV-capability HDR gate
#endif
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
/// refresh period on the vsync-latched platforms this is how to measure with it off.
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published /// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
/// values. NSLock instead of an actor the writer is the (non-async) pump thread. /// values. NSLock instead of an actor the writer is the (non-async) pump thread.
@@ -119,6 +129,12 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio? private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture? private var gamepadCapture: GamepadCapture?
private var gamepadFeedback: GamepadFeedback? private var gamepadFeedback: GamepadFeedback?
#if os(tvOS)
/// Siri Remote host pointer while streaming (touch surface moves, press = left click,
/// Play/Pause = right click) + the remote's deliberate exit (hold Back 1 s). See
/// SiriRemotePointer same trust gate/lifecycle as the gamepad capture above.
private var remotePointer: SiriRemotePointer?
#endif
var isBusy: Bool { phase != .idle } var isBusy: Bool { phase != .idle }
@@ -163,6 +179,14 @@ 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
@@ -300,6 +324,10 @@ final class SessionModel: ObservableObject {
// connection is still up); the feedback drain joins off-main like audio. // connection is still up); the feedback drain joins off-main like audio.
gamepadCapture?.stop() gamepadCapture?.stop()
gamepadCapture = nil gamepadCapture = nil
#if os(tvOS)
remotePointer?.stop() // releases any held click while the connection is still up
remotePointer = nil
#endif
let feedback = gamepadFeedback let feedback = gamepadFeedback
gamepadFeedback = nil gamepadFeedback = nil
if let conn = connection { if let conn = connection {
@@ -363,11 +391,20 @@ final class SessionModel: ObservableObject {
// session's virtual pad is a DualSense). Same trust gate as audio nothing is // session's virtual pad is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt. // forwarded during the trust prompt.
let capture = GamepadCapture(connection: conn, manager: .shared) let capture = GamepadCapture(connection: conn, manager: .shared)
// The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) on tvOS the only
// controller way out of a stream (B/Menu is swallowed during sessions; see ContentView).
capture.onDisconnectRequest = { [weak self] in self?.disconnect() }
capture.start() capture.start()
gamepadCapture = capture gamepadCapture = capture
let feedback = GamepadFeedback(connection: conn, manager: .shared) let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start() feedback.start()
gamepadFeedback = feedback gamepadFeedback = feedback
#if os(tvOS)
let pointer = SiriRemotePointer(connection: conn)
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
pointer.start()
remotePointer = pointer
#endif
} }
private func startStatsTimer() { private func startStatsTimer() {
@@ -429,12 +466,32 @@ final class SessionModel: ObservableObject {
} else { } else {
self.decodeValid = false self.decodeValid = false
} }
if let d = self.displayStage.drain() { let displayWindow = self.displayStage.drain()
if let d = displayWindow {
self.displayP50Ms = d.p50Ms self.displayP50Ms = d.p50Ms
self.displayValid = true self.displayValid = true
} else { } else {
self.displayValid = false self.displayValid = false
} }
// Mirror the window to the unified log (see statsLog) one line per second,
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
// `presents` counts frames that reached glass (the display meter's sample count)
// a presentsfps gap is the presenter dropping/serializing, an fps deficit is
// upstream (host capture/encode or the network).
if frames > 0 {
let line = String(
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
frames,
displayWindow?.count ?? 0,
self.endToEndValid ? self.endToEndP50Ms : -1,
self.endToEndValid ? self.endToEndP95Ms : -1,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
self.decodeValid ? self.decodeP50Ms : -1,
self.displayValid ? self.displayP50Ms : -1,
lost)
statsLog.info("\(line, privacy: .public)")
}
} }
} }
// .common so the HUD keeps updating during window drags / menu tracking. // .common so the HUD keeps updating during window drags / menu tracking.
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
// this card its frame (and, on iOS, its clamped corner) animate to the new size rather // this card its frame (and, on iOS, its clamped corner) animate to the new size rather
// than cross-fading a whole new card in. Only the inner content switches per tier. // than cross-fading a whole new card in. Only the inner content switches per tier.
tierContent tierContent
.padding(10) .padding(cardPadding)
.glassBackground(cardShape) .glassBackground(cardShape)
.padding(edgeInset) .padding(edgeInset)
} }
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
#endif #endif
#if os(tvOS)
// No focusable control during play: a focusable button steals the controller's
// A press (the focus engine consumes it before the host sees it). Disconnect is
// the Siri Remote's Menu button (.onExitCommand on the stream) just hint it.
Text("Press Menu to disconnect")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
#else
// D lives on the app's Stream menu (so it still works when the HUD is hidden) // D lives on the app's Stream menu (so it still works when the HUD is hidden)
// and in InputCapture's monitor while captured; this button is the in-overlay, // and in InputCapture's monitor while captured; this button is the in-overlay,
// click-to-disconnect affordance. // click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
// focusable control would steal the controller's A press from the host) NOR a hint
// line: the exits are the hold gestures the start-of-stream banner teaches (hold
// the remote's Back; hold L1+R1+Start+Select on a pad).
#if os(macOS) #if os(macOS)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() } Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#else #elseif os(iOS)
Button("Disconnect") { model.disconnect() } Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
#endif #endif
#endif
} }
} }
// MARK: - Card metrics // MARK: - Card metrics
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.) /// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine. private var cardPadding: CGFloat {
#if os(tvOS)
return 16
#else
return 10
#endif
}
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
/// keep the classic 10.
private var edgeInset: CGFloat { private var edgeInset: CGFloat {
#if os(iOS) #if os(iOS)
return 14 return 14
#elseif os(tvOS)
return 24
#else #else
return 10 return 10
#endif #endif
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
private var cardCornerRadius: CGFloat { private var cardCornerRadius: CGFloat {
#if os(iOS) #if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset) return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else #else
return 10 return 10
#endif #endif
@@ -1,13 +1,25 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the /// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS. /// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
struct AcknowledgementsView: View { struct AcknowledgementsView: View {
private var version: String? { private var version: String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
} }
// TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license
// walls use relative system styles, which already scale per platform.)
#if os(tvOS)
private static let titleFont: CGFloat = 36
private static let headlineFont: CGFloat = 26
private static let captionFont: CGFloat = 20
#else
private static let titleFont: CGFloat = 22
private static let headlineFont: CGFloat = 17
private static let captionFont: CGFloat = 12
#endif
var body: some View { var body: some View {
ScrollView { ScrollView {
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks, // Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
@@ -16,42 +28,40 @@ struct AcknowledgementsView: View {
// notice chunks visually continuous; the header block carries its own spacing + bottom pad. // notice chunks visually continuous; the header block carries its own spacing + bottom pad.
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Text("punktfunk") Text("Punktfunk")
.font(.geist(22, .bold, relativeTo: .title2)) .font(.geist(Self.titleFont, .bold, relativeTo: .title2))
if let version { if let version {
Text("Version \(version)") Text("Version \(version)")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(Licenses.appLicense) LicenseWall(text: Licenses.appLicense)
.font(.caption.monospaced()) .font(.caption.monospaced())
.modifier(SelectableText())
Divider() Divider()
Text("Bundled font") Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), " Text("Punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font " + "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.") + "License 1.1.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty { if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense) LicenseWall(text: Licenses.fontLicense)
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.modifier(SelectableText())
} }
Divider() Divider()
Text("Third-party software") Text("Third-party software")
.font(.geist(17, .semibold, relativeTo: .headline)) .font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text( Text(
"punktfunk uses the open-source components below, each under its own license. " "Punktfunk uses the open-source components below, each under its own license. "
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ " + "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
+ "(dynamically linked, replaceable)." + "(dynamically linked, replaceable)."
) )
.font(.geist(12, relativeTo: .caption)) .font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText()) .modifier(SelectableText())
.modifier(TVFocusable())
} }
} }
.frame(maxWidth: 900, alignment: .leading) .frame(maxWidth: 900, alignment: .leading)
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
#endif #endif
} }
} }
/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall
/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route
/// through it suspended the whole app instead of popping. Plain (non-interactive) focusability
/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen
/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL
/// focus stops all the way down one tall focusable block would strand focus at its top and the
/// next stop could sit past the LazyVStack's instantiation window.
private struct TVFocusable: ViewModifier {
func body(content: Content) -> some View {
#if os(tvOS)
content.focusable()
#else
content
#endif
}
}
/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized
/// chunks (see TVFocusable). The caller's `.font` cascades into either form.
private struct LicenseWall: View {
let text: String
var body: some View {
#if os(tvOS)
let chunks = Licenses.chunked(text)
ForEach(chunks.indices, id: \.self) { i in
Text(chunks[i])
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(TVFocusable())
}
#else
Text(text)
.modifier(SelectableText())
#endif
}
}
@@ -1,4 +1,4 @@
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView, // The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): the couch-relevant subset of SettingsView,
// restyled as a console settings page and fully navigable with a controller up/down moves the // restyled as a console settings page and fully navigable with a controller up/down moves the
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the // focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom // gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
@@ -13,7 +13,7 @@
import PunktfunkKit import PunktfunkKit
import SwiftUI import SwiftUI
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS) || os(tvOS)
import GameController import GameController
struct GamepadSettingsView: View { struct GamepadSettingsView: View {
@@ -34,8 +34,9 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.rawValue = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS) #if os(iOS)
@@ -47,6 +48,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously private let compact = false // no size classes on macOS; the sheet is sized generously
#endif #endif
@State private var focusID: String? @State private var focusID: String?
/// The direction of the last value step (+1 right/forward, -1 left) picks which edge the
/// changed value slides in from, so the animation follows the user's motion.
@State private var lastAdjustDelta = 1
var body: some View { var body: some View {
GamepadMenuList( GamepadMenuList(
@@ -57,13 +61,13 @@ struct GamepadSettingsView: View {
onBack: { dismiss() } onBack: { dismiss() }
) { row, focused in ) { row, focused in
rowView(row, focused: focused) rowView(row, focused: focused)
.frame(maxWidth: 620) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
Text("Settings") Text("Settings")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8) .padding(.bottom, compact ? 4 : 8)
@@ -74,7 +78,7 @@ struct GamepadSettingsView: View {
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail) Text(focusedDetail)
.font(.geist(13, relativeTo: .caption)) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55)) .foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true) .lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID) .animation(.smooth(duration: 0.2), value: focusID)
@@ -107,61 +111,78 @@ struct GamepadSettingsView: View {
private var closeButton: some View { private var closeButton: some View {
Button { dismiss() } label: { Button { dismiss() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
.contentShape(Circle()) .contentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.cancelAction) #if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Close settings") .accessibilityLabel("Close settings")
} }
// MARK: - Row rendering // MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View { private func rowView(_ row: Row, focused: Bool) -> some View {
VStack(alignment: .leading, spacing: 6) { let m = GamepadFormMetrics.self
return VStack(alignment: .leading, spacing: 6) {
if let header = row.header { if let header = row.header {
Text(header) Text(header)
.font(.geist(12, .semibold, relativeTo: .caption)) .font(.geist(m.headerFont, .semibold, relativeTo: .caption))
.tracking(1.4) .tracking(1.4)
.foregroundStyle(.white.opacity(0.45)) .foregroundStyle(.white.opacity(0.45))
.padding(.leading, 16) .padding(.leading, m.rowHPad)
.padding(.top, 14) .padding(.top, 14)
} }
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: row.icon) Image(systemName: row.icon)
.font(.system(size: 17)) .font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) .foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.frame(width: 28) .frame(width: m.iconWidth)
Text(row.label) Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body)) .font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 12) Spacer(minLength: 12)
HStack(spacing: 9) { HStack(spacing: 9) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
// Deliberately not `.push`: that travels the whole container width, loud
// and visibly outside the row. The ZStack is the stable home the
// removed/inserted texts transition within.
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
ZStack {
Text(row.value) Text(row.value)
.font(.geist(15, .medium, relativeTo: .callout)) .font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6)) .foregroundStyle(focused ? .white : .white.opacity(0.6))
.lineLimit(1) .lineLimit(1)
.id(row.value)
.transition(.asymmetric(
insertion: .offset(x: slide).combined(with: .opacity),
removal: .offset(x: -slide).combined(with: .opacity)))
}
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0)) .foregroundStyle(.white.opacity(focused ? 0.6 : 0))
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, m.rowHPad)
.padding(.vertical, 13) .padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass( .consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous), RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil, tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused) interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
} }
.scaleEffect(focused ? 1.0 : 0.98) .scaleEffect(focused ? 1.0 : 0.98)
@@ -193,10 +214,12 @@ struct GamepadSettingsView: View {
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
/// (never on state captured at wire time). /// (never on state captured at wire time).
private func adjust(id: String, by delta: Int) -> Bool { private func adjust(id: String, by delta: Int) -> Bool {
rows.first { $0.id == id }?.adjust(delta) ?? false lastAdjustDelta = delta
return rows.first { $0.id == id }?.adjust(delta) ?? false
} }
private func activate(id: String) { private func activate(id: String) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate() rows.first { $0.id == id }?.activate()
} }
@@ -252,6 +275,12 @@ struct GamepadSettingsView: View {
detail: "Sharper text and UI at more bandwidth — needs host opt-in and " detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
+ "hardware decode.", + "hardware decode.",
value: $enable444), value: $enable444),
choiceRow(
id: "presenter", icon: "rectangle.stack", label: "Presenter",
detail: "Stage 3 paces presents to the display — lowest display latency. "
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
options: SettingsOptions.presenters, current: presenter
) { presenter = $0 },
choiceRow( choiceRow(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -287,8 +316,7 @@ struct GamepadSettingsView: View {
) { hudPlacement = $0 }, ) { hudPlacement = $0 },
toggleRow( toggleRow(
id: "library", icon: "square.grid.2x2", label: "Game library", id: "library", icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) " detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
+ "(experimental).",
value: $libraryEnabled), value: $libraryEnabled),
toggleRow( toggleRow(
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
@@ -37,6 +37,30 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] = static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) } HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS runs
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
/// an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(tvOS)
"stage3"
#else
"stage2"
#endif
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value. /// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] = static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) } StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
@@ -105,8 +129,8 @@ enum SettingsOptions {
return options return options
} }
#if os(iOS) || os(macOS) // MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list) // touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`. /// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
@@ -124,8 +148,8 @@ enum SettingsOptions {
@MainActor @MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] { static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = [] var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS) #if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device", native = [("This device",
Int(max(bounds.width, bounds.height)), Int(max(bounds.width, bounds.height)),
Int(min(bounds.width, bounds.height)))] Int(min(bounds.width, bounds.height)))]
@@ -145,7 +169,7 @@ enum SettingsOptions {
/// the screen can't show), plus any stored custom value so it stays selectable. /// the screen can't show), plus any stored custom value so it stays selectable.
@MainActor @MainActor
static func refreshRates(including current: Int) -> [Int] { static func refreshRates(including current: Int) -> [Int] {
#if os(iOS) #if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond let maxHz = UIScreen.main.maximumFramesPerSecond
#else #else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60 let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
@@ -155,5 +179,4 @@ enum SettingsOptions {
if !rates.contains(current) { rates.append(current) } if !rates.contains(current) { rates.append(current) }
return rates.sorted() return rates.sorted()
} }
#endif
} }
@@ -24,7 +24,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2" @AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
#if os(macOS) #if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false @AppStorage(DefaultsKey.vsync) var vsync = false
#endif #endif
@@ -33,7 +33,7 @@ struct SettingsView: View {
#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 = true
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@@ -43,9 +43,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue @AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared @ObservedObject var gamepads = GamepadManager.shared
#if !os(tvOS)
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State var showControllerTest = false @State var showControllerTest = false
#endif #endif
@@ -284,19 +282,6 @@ struct SettingsView: View {
("4K @ 60", "3840x2160x60"), ("4K @ 60", "3840x2160x60"),
] ]
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
private static var presenterOptions: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2 (default)", "stage2"),
("Stage 3 (experimental)", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
private var modeTag: Binding<String> { private var modeTag: Binding<String> {
Binding( Binding(
get: { "\(width)x\(height)x\(hz)" }, get: { "\(width)x\(height)x\(hz)" },
@@ -313,6 +298,12 @@ struct SettingsView: View {
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
} }
/// The gamepad-UI switch as an on/off row (same shape as HDR above) the escape hatch back
/// to this focus-engine home for someone who prefers it with a controller connected.
private var gamepadUIEnabledTag: Binding<String> {
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
}
private var tvBody: some View { private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)" let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds let bounds = UIScreen.main.nativeBounds
@@ -338,7 +329,7 @@ struct SettingsView: View {
selection: $audioChannels) selection: $audioChannels)
if bitrateKbps > 1_000_000 { if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange) .foregroundStyle(.orange)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -347,7 +338,7 @@ struct SettingsView: View {
selection: $compositor) selection: $compositor)
TVSelectionRow( TVSelectionRow(
title: "Presenter", title: "Presenter",
options: Self.presenterOptions, options: SettingsOptions.presenters,
selection: $presenter) selection: $presenter)
TVSelectionRow( TVSelectionRow(
title: "10-bit HDR", title: "10-bit HDR",
@@ -355,7 +346,7 @@ struct SettingsView: View {
Text("The host creates a virtual output at exactly this mode — native " Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.") + "is honored only if available on the host.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -375,8 +366,11 @@ struct SettingsView: View {
TVSelectionRow( TVSelectionRow(
title: "Controller type", options: SettingsOptions.padTypes, title: "Controller type", options: SettingsOptions.padTypes,
selection: $gamepadType) selection: $gamepadType)
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
Text(Self.controllersFooter) Text(Self.controllersFooter)
.font(.geist(12, relativeTo: .caption)) .font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 8) .padding(.top, 8)
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
var interactive = false var interactive = false
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) { #if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
.overlay {
if let tint { shape.fill(tint) }
}
}
#else
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape) content.glassEffect(glass, in: shape)
} else { } else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
} }
#endif
} }
@available(iOS 26, macOS 26, tvOS 26, *) #if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass { private var glass: Glass {
var g: Glass = .regular var g: Glass = .regular
if let tint { g = g.tint(tint) } if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() } if interactive { g = g.interactive() }
return g return g
} }
#endif
} }
extension View { extension View {
@@ -48,7 +48,7 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). " + "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison " + "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.") + "needed.")
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
TVFieldRow( TVFieldRow(
@@ -59,7 +59,7 @@ struct PairSheet: View {
) { editing = .clientName } ) { editing = .clientName }
if let errorText { if let errorText {
Text(errorText) Text(errorText)
.font(.geist(16, relativeTo: .callout)) .font(.geist(22, relativeTo: .callout))
.foregroundStyle(.red) .foregroundStyle(.red)
} }
HStack(spacing: 32) { HStack(spacing: 32) {
@@ -263,19 +263,24 @@ public final class SessionAudio {
defer { drainDone.signal() } defer { drainDone.signal() }
// Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is // Decode happens IN-CORE (libopus multistream) AudioToolbox's Opus path is
// stereo-only and is handed back as interleaved f32 PCM in wire channel order. // stereo-only and is handed back as interleaved f32 PCM in wire channel order.
while !flag.isStopped { // Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
var alive = true
while alive, !flag.isStopped {
alive = autoreleasepool { () -> Bool in
let pcm: PunktfunkConnection.AudioPCM? let pcm: PunktfunkConnection.AudioPCM?
do { do {
pcm = try connection.nextAudioPcm(timeoutMs: 100) pcm = try connection.nextAudioPcm(timeoutMs: 100)
} catch { } catch {
break // session closed return false // session closed
} }
guard let pcm, pcm.frameCount > 0 else { continue } guard let pcm, pcm.frameCount > 0 else { return true }
pcm.samples.withUnsafeBufferPointer { p in pcm.samples.withUnsafeBufferPointer { p in
if let base = p.baseAddress { if let base = p.baseAddress {
ring.write(base, count: pcm.frameCount * pcm.channels) ring.write(base, count: pcm.frameCount * pcm.channels)
} }
} }
return true
}
} }
} }
thread.name = "punktfunk-audio" thread.name = "punktfunk-audio"
@@ -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
@@ -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()
} }
@@ -0,0 +1,173 @@
// The Siri Remote as a pointing device during a tvOS streaming session the remote's touch
// surface drives the HOST cursor (relative deltas, like a laptop trackpad), a surface press
// clicks (left button), and Play/Pause right-clicks. It also owns the remote's DELIBERATE
// session exit: hold Back/Menu `disconnectHold`. A short Back press does nothing the
// UIKit menu press it also generates is swallowed by ContentView's session branch, so neither
// a trackpad fumble nor a game-controller B press can end the session (the pad's exit is the
// L1+R1+Start+Select chord in GamepadCapture).
//
// The remote is read through GameController as a GCMicroGamepad with
// `reportsAbsoluteDpadValues = true`: the dpad axes then report the finger's ABSOLUTE position
// on the surface (±1, +y up) while touched, and snap to exactly (0, 0) on lift. Successive
// positions are differenced into relative mouse deltas; the exact-zero snap is treated as a
// lift (a real touch at the mathematical centre is measure-zero, and one dropped delta there
// is imperceptible). Handlers (not a poll) the same in-session delivery GamepadCapture
// relies on.
//
// Lifecycle mirrors GamepadCapture: started by SessionModel when streaming begins (never
// during the trust prompt), stopped on disconnect; held buttons are released on stop so the
// host never keeps a stuck click.
#if os(tvOS)
import Foundation
import GameController
import UIKit
@MainActor
public final class SiriRemotePointer {
private let connection: PunktfunkConnection
private var observers: [NSObjectProtocol] = []
private var bound: GCController?
/// Finger position (±1 axes) at the last dpad callback while touched; nil = lifted.
private var lastTouch: (x: Float, y: Float)?
/// Wire buttons currently held (1 = left, 3 = right) released on stop/unbind.
private var heldButtons: Set<UInt32> = []
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
private var menuDownAt: Date?
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
/// controller chord's 1.5 s the remote has no way to trip this during gameplay.
private static let disconnectHold: TimeInterval = 1.0
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
/// small; two comfortable swipes should cross a 1080p desktop.
private static let pointerScale: Float = 1100
/// Largest single-callback finger travel accepted as real motion (surface units; the axes
/// span ±1, so 0.4 a fifth of the pad). On RELEASE the hardware slides the reported
/// position back to (0, 0) through intermediate callbacks naive differencing turns that
/// tail into reverse deltas that RETRACE the whole swipe, so the cursor springs back to its
/// anchor and the pointer feels absolute. Real finger motion arrives as many small steps
/// (even a fast flick stays well under this per callback); the release tail arrives as one
/// or two huge jumps discard those (the anchor still follows, so nothing accumulates).
private static let maxStep: Float = 0.4
/// Fired ON MAIN after Back/Menu was held `disconnectHold` and released.
public var onDisconnectRequest: (() -> Void)?
public init(connection: PunktfunkConnection) {
self.connection = connection
}
public func start() {
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.rebind() }
})
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidDisconnect, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.rebind() }
})
rebind()
}
public func stop() {
observers.forEach(NotificationCenter.default.removeObserver(_:))
observers.removeAll()
bind(nil)
}
/// The Siri Remote is the non-extended controller carrying a microGamepad a full gamepad
/// (which also EXPOSES a microGamepad view of itself) must never be captured here, its
/// buttons belong to GamepadCapture.
private func rebind() {
let remote = GCController.controllers().first {
$0.extendedGamepad == nil && $0.microGamepad != nil
}
bind(remote)
}
private func bind(_ controller: GCController?) {
guard controller !== bound else { return }
if let old = bound?.microGamepad {
old.dpad.valueChangedHandler = nil
old.buttonA.pressedChangedHandler = nil
old.buttonX.pressedChangedHandler = nil
old.buttonMenu.pressedChangedHandler = nil
}
releaseHeld()
lastTouch = nil
menuDownAt = nil
bound = controller
guard let micro = controller?.microGamepad else { return }
// Absolute finger position instead of the emulated dpad the raw surface is what a
// trackpad needs. Rotation stays off: the remote's natural grip is the coordinate frame.
micro.reportsAbsoluteDpadValues = true
micro.allowsRotation = false
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
}
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
}
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
}
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
}
}
private func touchMoved(x: Float, y: Float) {
// Exact (0, 0) is the lift snap drop the anchor so the next touch starts a fresh
// gesture instead of a jump-delta from the old position.
guard x != 0 || y != 0 else {
lastTouch = nil
return
}
defer { lastTouch = (x, y) }
guard let last = lastTouch else { return } // first contact anchors, moves nothing
let stepX = x - last.x
let stepY = y - last.y
// The release tail (and any tracking glitch) shows up as a single impossible jump
// see `maxStep`. Skip the emission; the deferred anchor update above still follows the
// reported position, so the gesture cleanly re-anchors instead of retracing.
guard abs(stepX) < Self.maxStep, abs(stepY) < Self.maxStep else { return }
let dx = stepX * Self.pointerScale / 2 // axes span ±1 full swipe = 2.0
let dy = -stepY * Self.pointerScale / 2 // GC +y is up; mouse +y is down
let ix = Int32(dx.rounded())
let iy = Int32(dy.rounded())
guard ix != 0 || iy != 0 else { return }
connection.send(.mouseMove(dx: ix, dy: iy))
}
private func setButton(_ button: UInt32, down: Bool) {
if down { heldButtons.insert(button) } else { heldButtons.remove(button) }
connection.send(.mouseButton(button, down: down))
}
private func menuChanged(pressed: Bool) {
if pressed {
menuDownAt = Date()
return
}
let heldFor = menuDownAt.map { Date().timeIntervalSince($0) } ?? 0
menuDownAt = nil
if heldFor >= Self.disconnectHold {
onDisconnectRequest?()
}
// A short press is deliberately nothing: the accompanying UIKit menu press is swallowed
// in ContentView, and forwarding it as a host key would make trackpad fumbles type.
}
private func releaseHeld() {
for button in heldButtons {
connection.send(.mouseButton(button, down: false))
}
heldButtons.removeAll()
}
}
#endif
@@ -13,14 +13,14 @@ public enum Licenses {
return text return text
} }
/// punktfunk's own license MIT OR Apache-2.0, at your option. /// Punktfunk's own license MIT OR Apache-2.0, at your option.
public static var appLicense: String { public static var appLicense: String {
let mit = resource("LICENSE-MIT") let mit = resource("LICENSE-MIT")
let apache = resource("LICENSE-APACHE") let apache = resource("LICENSE-APACHE")
if mit.isEmpty && apache.isEmpty { if mit.isEmpty && apache.isEmpty {
return "punktfunk is licensed under MIT OR Apache-2.0, at your option." return "Punktfunk is licensed under MIT OR Apache-2.0, at your option."
} }
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n" return "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
+ "================================ MIT ================================\n\n" + "================================ MIT ================================\n\n"
+ mit + mit
+ "\n\n============================== Apache-2.0 ==============================\n\n" + "\n\n============================== Apache-2.0 ==============================\n\n"
@@ -51,11 +51,27 @@ public enum Licenses {
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay /// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n"; /// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once. /// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
public static let thirdPartyNoticesChunks: [String] = { public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
let chunkSize = 200 /// Lines per chunk: tvOS reads much smaller chunks focus is how tvOS scrolls, and each
return stride(from: 0, to: lines.count, by: chunkSize).map { start in /// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n") /// itself per step; ~24 lines two thirds of a screen reads like a page turn. Elsewhere the
/// only constraint is the text-render height limit, so chunks stay big.
private static var chunkLines: Int {
#if os(tvOS)
24
#else
200
#endif
}
/// `text` split at line boundaries into render/focus-sized chunks (joined with "\n"; the
/// inter-chunk break is the caller's stack-row boundary, so no text is lost). tvOS pages
/// focus through these every license wall on the Acknowledgements screen renders this way.
public static func chunked(_ text: String) -> [String] {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return stride(from: 0, to: lines.count, by: chunkLines).map { start in
lines[start..<min(start + chunkLines, lines.count)].joined(separator: "\n")
}
} }
}()
} }
@@ -0,0 +1,126 @@
// The YCbCrRGB conversion as three shader rows, ported from pf-client-core's `csc_rows`
// (crates/pf-client-core/src/video.rs) the ONE coefficient implementation every punktfunk
// presenter derives its CSC from. Keep the two in LOCKSTEP: both carry the same unit tests
// (CscRowsTests.swift the Rust `csc_rows` tests), and a coefficient change lands in both or
// neither.
//
// Why this exists: the stage-2 Metal shaders used to hardcode BT.709 (SDR) / BT.2020 (HDR)
// matrices, silently ignoring the stream's signaled matrix. A Linux host's RGB-input NVENC paths
// signal BT.601 limited (NVENC's fixed internal RGBYUV conversion; ffmpeg force-writes that
// VUI), so those streams rendered with the wrong coefficients a constant hue error. The rows
// are now computed per frame from the decoded buffer's actual signaling (VideoToolbox propagates
// the HEVC VUI / AV1 colour config onto the CVPixelBuffer's attachments) and handed to the
// fragment shaders as bytes.
import CoreVideo
import simd
/// The fragment shaders' CSC constant block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`.
/// Layout matches the Metal-side `struct CscUniform { float4 r0; float4 r1; float4 r2; }`
/// (three 16-byte-aligned float4s, stride 48) passed via `setFragmentBytes`.
public struct CscUniform: Equatable, Sendable {
public var r0: SIMD4<Float>
public var r1: SIMD4<Float>
public var r2: SIMD4<Float>
}
public enum CscRows {
/// A decoded frame's YCbCr signaling: the H.273 matrix code (1 = BT.709, 5/6 = BT.601,
/// 9/10 = BT.2020; 2 = unspecified the BT.709 SDR default, mirroring `ColorDesc`) and
/// whether the samples are full range.
public struct Signal: Equatable, Sendable {
public var matrix: UInt8
public var fullRange: Bool
public init(matrix: UInt8, fullRange: Bool) {
self.matrix = matrix
self.fullRange = fullRange
}
}
/// Read a decoded buffer's signaling: the matrix from the `CVImageBuffer` attachment
/// (VideoToolbox propagates the bitstream's colour description there), the range from the
/// pixel format itself (the video- vs full-range biplanar siblings), so a full-range stream
/// expands correctly no matter which sibling VideoToolbox delivered.
public static func signal(of buffer: CVPixelBuffer) -> Signal {
var matrix: UInt8 = 2 // unspecified BT.709 default in rows()
if let att = CVBufferCopyAttachment(buffer, kCVImageBufferYCbCrMatrixKey, nil),
CFGetTypeID(att) == CFStringGetTypeID() {
let s = unsafeDowncast(att, to: CFString.self)
if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_709_2) {
matrix = 1
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_601_4) {
matrix = 5
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_SMPTE_240M_1995) {
matrix = 7
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_2020) {
matrix = 9
} else {
// CICP codes CoreMedia has no named constant for arrive as the literal string
// "YCbCrMatrix#<code>" the suffix IS the H.273 code. BT.470BG (5) takes this
// form (proven by the 601 golden fixture), and BT.470BG is exactly what a Linux
// host's RGB-input NVENC signals, so missing it re-creates the hue bug the
// per-frame signaling exists to fix.
let str = s as String
if str.hasPrefix("YCbCrMatrix#"), let code = UInt8(str.dropFirst(12)) {
matrix = code
}
}
}
let pf = CVPixelBufferGetPixelFormatType(buffer)
let fullRange = pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
return Signal(matrix: matrix, fullRange: fullRange)
}
/// Compute the three rows bit-depth exact. `depth` picks the limited-range code points
/// (8-bit: 16/235/240 over 255; 10-bit: 64/940/960 over 1023 NOT the same normalized
/// values, the difference is ~half a code). `msbPacked` folds in the P010/x444 packing
/// factor: 10 significant bits live in the MSBs of 16, so an `.r16Unorm` sample reads
/// `code·64/65535` multiplying by `65535/65472` recovers exact `code/1023` (this replaces
/// the shaders' old documented ~0.1% approximation).
public static func rows(_ signal: Signal, depth: Int, msbPacked: Bool) -> CscUniform {
// BT.601 (5/6), BT.2020 (9/10); everything else incl. unspecified is the host's
// BT.709 SDR default (mirrors the Rust side's dispatch).
let (kr, kb): (Double, Double)
switch signal.matrix {
case 5, 6: (kr, kb) = (0.299, 0.114)
case 9, 10: (kr, kb) = (0.2627, 0.0593)
default: (kr, kb) = (0.2126, 0.0722)
}
let kg = 1.0 - kr - kb
let max = Double((1 << depth) - 1) // 255 / 1023
let step = Double(1 << (depth - 8)) // code points per 8-bit step: 1 / 4
let pack = msbPacked ? 65535.0 / 65472.0 : 1.0
let (sy, oy, sc): (Double, Double, Double)
if signal.fullRange {
(sy, oy, sc) = (pack, 0.0, pack)
} else {
(sy, oy, sc) = (
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step)
)
}
// rgb = M * (yuv + off) = M*yuv + M*off rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack]
let m: [[Double]] = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
]
func row(_ r: Int) -> SIMD4<Float> {
let w = (0..<3).reduce(0.0) { $0 + m[r][$1] * off[$1] }
return SIMD4(Float(m[r][0]), Float(m[r][1]), Float(m[r][2]), Float(w))
}
return CscUniform(r0: row(0), r1: row(1), r2: row(2))
}
}
@@ -28,17 +28,39 @@ private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "pre
/// dimmer. Matches the host's standard PQ reference white. /// dimmer. Matches the host's standard PQ reference white.
private let hdrReferenceWhiteNits: Float = 203.0 private let hdrReferenceWhiteNits: Float = 203.0
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR /// PUNKTFUNK_SDR_COLORSPACE=srgb A/B hatch for the SDR layer's colour tag. Today the SDR layer
/// and BT.2020-PQ HDR YCbCrRGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin /// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded RGB as-is the /// stream is displayed with the panel's native primaries mild oversaturation on every P3 Mac.
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples /// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission). /// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
private let sdrColorspaceOverride: CGColorSpace? = {
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
return nil
}
return CGColorSpace(name: CGColorSpace.sRGB)
}()
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and YCbCrRGB
/// fragment shaders whose conversion arrives as three constant rows computed per frame on the CPU
/// (`CscRows` the Swift port of pf-client-core's `csc_rows`, from the decoded buffer's actual
/// signaling). One set of coefficients honors BT.601/709/2020 × full/limited × 8/10-bit instead of
/// the old hardcoded BT.709/BT.2020 matrices a BT.601-signaled stream (a Linux host's RGB-input
/// NVENC) used to render with BT.709 coefficients, a constant hue error. uv.y is flipped (1 - p.y)
/// so the top-left-origin texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded
/// RGB as-is the CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system
/// compositor the samples are PQ and how to tone-map them (no EOTF here, matching the host's
/// BT.2020 PQ emission).
private let shaderSource = """ private let shaderSource = """
#include <metal_stdlib> #include <metal_stdlib>
using namespace metal; using namespace metal;
struct VOut { float4 pos [[position]]; float2 uv; }; struct VOut { float4 pos [[position]]; float2 uv; };
// The CPU-computed CSC rows (CscRows.swift, layout-matched): rgb[i] = dot(ri.xyz, yuv) + ri.w.
// Range expansion, the matrix, and the 10-bit MSB-packing factor are all folded in.
struct CscUniform { float4 r0; float4 r1; float4 r2; };
vertex VOut pf_vtx(uint vid [[vertex_id]]) { vertex VOut pf_vtx(uint vid [[vertex_id]]) {
float2 p = float2(float((vid << 1) & 2), float(vid & 2)); float2 p = float2(float((vid << 1) & 2), float(vid & 2));
VOut o; VOut o;
@@ -94,43 +116,80 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
return uv; return uv;
} }
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) full-range RGB. Chroma is sampled at the // The shared sample + row-multiply: YCbCr (bicubic luma, siting-corrected bilinear chroma)
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0. // RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
fragment float4 pf_frag(VOut in [[stage_in]], // offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
texture2d<float> lumaTex [[texture(0)]], // gamma-encoded RGB, an HDR frame's rows yield PQ-encoded RGB the fragment variants below
texture2d<float> chromaTex [[texture(1)]]) { // differ only in what they do next.
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
constant CscUniform& csc) {
constexpr sampler s(filter::linear, address::clamp_to_edge); constexpr sampler s(filter::linear, address::clamp_to_edge);
float y = catmullRomLuma(lumaTex, s, in.uv); float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
// BT.709, 8-bit limited (video) range full-range RGB. return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
y = (y - 16.0/255.0) * (255.0/219.0); dot(csc.r1.xyz, yuv) + csc.r1.w,
float u = (c.x - 128.0/255.0) * (255.0/224.0); dot(csc.r2.xyz, yuv) + csc.r2.w));
float v = (c.y - 128.0/255.0) * (255.0/224.0);
float r = y + 1.5748 * v;
float g = y - 0.1873 * u - 0.4681 * v;
float b = y + 1.8556 * u;
return float4(saturate(float3(r, g, b)), 1.0);
} }
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), YCbCr that is PQ-encoded. We apply the BT.2020 // SDR: 8-bit NV12 / 4:4:4 full-range RGB, transfer left baked (shown as-is, the proven SDR
// matrix to get PQ-encoded RGB and output it as-is the CAMetalLayer's itur_2100_PQ colour space // layer config).
// + edrMetadata tell the compositor the samples are PQ, so it does the PQdisplay tone-map. No EOTF fragment float4 pf_frag(VOut in [[stage_in]],
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample texture2d<float> lumaTex [[texture(0)]],
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%). texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
}
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded YCbCr) full-range PQ RGB, output as-is
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
// PQ, so it does the PQdisplay tone-map. No EOTF here. The rows fold in the exact 10-bit
// MSB-packing factor (the old hardcoded shader carried a documented ~0.1% /1024-vs-/1023 error).
fragment float4 pf_frag_hdr(VOut in [[stage_in]], fragment float4 pf_frag_hdr(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]], texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) { texture2d<float> chromaTex [[texture(1)]],
constexpr sampler s(filter::linear, address::clamp_to_edge); constant CscUniform& csc [[buffer(0)]]) {
float y = catmullRomLuma(lumaTex, s, in.uv); return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; }
// BT.2020 10-bit limited (video) range full-range PQ RGB.
y = (y - 64.0/1023.0) * (1023.0/876.0); // HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
float u = (c.x - 512.0/1023.0) * (1023.0/896.0); // disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
float v = (c.y - 512.0/1023.0) * (1023.0/896.0); // wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
float r = y + 1.4746 * v; // composites UNtone-mapped the CAMetalLayer header says so outright which showed as a badly
float g = y - 0.16455 * u - 0.57135 * v; // overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF linear
float b = y + 1.8814 * u; // light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
return float4(saturate(float3(r, g, b)), 1.0); // rolloff with a 1000-nit knee, BT.2020BT.709 primaries, BT.709 OETF into the proven SDR layer
// config. The 10-bit BT.2020 stream keeps its full decode depth; only the final presentation is
// display-referred SDR. (When the display IS in an HDR mode requested per session via
// AVDisplayManager, see StreamViewIOS tvOS presents pf_frag_hdr's PQ passthrough instead:
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
// YCbCr full-range PQ RGB via the per-frame rows (as pf_frag_hdr).
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
// ST 2084 EOTF: PQ code value linear light, 1.0 = 10,000 nits.
const float m1 = 2610.0/16384.0;
const float m2 = 78.84375;
const float c1 = 3424.0/4096.0;
const float c2 = 18.8515625;
const float c3 = 18.6875;
float3 p = pow(pq, 1.0/m2);
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
float3 t = lin * (10000.0/203.0);
// BT.2020 BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
float3 t709 = float3(
dot(t, float3( 1.6605, -0.5876, -0.0728)),
dot(t, float3(-0.1246, 1.1329, -0.0083)),
dot(t, float3(-0.0182, -0.1006, 1.1187)));
t709 = max(t709, 0.0);
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
const float w = 1000.0/203.0;
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
// BT.709 OETF the same encoding the SDR stream arrives in, so both paths present alike.
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
return float4(e, 1.0);
} }
""" """
@@ -144,12 +203,19 @@ public final class MetalVideoPresenter {
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle). /// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
private let pipelineSDR: MTLRenderPipelineState private let pipelineSDR: MTLRenderPipelineState
private let pipelineHDR: MTLRenderPipelineState private let pipelineHDR: MTLRenderPipelineState
/// tvOS only: the in-shader PQSDR tone-map fallback (pf_frag_hdr_tv bgra8), used whenever
/// the display is composited without HDR headroom see `setDisplayHeadroom`. nil elsewhere.
private let pipelineHDRToneMap: MTLRenderPipelineState?
private var textureCache: CVMetalTextureCache? private var textureCache: CVMetalTextureCache?
/// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs. /// Current layer configuration switched in `configure(hdr:)` when a frame's HDR-ness differs.
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread /// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
/// `configure` call is ordered before the thread starts, so it doesn't race). /// `configure` call is ordered before the thread starts, so it doesn't race).
private var hdrActive = false private var hdrActive = false
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
/// its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
/// derived from the staged display headroom at the top of every `render`.
private var hdrPassthroughActive = false
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session /// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
/// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the /// SDRHDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write /// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
@@ -163,6 +229,11 @@ public final class MetalVideoPresenter {
private let stagingLock = NSLock() private let stagingLock = NSLock()
private var pendingHdrMeta: PunktfunkConnection.HdrMeta? private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
private var drawableTarget: CGSize = .zero private var drawableTarget: CGSize = .zero
/// tvOS: the display's current EDR headroom (UIScreen.currentEDRHeadroom), pushed from the
/// main thread (SessionPresenter.layout + the mode-switch observers). > 1 the display is
/// composited with HDR headroom, so HDR frames present as PQ passthrough; otherwise the
/// in-shader tone-map keeps the picture from blowing out. 1 (the default) is the safe start.
private var stagedDisplayHeadroom: CGFloat = 1.0
#if DEBUG #if DEBUG
/// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change. /// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change.
@@ -177,6 +248,7 @@ public final class MetalVideoPresenter {
else { return nil } else { return nil }
let pipelineSDR: MTLRenderPipelineState let pipelineSDR: MTLRenderPipelineState
let pipelineHDR: MTLRenderPipelineState let pipelineHDR: MTLRenderPipelineState
let pipelineHDRToneMap: MTLRenderPipelineState?
do { do {
let library = try device.makeLibrary(source: shaderSource, options: nil) let library = try device.makeLibrary(source: shaderSource, options: nil)
let vtx = library.makeFunction(name: "pf_vtx") let vtx = library.makeFunction(name: "pf_vtx")
@@ -188,8 +260,20 @@ public final class MetalVideoPresenter {
let hdr = MTLRenderPipelineDescriptor() let hdr = MTLRenderPipelineDescriptor()
hdr.vertexFunction = vtx hdr.vertexFunction = vtx
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr") hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
hdr.colorAttachments[0].pixelFormat = .rgba16Float // EDR-capable hdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr) pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr)
#if os(tvOS)
// tvOS carries BOTH HDR pipelines: PQ passthrough when the display is composited
// with HDR headroom, the in-shader tone-map ( the 8-bit SDR config) when it isn't.
// See setDisplayHeadroom / configureColor.
let tm = MTLRenderPipelineDescriptor()
tm.vertexFunction = vtx
tm.fragmentFunction = library.makeFunction(name: "pf_frag_hdr_tv")
tm.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineHDRToneMap = try device.makeRenderPipelineState(descriptor: tm)
#else
pipelineHDRToneMap = nil
#endif
} catch { } catch {
return nil return nil
} }
@@ -229,17 +313,19 @@ public final class MetalVideoPresenter {
return MetalVideoPresenter( return MetalVideoPresenter(
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR, device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
textureCache: textureCache, layer: layer) pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
} }
private init( private init(
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState, device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
textureCache: CVMetalTextureCache, layer: CAMetalLayer
) { ) {
self.device = device self.device = device
self.queue = queue self.queue = queue
self.pipelineSDR = pipelineSDR self.pipelineSDR = pipelineSDR
self.pipelineHDR = pipelineHDR self.pipelineHDR = pipelineHDR
self.pipelineHDRToneMap = pipelineHDRToneMap
self.textureCache = textureCache self.textureCache = textureCache
self.layer = layer self.layer = layer
} }
@@ -251,30 +337,68 @@ public final class MetalVideoPresenter {
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor; /// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
/// SDR uses the plain 8-bit sRGB path. /// SDR uses the plain 8-bit sRGB path.
public func configure(hdr: Bool) { public func configure(hdr: Bool) {
#if os(tvOS)
// Reconfigure on an HDR flip AND on a passthroughtone-map flip: the display's headroom
// changes when the AVDisplayManager mode switch (requested at session start) completes
// typically a second or two into the session.
stagingLock.lock()
let passthrough = stagedDisplayHeadroom > 1.0
stagingLock.unlock()
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
hdrActive = hdr
hdrPassthroughActive = passthrough
#else
guard hdr != hdrActive else { return } guard hdr != hdrActive else { return }
hdrActive = hdr hdrActive = hdr
#endif
configureColor(hdr: hdr) configureColor(hdr: hdr)
} }
/// tvOS: park the display's current EDR headroom (a MAIN-thread `UIScreen` read pushed by
/// SessionPresenter.layout and the stream view's mode-switch observers). > 1 flips HDR frames
/// to PQ passthrough (the display's own tone-map applies); 1 keeps the in-shader tone-map.
/// Applied by the render thread on the next frame, like every other staged value here.
public func setDisplayHeadroom(_ headroom: CGFloat) {
stagingLock.lock()
stagedDisplayHeadroom = headroom
stagingLock.unlock()
}
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested /// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API /// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so /// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there)
/// it gets the PQ pixel format + colour space only the tvOS compositor tone-maps from those. /// and a bare PQ colour-space tag composites UNtone-mapped (the "overblown HDR" Apple TV report),
/// so tvOS instead tone-maps PQSDR in the shader (pf_frag_hdr_tv) and keeps the SDR layer config.
private func configureColor(hdr: Bool) { private func configureColor(hdr: Bool) {
if hdr { if hdr {
#if os(tvOS)
if hdrPassthroughActive {
// Display composited WITH HDR headroom (the session's AVDisplayManager request
// landed): emit PQ passthrough in a real HDR10 output that's the correct
// emission, and the TV applies its own tone-map.
layer.pixelFormat = .rgba16Float
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
} else {
// SDR-composited display: PQ would render untone-mapped (blown out) the
// pf_frag_hdr_tv shader tone-maps to SDR instead.
layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil
}
#else
layer.pixelFormat = .rgba16Float layer.pixelFormat = .rgba16Float
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ) layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = true layer.wantsExtendedDynamicRangeContent = true
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the // Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright. // flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
layer.edrMetadata = makeEDR(lastHdrMeta) layer.edrMetadata = makeEDR(lastHdrMeta)
#endif #endif
} else { } else {
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB // SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
// (the proven SDR path never showed the "too bright" issue, which was HDR-only). // colour matching on macOS (the panel's native primaries the long-proven look,
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
// for correct colour matching instead (A/B pending see sdrColorspaceOverride).
layer.pixelFormat = .bgra8Unorm layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil layer.colorspace = sdrColorspaceOverride
#if !os(tvOS) #if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil layer.edrMetadata = nil
@@ -360,6 +484,11 @@ public final class MetalVideoPresenter {
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange || pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange || pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange || pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
// The frame's YCbCrRGB rows, from its ACTUAL signaling (buffer attachments + pixel
// format) a BT.601-signaled stream gets 601 coefficients, full-range gets full-range
// expansion; recomputed per frame because the host can flip colour in-band (SDRHDR).
var csc = CscRows.rows(
CscRows.signal(of: pixelBuffer), depth: tenBit ? 10 : 8, msbPacked: tenBit)
guard let textureCache, guard let textureCache,
let luma = makeTexture( let luma = makeTexture(
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache), pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
@@ -395,9 +524,17 @@ public final class MetalVideoPresenter {
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
return false return false
} }
#if os(tvOS)
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
#else
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR) encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
#endif
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0) encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1) encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding() encoder.endEncoding()
if let onPresented { if let onPresented {
@@ -10,6 +10,9 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import QuartzCore import QuartzCore
#if os(tvOS)
import UIKit
#endif
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or /// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
/// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a /// presenter directly makes a `owner link owner` cycle that only `invalidate()` breaks if a
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first, /// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var) /// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG /// falls back to the platform default. `allowStage1` is false in release builds, where a
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback. /// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice { static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw { switch raw {
case "stage1": return allowStage1 ? .stage1 : .stage2 case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2
case "stage3": return .stage3 case "stage3": return .stage3
default: return .stage2 default: return platformDefault
} }
} }
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
static var platformDefault: PresenterChoice {
#if os(tvOS)
.stage3
#else
.stage2
#endif
}
} }
final class SessionPresenter { final class SessionPresenter {
@@ -179,6 +197,13 @@ final class SessionPresenter {
stage2?.setDrawableTarget(CGSize( stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(), width: (fit.width * contentsScale).rounded(),
height: (fit.height * contentsScale).rounded())) height: (fit.height * contentsScale).rounded()))
#if os(tvOS)
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
// HDR mode (the session's AVDisplayManager request landed see StreamViewIOS), and HDR
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
// mode notifications, so a mid-session switch reaches here without a bounds change.
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
#endif
} }
/// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the /// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
@@ -358,7 +358,12 @@ public final class Stage2Pipeline {
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a // decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
// resolution-ceiling miss). End cleanly instead of looping on a black screen. // resolution-ceiling miss). End cleanly instead of looping on a black screen.
var decodeFailRun = 0 var decodeFailRun = 0
while !token.isStopped { // Every iteration drains its own autorelease pool: this thread has no runloop, so
// autoreleased VT/CM temporaries would otherwise accumulate until session end.
// `false` = session over exit the loop (the closure can't `break` across itself).
var alive = true
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do { do {
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
// decoder conceals the reference-missing deltas often WITHOUT an error callback // decoder conceals the reference-missing deltas often WITHOUT an error callback
@@ -378,13 +383,13 @@ public final class Stage2Pipeline {
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) { if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
presenter.setHdrMeta(meta) presenter.setHdrMeta(meta)
} }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue } guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
onFrame?(au) onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included) format = f // refreshed on every IDR (mode changes included)
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
} }
guard let f = format, !token.isStopped else { continue } guard let f = format, !token.isStopped else { return true }
if decoder.decode(au: au, format: f) { if decoder.decode(au: au, format: f) {
decodeFailRun = 0 decodeFailRun = 0
} else { } else {
@@ -397,12 +402,14 @@ public final class Stage2Pipeline {
// recovers within a GOP) 4:4:4 isn't decodable here; end the session. // recovers within a GOP) 4:4:4 isn't decodable here; end the session.
if connection.isChroma444, decodeFailRun >= 180 { if connection.isChroma444, decodeFailRun >= 180 {
if !token.isStopped { onSessionEnd?() } if !token.isStopped { onSessionEnd?() }
break return false
} }
} }
return true
} catch { } catch {
if !token.isStopped { onSessionEnd?() } if !token.isStopped { onSessionEnd?() }
break // session closed return false // session closed
}
} }
} }
} }
@@ -435,10 +442,14 @@ public final class Stage2Pipeline {
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
let renderThread = Thread { let renderThread = Thread {
defer { renderStopped.signal() } defer { renderStopped.signal() }
while !token.isStopped { // Every iteration drains its own autorelease pool (`return` = the old `continue`):
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable
// without a per-iteration pool every presented frame's drawable object (plus its
// texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end.
while !token.isStopped { autoreleasepool {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut { if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
// Stage-3: while a present is in flight, don't take from the ring at all frames // Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented // keep coalescing there (newest wins, the intended drop point) and the presented
@@ -447,13 +458,13 @@ public final class Stage2Pipeline {
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) { if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake() debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
guard !token.isStopped, let frame = ring.take() else { guard !token.isStopped, let frame = ring.take() else {
gate?.release() // armed but nothing to render don't hold the gate stale gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake() debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
continue return
} }
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link // V-Sync ON: flip on the next predicted vsync (< one period out, stale link
// immediate see VsyncClock). OFF: flip as soon as the GPU finishes. // immediate see VsyncClock). OFF: flip as soon as the GPU finishes.
@@ -488,7 +499,7 @@ public final class Stage2Pipeline {
ring.putBack(frame) ring.putBack(frame)
} }
debugStats?.flushIfDue(ring: ring, gate: gate) debugStats?.flushIfDue(ring: ring, gate: gate)
} } }
} }
renderThread.name = "punktfunk-stage2-render" renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive renderThread.qualityOfService = .userInteractive
@@ -512,6 +523,13 @@ public final class Stage2Pipeline {
presenter.setDrawableTarget(size) presenter.setDrawableTarget(size)
} }
/// Forward the display's current EDR headroom to the presenter (MAIN thread a `UIScreen`
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
public func setDisplayHeadroom(_ headroom: CGFloat) {
presenter.setDisplayHeadroom(headroom)
}
/// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN /// Stop the pump + render thread ( one poll timeout each) and drop the decode session. MAIN
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline /// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent). /// (the stop is permanent).
@@ -47,7 +47,12 @@ final class StreamPump {
var awaitingIDR = false var awaitingIDR = false
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
var wasFailed = false var wasFailed = false
while !token.isStopped { // Every iteration drains its own autorelease pool: this thread has no runloop, so
// autoreleased CM/layer temporaries would otherwise accumulate until session end.
// `false` = session over exit the loop (the closure can't `break` across itself).
var alive = true
while alive, !token.isStopped {
alive = autoreleasepool { () -> Bool in
do { do {
// Loss recovery (the primary path). Under the host's infinite GOP the only // Loss recovery (the primary path). Under the host's infinite GOP the only
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs // recovery keyframe is one we request. The reassembler drops unrecoverable AUs
@@ -69,7 +74,7 @@ final class StreamPump {
} }
if awaitingIDR { recovery.request() } if awaitingIDR { recovery.request() }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue } guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
onFrame?(au) onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat { if let f = idrFormat {
@@ -97,13 +102,15 @@ final class StreamPump {
guard let f = format, guard let f = format,
let sample = connection.videoCodec.sampleBuffer(au: au, format: f), let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
!token.isStopped // don't enqueue a stale frame after a restart !token.isStopped // don't enqueue a stale frame after a restart
else { continue } else { return true }
layer.enqueue(sample) layer.enqueue(sample)
return true
} catch { } catch {
if !token.isStopped { if !token.isStopped {
onSessionEnd?() onSessionEnd?()
} }
break // session closed return false // session closed
}
} }
} }
} }
@@ -36,6 +36,9 @@ import PunktfunkCore
import SwiftUI import SwiftUI
import UIKit import UIKit
import os import os
#if os(tvOS)
import AVKit // AVDisplayManager the per-session display-mode (HDR10/refresh) request
#endif
/// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the /// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the
/// resolved pointer-lock state each time capture engages, so the user can see whether the /// resolved pointer-lock state each time capture engages, so the user can see whether the
@@ -108,7 +111,20 @@ public struct StreamView: UIViewControllerRepresentable {
} }
} }
public final class StreamViewController: UIViewController { #if os(tvOS)
/// tvOS: a GCEventViewController with `controllerUserInteractionEnabled = false` routes game-
/// controller (and Siri Remote) input EXCLUSIVELY to the GameController framework while the
/// stream is up. Without it a pad's B/Menu press doubles as a UIKit menu press which ended
/// the session (or suspended the whole app) from ordinary gameplay; a SwiftUI
/// `.onExitCommand {}` swallow proved unreliable with nothing focusable on screen. Every
/// in-session exit is GC-level by design: the pad's escape chord (GamepadCapture) and the
/// remote's hold-Back (SiriRemotePointer).
public typealias StreamViewControllerBase = GCEventViewController
#else
public typealias StreamViewControllerBase = UIViewController
#endif
public final class StreamViewController: StreamViewControllerBase {
public private(set) var connection: PunktfunkConnection? public private(set) var connection: PunktfunkConnection?
private var observers: [NSObjectProtocol] = [] private var observers: [NSObjectProtocol] = []
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2 /// Record the unified latency stages (end-to-end / decode / display) when the stage-2
@@ -119,6 +135,11 @@ public final class StreamViewController: UIViewController {
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback. /// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter() private let presenter = SessionPresenter()
#if os(tvOS)
/// The window's display manager the session's mode request was set on held weakly so
/// stop() can clear the request even after the view has left the window.
private weak var sessionDisplayManager: AVDisplayManager?
#endif
#if os(iOS) #if os(iOS)
private var inputCapture: InputCapture? private var inputCapture: InputCapture?
fileprivate var captured = false fileprivate var captured = false
@@ -157,6 +178,12 @@ public final class StreamViewController: UIViewController {
public override func loadView() { public override func loadView() {
view = StreamLayerUIView() view = StreamLayerUIView()
#if os(tvOS)
// Kill the pad/remote UIKit press path at the source for the whole session (see the
// GCEventViewController typealias above). GC delivery is untouched: GamepadCapture
// forwards the pad, SiriRemotePointer drives the pointer and owns the remote exit.
controllerUserInteractionEnabled = false
#endif
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g. // Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
// moving to an external display at a different scale) the iOS analogue of macOS's // moving to an external display at a different scale) the iOS analogue of macOS's
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it // viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
@@ -230,6 +257,18 @@ public final class StreamViewController: UIViewController {
} }
#endif #endif
#if os(tvOS)
// The GCEventViewController's interaction flag applies to the deepest such controller
// CONTAINING THE FIRST RESPONDER inside SwiftUI's hosting-controller sandwich that is not
// guaranteed to be us unless we anchor the responder chain here explicitly.
public override var canBecomeFirstResponder: Bool { true }
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
becomeFirstResponder()
}
#endif
func start( func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -342,6 +381,19 @@ public final class StreamViewController: UIViewController {
setCaptured(true) // entering a session is the deliberate "capture me" moment setCaptured(true) // entering a session is the deliberate "capture me" moment
} }
#endif #endif
#if os(tvOS)
// The TV's mode switch (requested in applyDisplayCriteriaIfNeeded) completes
// asynchronously, and a dynamic-range-only switch doesn't re-layout by itself
// re-layout on the switch/mode notifications so the presenter sees the new EDR
// headroom immediately (layout pushes UIScreen.currentEDRHeadroom down).
observers.append(NotificationCenter.default.addObserver(
forName: .AVDisplayManagerModeSwitchEnd, object: nil, queue: .main
) { [weak self] _ in self?.layoutMetalLayer() })
observers.append(NotificationCenter.default.addObserver(
forName: UIScreen.modeDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in self?.layoutMetalLayer() })
#endif
} }
func stop() { func stop() {
@@ -360,6 +412,12 @@ public final class StreamViewController: UIViewController {
streamView.onScroll = nil streamView.onScroll = nil
streamView.currentHostMode = nil streamView.currentHostMode = nil
#endif #endif
#if os(tvOS)
// Return the TV to the user's preferred mode the home screen must not stay in the
// session's HDR10/refresh mode.
sessionDisplayManager?.preferredDisplayCriteria = nil
sessionDisplayManager = nil
#endif
presenter.stop() presenter.stop()
connection = nil connection = nil
} }
@@ -367,8 +425,50 @@ public final class StreamViewController: UIViewController {
public override func viewDidLayoutSubviews() { public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
layoutMetalLayer() layoutMetalLayer()
#if os(tvOS)
applyDisplayCriteriaIfNeeded()
#endif
} }
#if os(tvOS)
/// Ask the TV for a display mode matching the session HDR10 at the stream's refresh rate
/// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation
/// playback layers do this implicitly). Honored only when the user allows matching (tvOS
/// Settings Video and Audio Match Content); the presenter reads the RESULT off UIScreen's
/// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
/// stop() teardown clears it.
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
// A synthetic HDR10-HEVC format description carrying the negotiated mode what the
// stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the
// display to it (tvOS 17+, our deployment floor).
let ext: [CFString: Any] = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
var desc: CMFormatDescription?
CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC,
width: Int32(mode.width), height: Int32(mode.height),
extensions: ext as CFDictionary, formatDescriptionOut: &desc)
guard let desc else { return }
manager.preferredDisplayCriteria = AVDisplayCriteria(
refreshRate: Float(mode.refreshHz), formatDescription: desc)
sessionDisplayManager = manager
}
#endif
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the /// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
/// canonical render scale and is reliable once the controller is in the hierarchy; /// canonical render scale and is reliable once the controller is in the hierarchy;
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
@@ -0,0 +1,112 @@
import CoreMedia
import CoreVideo
import VideoToolbox
import XCTest
import simd
@testable import PunktfunkKit
/// Golden end-to-end colour tests: decode the known-signaling bar fixtures through a real
/// `VTDecompressionSession`, read the buffer's propagated signaling via `CscRows.signal(of:)`,
/// convert sampled YCbCr through `CscRows.rows` the exact math the Metal shaders run and
/// require the ORIGINAL RGB bars back. This is the proof of the two assumptions the stage-2
/// colour fix rests on: (1) VideoToolbox propagates the bitstream's matrix onto the decoded
/// CVPixelBuffer's attachments, and (2) signal+rows renders it correctly for BT.601/709 ×
/// limited/full. A hardcoded-709 regression fails the 601 fixture by tens of code points.
final class ColorBarDecodeTests: XCTestCase {
private static let bars: [(r: Float, g: Float, b: Float)] = [
(255, 255, 255), (255, 255, 0), (0, 255, 255), (0, 255, 0),
(255, 0, 255), (255, 0, 0), (0, 0, 255), (0, 0, 0),
]
/// Decode one fixture AU to a biplanar 4:2:0 buffer of the given range sibling.
private func decode(_ au: [UInt8], pixelFormat: OSType) throws -> CVPixelBuffer {
let data = Data(au)
guard let format = AnnexB.formatDescription(fromIDR: data, codec: .hevc) else {
throw XCTSkip("could not build a format description from the fixture")
}
let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: pixelFormat]
var session: VTDecompressionSession?
let created = VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: format,
decoderSpecification: nil, imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
guard created == noErr, let session else {
throw XCTSkip("VTDecompressionSessionCreate failed (\(created))")
}
defer { VTDecompressionSessionInvalidate(session) }
let unit = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
guard let sample = AnnexB.sampleBuffer(au: unit, format: format, codec: .hevc) else {
throw XCTSkip("could not build a sample buffer")
}
var produced: CVPixelBuffer?
let status = VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in
if status == noErr { produced = imageBuffer }
}
XCTAssertEqual(status, noErr, "decode submit")
VTDecompressionSessionWaitForAsynchronousFrames(session)
return try XCTUnwrap(produced, "no decoded frame")
}
private func assertBars(
_ name: String, au: [UInt8], pixelFormat: OSType,
expected: CscRows.Signal
) throws {
let buffer = try decode(au, pixelFormat: pixelFormat)
let signal = CscRows.signal(of: buffer)
XCTAssertEqual(signal, expected, "\(name): VT must propagate the bitstream signaling")
let rows = CscRows.rows(signal, depth: 8, msbPacked: false)
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
let yBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 0))
.assumingMemoryBound(to: UInt8.self)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
let cBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 1))
.assumingMemoryBound(to: UInt8.self)
let cStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
for (i, bar) in Self.bars.enumerated() {
let (cx, cy) = (i * 32 + 16, 32)
let y = Float(yBase[cy * yStride + cx]) / 255.0
let cb = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2]) / 255.0
let cr = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2 + 1]) / 255.0
let yuv = SIMD3<Float>(y, cb, cr)
let rgb = SIMD3<Float>(
simd_dot(SIMD3(rows.r0.x, rows.r0.y, rows.r0.z), yuv) + rows.r0.w,
simd_dot(SIMD3(rows.r1.x, rows.r1.y, rows.r1.z), yuv) + rows.r1.w,
simd_dot(SIMD3(rows.r2.x, rows.r2.y, rows.r2.z), yuv) + rows.r2.w)
XCTAssertEqual(rgb.x * 255, bar.r, accuracy: 3, "\(name) bar \(i) R")
XCTAssertEqual(rgb.y * 255, bar.g, accuracy: 3, "\(name) bar \(i) G")
XCTAssertEqual(rgb.z * 255, bar.b, accuracy: 3, "\(name) bar \(i) B")
}
}
/// BT.601 (BT.470BG) limited what a Linux host's RGB-input NVENC signals. The fixture that
/// catches a hardcoded-BT.709 shader.
func testGolden601LimitedBars() throws {
try assertBars(
"601-limited", au: ColorBarFixtures.bars601Limited,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
expected: .init(matrix: 5, fullRange: false))
}
/// BT.709 limited the hosts' explicit SDR signaling.
func testGolden709LimitedBars() throws {
try assertBars(
"709-limited", au: ColorBarFixtures.bars709Limited,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
expected: .init(matrix: 1, fullRange: false))
}
/// BT.709 full range the PUNKTFUNK_444_FULLRANGE experiment's signaling (requesting the
/// full-range sibling keeps VT from range-converting, so the full-range rows are exercised).
func testGolden709FullBars() throws {
try assertBars(
"709-full", au: ColorBarFixtures.bars709Full,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
expected: .init(matrix: 1, fullRange: true))
}
}
@@ -0,0 +1,864 @@
// Golden colour-bar fixtures the SAME bytes as crates/pf-client-core/tests/bars-*.h265
// (one 256×64 LOSSLESS x265 IDR of 8 saturated bars per signaling variant; generated
// offline with ffmpeg/libx265, RGBYUV matched to the declared VUI so the original RGB
// is recoverable ±1 code). Regenerate both together the Rust and Swift golden tests
// must chew identical streams. Test-target only; nothing here ships.
enum ColorBarFixtures {
static let bars601Limited: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x0a,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x35, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0xb5, 0xb6, 0x5d, 0x34, 0x79, 0xe7, 0x9e, 0x7f, 0x7d, 0xf7,
0xdf, 0x7d, 0xf8, 0x18, 0x53, 0xff, 0xfe, 0xc3, 0xa1, 0x01, 0xf1, 0xbc, 0xa9, 0x03, 0x52, 0x4f,
0xc2, 0x91, 0xef, 0xff, 0xf9, 0x13, 0xb5, 0xff, 0xff, 0xe0, 0xd3, 0xc0, 0xe8, 0xf7, 0xbe, 0xff,
0x98, 0x39, 0x83, 0x98, 0x39, 0x83, 0x99, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59,
0xb5, 0x88, 0xe7, 0xff, 0xb1, 0xee, 0x80, 0x1f, 0xaf, 0xd3, 0xc3, 0x0c, 0x30, 0xc3, 0x0c, 0x71,
0xc7, 0x1c, 0x71, 0xc7, 0x16, 0x7a, 0x50, 0x01, 0xc9, 0xff, 0xf9, 0x43, 0x06, 0x41, 0x75, 0x47,
0xb1, 0xe7, 0x71, 0x8e, 0x4e, 0x9d, 0x1f, 0xff, 0xce, 0x91, 0x28, 0x4d, 0xc5, 0x7a, 0x68, 0x84,
0xdc, 0x76, 0x6c, 0xb4, 0xb3, 0x45, 0xc9, 0xef, 0xff, 0xcb, 0x9f, 0x2b, 0x6b, 0x50, 0x63, 0xb0,
0x1f, 0xe0, 0x49, 0xe5, 0x74, 0x7f, 0xff, 0x3a, 0x44, 0xa1, 0x37, 0x15, 0xe9, 0xa2, 0x13, 0x71,
0xd9, 0xaa, 0x79, 0xa3, 0xe3, 0xff, 0xf5, 0x23, 0x05, 0x46, 0x2f, 0x61, 0x97, 0x0a, 0x0e, 0x16,
0x36, 0x94, 0x4f, 0xff, 0xb3, 0x7f, 0x1f, 0xe6, 0x13, 0x25, 0x0f, 0x78, 0x49, 0x00, 0x63, 0xf9,
0x58, 0x64, 0x83, 0x85, 0x00, 0x07, 0x98, 0xce, 0xcb, 0x42, 0xb2, 0x35, 0x1d, 0xfa, 0xbf, 0xc6,
0x85, 0xe6, 0x00, 0x48, 0xff, 0xfe, 0xed, 0x0e, 0xf8, 0x8b, 0xfc, 0x55, 0xed, 0x8d, 0xa0, 0xbd,
0xf6, 0xdf, 0xff, 0xc7, 0xf9, 0xd8, 0x58, 0x24, 0x13, 0x43, 0x41, 0x3e, 0x1f, 0xc8, 0x40, 0x1c,
0x5f, 0xff, 0x95, 0x21, 0x6f, 0x83, 0x90, 0x1c, 0x23, 0xc9, 0xe6, 0x5e, 0xdb, 0xd7, 0xff, 0xde,
0xb1, 0x0b, 0xeb, 0x42, 0x9f, 0xe3, 0xae, 0xdb, 0x41, 0x26, 0x3b, 0x04, 0x56, 0x7f, 0xfe, 0xc9,
0xf5, 0x58, 0x41, 0xd2, 0xa9, 0xeb, 0x7f, 0xd7, 0x3b, 0xa2, 0xef, 0xff, 0xde, 0x56, 0xa7, 0xa2,
0xac, 0x43, 0x81, 0xaf, 0xce, 0x33, 0x5c, 0x41, 0xbf, 0x1f, 0xff, 0x5d, 0xee, 0xcb, 0x39, 0xae,
0x1e, 0xa3, 0xd4, 0x47, 0xc7, 0xe9, 0x47, 0xff, 0xeb, 0x2a, 0xe3, 0x45, 0x58, 0xb5, 0x6a, 0x37,
0xfd, 0xd2, 0xcf, 0xfa, 0x80, 0xa2, 0x8c, 0xff, 0xff, 0xf0, 0x0f, 0x7e, 0x56, 0xf1, 0x4e, 0x9d,
0x3a, 0x74, 0xf2, 0x64, 0xc9, 0x93, 0x26, 0x4c, 0x98, 0x43, 0xff, 0xff, 0x77, 0x88, 0xb7, 0xf6,
0x16, 0xba, 0x3e, 0xa1, 0xc4, 0xf8, 0x98, 0xc5, 0xc5, 0x67, 0xf6, 0xa6, 0x20, 0x0b, 0x3d, 0x62,
0x9e, 0xaf, 0xe6, 0xaa, 0x45, 0x3e, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xfd, 0x2f, 0xd2,
0xfd, 0x2f, 0xd2, 0xfd, 0x2f, 0xd2, 0xfd, 0x2d, 0x3d, 0xfe, 0x6b, 0xf7, 0xff, 0xff, 0x04, 0x5e,
0x01, 0x17, 0x8a, 0x34, 0xae, 0x56, 0xe5, 0x6e, 0x56, 0xe5, 0x6e, 0x66, 0x26, 0x62, 0x66, 0x26,
0x62, 0x66, 0x26, 0x62, 0x66, 0x25, 0xe8, 0x81, 0xb7, 0xff, 0xfc, 0x8c, 0xfc, 0x2d, 0xdd, 0xfc,
0x78, 0xe0, 0x8f, 0x36, 0xc1, 0x09, 0xb5, 0x10, 0xff, 0xff, 0x05, 0x24, 0xbb, 0x93, 0x6d, 0x8b,
0x11, 0x3f, 0xf5, 0x5f, 0x91, 0x4d, 0xe8, 0x34, 0x32, 0x13, 0xff, 0xf2, 0xd0, 0x8e, 0xa4, 0x10,
0x3b, 0x17, 0x3d, 0x0d, 0xf0, 0x64, 0x88, 0x64, 0xd4, 0xff, 0xfd, 0xe4, 0x28, 0x23, 0x05, 0xf8,
0x06, 0x68, 0xb4, 0xa5, 0x8c, 0x20, 0x1e, 0x54, 0x22, 0xf3, 0xff, 0xf6, 0x32, 0x77, 0x6d, 0x34,
0xce, 0xe5, 0x7e, 0xdf, 0xcc, 0x43, 0x72, 0x2c, 0xdf, 0x7f, 0xfe, 0x7f, 0xb0, 0x15, 0x20, 0x52,
0xd4, 0x06, 0xf8, 0x83, 0x2d, 0x08, 0x97, 0x2f, 0x08, 0x3d, 0x3d, 0x5f, 0xff, 0xe6, 0x0a, 0xa2,
0x05, 0xf9, 0x24, 0x92, 0x4b, 0x18, 0x61, 0x86, 0x18, 0x61, 0x88, 0x6f, 0xff, 0xd4, 0xec, 0x35,
0x68, 0x57, 0xf9, 0x4c, 0x73, 0xc7, 0x6e, 0x6d, 0x63, 0x89, 0x93, 0xff, 0xff, 0x6e, 0x4e, 0xd9,
0x23, 0x2c, 0xf5, 0xed, 0xef, 0xde, 0xfd, 0xef, 0xde, 0xfd, 0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x6c,
0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x01, 0x1b, 0x12, 0xc0, 0x18, 0x22, 0xee, 0x4d, 0x92, 0x49, 0x24,
0x92, 0x7a, 0xaa, 0xaa, 0xaa, 0xaa, 0xa7, 0xd0, 0x1a, 0x5f, 0xff, 0x9c, 0xa9, 0xca, 0xe5, 0x6d,
0x24, 0x4e, 0x60, 0x52, 0x2f, 0xa6, 0x7f, 0xff, 0xb0, 0xe8, 0x40, 0x7c, 0x6f, 0x2a, 0x40, 0xd4,
0x93, 0xf0, 0xa6, 0xa6, 0x14, 0x55, 0xff, 0xf3, 0x76, 0x99, 0x75, 0xaa, 0xeb, 0x16, 0xc8, 0x7d,
0x93, 0x68, 0x60, 0xff, 0xfd, 0x65, 0x5c, 0x68, 0xab, 0x16, 0xad, 0x46, 0xff, 0xba, 0x5a, 0x12,
0x2c, 0x9f, 0xff, 0xe5, 0x39, 0x42, 0xf7, 0xb8, 0x97, 0xfb, 0xe6, 0xf7, 0xd3, 0x1e, 0x27, 0xff,
0xf9, 0xc3, 0x14, 0x52, 0xf4, 0xf5, 0x3c, 0xbb, 0x6e, 0xb7, 0x2b, 0xbb, 0x80, 0x5e, 0xbf, 0xff,
0x41, 0xd9, 0xcb, 0x9f, 0x5d, 0xc9, 0x28, 0x36, 0x1c, 0xcf, 0x18, 0xa2, 0xd9, 0xdf, 0xff, 0xa7,
0xf3, 0x09, 0x84, 0xe6, 0x31, 0x94, 0xa5, 0x8d, 0xad, 0x93, 0x83, 0x8c, 0x01, 0x58, 0xff, 0xfd,
0x4b, 0xa2, 0xd2, 0x82, 0x6b, 0xc7, 0x81, 0x87, 0x90, 0xc0, 0x16, 0x28, 0x4c, 0x7f, 0xfe, 0x95,
0xc3, 0x60, 0xc7, 0x92, 0xea, 0x9a, 0xf4, 0xee, 0x9b, 0x68, 0x59, 0x04, 0x74, 0xec, 0x80, 0x0f,
0x41, 0x5c, 0xee, 0x30, 0xf1, 0xc6, 0xe8, 0x40, 0x12, 0xed, 0xb5, 0x55, 0xff, 0xfc, 0x11, 0xc8,
0x03, 0x4d, 0x33, 0x0f, 0x22, 0xec, 0x22, 0xdf, 0x56, 0x44, 0xb6, 0x7f, 0xff, 0x12, 0x30, 0x22,
0x76, 0x41, 0xf5, 0x7f, 0xf8, 0x06, 0x4f, 0x55, 0x86, 0x31, 0x58, 0xa3, 0xff, 0xf4, 0xf1, 0xc2,
0x65, 0x14, 0x4c, 0x4d, 0x40, 0x92, 0x12, 0x88, 0x96, 0xdb, 0xc4, 0xff, 0xfd, 0x33, 0x8d, 0xc5,
0xcb, 0xf7, 0x51, 0xc8, 0xd1, 0x4a, 0x19, 0x22, 0x0f, 0x81, 0xee, 0x95, 0xff, 0xff, 0x9c, 0x31,
0x45, 0x2f, 0x4f, 0x53, 0xcb, 0xb6, 0xeb, 0x72, 0xca, 0xaf, 0xff, 0x9b, 0x7c, 0x7c, 0x9e, 0x22,
0x9b, 0x07, 0xb8, 0xd1, 0x57, 0x20, 0x17, 0x9d, 0xff, 0xf8, 0x24, 0xc8, 0xb0, 0xee, 0x78, 0x7b,
0x95, 0x1d, 0x8d, 0x56, 0x89, 0xff, 0xf8, 0x15, 0xb7, 0xfa, 0x2a, 0xbe, 0x6b, 0x3f, 0xf0, 0xb5,
0xb3, 0x63, 0x74, 0x55, 0xff, 0xfc, 0xcc, 0xbd, 0x0e, 0x72, 0xf9, 0x76, 0x87, 0x1d, 0x0e, 0xd9,
0x12, 0x9f, 0xff, 0x32, 0x43, 0xc0, 0x1f, 0x1b, 0x10, 0xf4, 0xd3, 0x9f, 0x4b, 0xd2, 0x6b, 0x2f,
0xff, 0xef, 0xcb, 0x14, 0x52, 0x91, 0x69, 0x76, 0xc8, 0x6d, 0x94, 0x00, 0x34, 0xff, 0xfb, 0xec,
0xba, 0x8f, 0x8d, 0xeb, 0x14, 0xf5, 0x9e, 0xab, 0x96, 0xf0, 0xd9, 0x4f, 0x39, 0x0f, 0xff, 0xf8,
0x50, 0x81, 0x7f, 0x73, 0xb2, 0xe5, 0xcb, 0x97, 0x32, 0x6c, 0xd9, 0xb3, 0x66, 0xcd, 0x9b, 0x61,
0x7f, 0xff, 0x74, 0x86, 0x0d, 0xaf, 0x45, 0xff, 0x85, 0x5a, 0xa4, 0x21, 0x8d, 0xfd, 0x18, 0x3a,
0x85, 0x00, 0x5d, 0x6a, 0x15, 0xf4, 0xff, 0x44, 0xd0, 0x78, 0xe5, 0xaf, 0x7a, 0xf7, 0xaf, 0x7a,
0xf7, 0xb0, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf0, 0xf8, 0x37, 0xae, 0xc0,
0x38, 0xb4, 0xea, 0x7a, 0x6e, 0x22, 0x51, 0xbc, 0x31, 0xdd, 0x3b, 0x74, 0xed, 0xd3, 0xb7, 0x4e,
0xdd, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x5d, 0x0d, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x56, 0xc8, 0x91,
0x7f, 0xff, 0xa1, 0xad, 0x88, 0x55, 0x61, 0x10, 0x40, 0x65, 0xd0, 0x9b, 0x4b, 0xc3, 0x5b, 0x8f,
0xff, 0xd0, 0xb6, 0xa8, 0x17, 0xbd, 0x41, 0xad, 0xe6, 0xc9, 0x9a, 0x1c, 0x1c, 0x1b, 0x5d, 0xa6,
0x97, 0xff, 0xf4, 0x29, 0x51, 0x3f, 0xff, 0xfa, 0xae, 0x19, 0xa1, 0x7e, 0x73, 0xcd, 0xc7, 0xfa,
0xff, 0xfd, 0x07, 0x66, 0xff, 0x1d, 0x6b, 0x4c, 0x94, 0xe8, 0xc3, 0x30, 0x89, 0x10, 0xd4, 0x81,
0x17, 0xff, 0xe4, 0x00, 0x31, 0xf0, 0xc2, 0xff, 0x9b, 0xc2, 0xb6, 0xd5, 0xbc, 0x8d, 0x4a, 0xbf,
0xff, 0xf8, 0xf9, 0xdb, 0xda, 0xc8, 0x96, 0xff, 0x59, 0x48, 0xb9, 0xdc, 0xca, 0x74, 0x56, 0x89,
0x79, 0x8c, 0xff, 0xff, 0x11, 0xf1, 0x1f, 0x0b, 0x12, 0x49, 0x24, 0xa4, 0x30, 0xc3, 0x0c, 0x30,
0xc4, 0x34, 0xff, 0xfd, 0xec, 0xf2, 0xd5, 0x8c, 0xa1, 0x40, 0x45, 0x93, 0x4f, 0x05, 0x8a, 0xdb,
0xbf, 0xff, 0xfc, 0x63, 0xf8, 0xb1, 0xe1, 0x66, 0xf5, 0x2c, 0x92, 0xc9, 0x2c, 0x92, 0xc9, 0x2c,
0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xc9, 0x46, 0xf9, 0x00, 0x24, 0xe2,
0xac, 0x81, 0x0c, 0x30, 0xc3, 0x0c, 0x7c, 0x71, 0xc7, 0x1c, 0x71, 0xc3, 0x33, 0xbe, 0x7f, 0xff,
0x09, 0x4e, 0x4f, 0x3a, 0xf9, 0x34, 0x8d, 0x41, 0xb5, 0x0d, 0x42, 0xbf, 0xfe, 0xf5, 0x88, 0x5f,
0x5a, 0x14, 0xff, 0x1d, 0x76, 0xda, 0x09, 0x36, 0xa4, 0x5d, 0xaf, 0xff, 0xbd, 0x87, 0x69, 0x9c,
0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e, 0x17, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1,
0xd7, 0x6d, 0xa0, 0x93, 0x60, 0x15, 0xbf, 0xfe, 0xf6, 0x1d, 0xa6, 0x73, 0x67, 0xdc, 0x31, 0x22,
0x62, 0x78, 0x5d, 0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x82, 0x4d,
0xcc, 0x83, 0xff, 0xff, 0xf1, 0xd3, 0x9b, 0xa2, 0x9d, 0xe7, 0x8c, 0x66, 0x72, 0xc0, 0x2f, 0xcf,
0x65, 0x9b, 0xff, 0xf7, 0xc0, 0xca, 0x22, 0x04, 0xa8, 0x24, 0xf3, 0x1d, 0x63, 0x5d, 0x84, 0x8f,
0xff, 0x44, 0x5f, 0xff, 0x9f, 0xeb, 0xff, 0x4c, 0x13, 0x36, 0x00, 0x3c, 0x22, 0xc9, 0xc2, 0xf5,
0xf9, 0xef, 0xff, 0xcf, 0xf5, 0xff, 0xa6, 0x09, 0x9b, 0x00, 0x1e, 0x11, 0x64, 0xe1, 0x7a, 0xf0,
0x2a, 0x80, 0xe0, 0x04, 0x5a, 0x21, 0x7a, 0xa9, 0x51, 0x5c, 0x9d, 0x9c, 0x8a, 0x61, 0xc3, 0xd2,
0xff, 0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf,
0xff, 0x72, 0x03, 0xdc, 0x34, 0x44, 0x7e, 0x97, 0x68, 0x3e, 0x1a, 0xca, 0x8d, 0xf6, 0xb9, 0xc9,
0xff, 0xf9, 0x98, 0x62, 0x79, 0xa6, 0x97, 0x88, 0xdb, 0x12, 0xa0, 0xdb, 0x18, 0x4a, 0x52, 0x7f,
0xfe, 0x65, 0x99, 0x2d, 0xca, 0xa3, 0x9b, 0x23, 0x17, 0x99, 0x47, 0x1b, 0x57, 0x6b, 0x24, 0xda,
0x4f, 0xff, 0x9f, 0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfc, 0x3e, 0xcf, 0xff, 0x9f,
0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfb, 0xed, 0x2b, 0xed, 0xff, 0xf5, 0xca, 0x2c,
0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x14, 0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65,
0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x2d, 0x3c, 0xe4, 0xff, 0xf9, 0xfe, 0xf1, 0xf6, 0xb4, 0x7a, 0xc8,
0x10, 0x48, 0x21, 0xb8, 0xfb, 0x3f, 0xfe, 0x7e, 0xe4, 0xc0, 0x14, 0xbb, 0xbe, 0x7e, 0xb5, 0xb7,
0xef, 0xb4, 0xfa, 0x17, 0xff, 0xd7, 0x3f, 0x91, 0xb5, 0xa5, 0x4a, 0x1d, 0xb1, 0x7b, 0x6a, 0xd4,
0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x28, 0xe4, 0xd2,
0xe3, 0x5f, 0xff, 0xde, 0xd4, 0xf2, 0x31, 0x27, 0x12, 0xa5, 0x4a, 0x95, 0x3c, 0x28, 0x50, 0xa1,
0x42, 0x85, 0x06, 0xda, 0xff, 0xfd, 0xc8, 0x0f, 0x94, 0x31, 0x1a, 0xd2, 0x66, 0x8a, 0xa8, 0x74,
0x03, 0x52, 0xcf, 0x12, 0x00, 0x52, 0x6c, 0xd3, 0x36, 0x5f, 0x46, 0x35, 0xfb, 0xc6, 0xbf, 0x4b,
0xf4, 0xbf, 0x4b, 0xf4, 0xc0, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x95, 0xf5,
0xab, 0x2f, 0xff, 0xfe, 0xd2, 0xdd, 0x9c, 0x95, 0xe0, 0x25, 0xdd, 0x39, 0xd3, 0x9d, 0x39, 0xd3,
0x9d, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x13, 0xab, 0xb0, 0x0b, 0xff,
0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x52, 0xf5, 0xff, 0xfb,
0x90, 0x1f, 0x28, 0x62, 0x35, 0xa4, 0xcd, 0x15, 0x50, 0xe8, 0x06, 0xa5, 0xb2, 0xce, 0xbf, 0x7f,
0xff, 0x14, 0x7b, 0x0e, 0x08, 0x20, 0x18, 0x32, 0x3e, 0xeb, 0x9a, 0xc6, 0xee, 0x81, 0xdf, 0xff,
0xc5, 0x0c, 0x78, 0xe1, 0x5d, 0x95, 0x29, 0x2c, 0x78, 0x61, 0xfa, 0x77, 0x12, 0x37, 0x16, 0xff,
0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf, 0xff,
0x72, 0x03, 0xe5, 0x0c, 0x46, 0xb4, 0x99, 0xa2, 0xaa, 0x1d, 0x00, 0xd4, 0xb6, 0xe7, 0x39, 0xf1,
0xaf, 0xff, 0xe2, 0x23, 0x20, 0x60, 0x5e, 0x18, 0x61, 0x88, 0x57, 0x5d, 0x75, 0xd7, 0x5d, 0x5b,
0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x81, 0xde, 0x79, 0xff, 0xff,
0xf5, 0x01, 0xe9, 0xc0, 0xa2, 0xd0, 0xd3, 0x64, 0xd6, 0x4d, 0x64, 0xd6, 0x4d, 0x65, 0x5c, 0x55,
0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x4d, 0xaa, 0x10, 0x05, 0xd9, 0x33, 0xd5, 0xc0,
0x00, 0x00, 0x03, 0x00, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xfd, 0xc6, 0x42, 0xf7, 0xff, 0xd7, 0x28,
0xb0, 0x89, 0x65, 0x97, 0xb9, 0x18, 0x6f, 0x70, 0x53, 0x17, 0xff, 0xd7, 0x28, 0xb0, 0x89, 0x65,
0x97, 0xb9, 0x18, 0x6f, 0x70, 0x30, 0x56, 0xfd, 0x5b, 0xff, 0xf2, 0x33, 0x67, 0x90, 0x81, 0x60,
0x4a, 0x3d, 0x34, 0x7d, 0x12, 0xe6, 0xff, 0xfc, 0x8a, 0xea, 0x0e, 0x95, 0x0c, 0xc0, 0x73, 0xf5,
0x83, 0xaf, 0xfa, 0x2f, 0x2f, 0xff, 0xbd, 0x87, 0x69, 0x9c, 0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e,
0x07, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1, 0xd7, 0x6d, 0xa0, 0x78, 0x35, 0x4a,
0x6d, 0xff, 0xfc, 0x50, 0xc7, 0x8e, 0x15, 0xd9, 0x52, 0x92, 0xc7, 0x86, 0x1f, 0xa7, 0x74, 0x0e,
0xff, 0xfe, 0x28, 0x63, 0xc7, 0x0a, 0xec, 0xa9, 0x49, 0x63, 0xc3, 0x0f, 0xd3, 0xb8, 0x92, 0x9f,
0x57, 0xff, 0xee, 0x40, 0x7c, 0xa1, 0x88, 0xd6, 0x93, 0x34, 0x55, 0x43, 0xa0, 0x1a, 0xa5, 0xeb,
0xff, 0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x4b, 0x13, 0x9a,
0xfc, 0x01, 0x0a, 0xb8, 0xdc, 0xe9, 0xaa, 0x51, 0xa6, 0x2f, 0x33, 0x38, 0x70, 0x9f, 0x3f, 0xff,
0x81, 0x38, 0x6f, 0x96, 0x59, 0x2c, 0x9d, 0xc5, 0x46, 0x2d, 0xbb, 0xb2, 0x86, 0x2f, 0xff, 0xde,
0x02, 0x4a, 0x0e, 0x78, 0xf4, 0x81, 0xf4, 0x0e, 0xf1, 0xaf, 0x76, 0xc4, 0x68, 0x3a, 0x7f, 0xfe,
0x7a, 0x23, 0xee, 0xeb, 0xae, 0x0b, 0xba, 0xa9, 0x83, 0xd2, 0x73, 0xc7, 0xd0, 0x9f, 0xff, 0x9e,
0x6a, 0xb4, 0x7c, 0xad, 0x6d, 0xa6, 0x32, 0xbc, 0x60, 0xd2, 0xe3, 0x9c, 0x90, 0x95, 0xe3, 0xff,
0xe9, 0x1b, 0x9a, 0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0xac, 0xb3, 0xff, 0xe9, 0x1b, 0x9a,
0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0x99, 0x5e, 0x95, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9,
0xc1, 0x16, 0xba, 0xb0, 0xf7, 0x82, 0xfd, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9, 0xc1, 0x16, 0xba,
0xb0, 0xf7, 0x81, 0x46, 0x41, 0x3d, 0xbf, 0xff, 0x01, 0x63, 0xdb, 0x18, 0x93, 0x66, 0x4d, 0xce,
0x9b, 0xce, 0x5f, 0xaf, 0xff, 0xc0, 0x35, 0x3a, 0x1b, 0x38, 0x22, 0xd7, 0x56, 0x1e, 0xf0, 0x28,
0x50, 0x74, 0xff, 0xfa, 0x4a, 0x1b, 0x55, 0xac, 0x47, 0xb6, 0x24, 0xc4, 0x4a, 0xa2, 0xcb, 0x3f,
0xfe, 0x91, 0xb9, 0xa4, 0x89, 0x64, 0x52, 0xe9, 0x2d, 0xc5, 0x79, 0x98, 0x82, 0x80,
]
static let bars709Limited: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x02,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x7b, 0xb8, 0x0b, 0x9c, 0xe7, 0x3a, 0x29, 0x4a, 0x52, 0x94,
0xa5, 0xc2, 0x9f, 0xff, 0xe8, 0xfd, 0xf0, 0x63, 0x71, 0xfc, 0x63, 0xec, 0x8f, 0x38, 0xf7, 0xff,
0xfc, 0x89, 0xda, 0xff, 0xff, 0xf0, 0x69, 0xe0, 0x74, 0x7b, 0xdf, 0x7f, 0xcc, 0x1c, 0xc1, 0xcc,
0x1c, 0xc1, 0xcc, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xc4, 0x73, 0xff,
0xd8, 0xf7, 0x40, 0x0f, 0x9f, 0x18, 0xc6, 0x31, 0x8c, 0x67, 0x39, 0xce, 0x73, 0x9c, 0xb3, 0xd2,
0x80, 0x0e, 0x4f, 0xff, 0x82, 0x8d, 0x24, 0xc8, 0xe1, 0x4d, 0xd7, 0xae, 0x61, 0xc6, 0x8f, 0xff,
0xc6, 0x7a, 0xd8, 0xf8, 0x9c, 0xf6, 0xf9, 0xcf, 0x66, 0x3d, 0xa5, 0x9a, 0x2e, 0x4f, 0x7f, 0xfc,
0x55, 0x8a, 0xa8, 0xed, 0xa7, 0x75, 0xbb, 0xe9, 0x49, 0x47, 0xff, 0xe3, 0x3d, 0x6c, 0x7c, 0x4e,
0x7b, 0x7c, 0xe7, 0xb3, 0x16, 0x79, 0xa3, 0xe3, 0xff, 0xe7, 0xab, 0xaa, 0x9f, 0x21, 0x38, 0x65,
0xc4, 0xb8, 0x18, 0x9f, 0xfe, 0x9b, 0xec, 0x4b, 0x58, 0x5d, 0xf8, 0xe5, 0xdf, 0x53, 0x6f, 0x2b,
0x0c, 0x90, 0x70, 0xa0, 0x00, 0xee, 0xdd, 0x34, 0xd4, 0x1e, 0xb0, 0x10, 0x19, 0xe6, 0x85, 0xe6,
0x00, 0x48, 0xff, 0xfd, 0x80, 0x28, 0x35, 0x78, 0x31, 0x21, 0xb6, 0x44, 0xdc, 0xed, 0xff, 0xf7,
0xd9, 0xef, 0xcd, 0xe3, 0x09, 0x63, 0x63, 0xc8, 0x0d, 0x40, 0x1c, 0x5f, 0xff, 0x07, 0x4b, 0x3e,
0xdd, 0x9f, 0x5f, 0x13, 0x8f, 0x5e, 0x4d, 0x7f, 0xfb, 0x27, 0xc1, 0xaa, 0x45, 0xe8, 0x00, 0x78,
0x18, 0xd2, 0x7b, 0x04, 0x56, 0x7f, 0xfd, 0x49, 0x9f, 0xfe, 0xb6, 0xbb, 0x0b, 0x58, 0x86, 0x8b,
0xdf, 0xff, 0x63, 0x53, 0x7d, 0x4f, 0xd7, 0x6d, 0x83, 0xdd, 0x24, 0x58, 0x37, 0xe3, 0xff, 0xd2,
0xa0, 0xff, 0xea, 0x7c, 0x38, 0xbd, 0xc8, 0xcb, 0x41, 0xff, 0xf3, 0xe1, 0x33, 0x34, 0x62, 0xe7,
0x3d, 0xc8, 0x0b, 0x72, 0xea, 0x02, 0x8a, 0x33, 0xff, 0xff, 0xa6, 0xb4, 0x4c, 0x89, 0x44, 0x92,
0x49, 0x25, 0xa6, 0x9a, 0x69, 0xa6, 0x9a, 0x60, 0x3f, 0xff, 0xf3, 0x35, 0x41, 0x04, 0x41, 0xef,
0xf0, 0x95, 0xfa, 0x4c, 0x21, 0x48, 0x56, 0x7f, 0x6a, 0x62, 0x00, 0xb3, 0xd4, 0x0d, 0xe9, 0xf0,
0x69, 0x96, 0x4e, 0x10, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x38, 0x43, 0x84, 0x38, 0x43, 0x84,
0x38, 0x43, 0x84, 0x38, 0x42, 0xdf, 0x7f, 0x9a, 0xfd, 0xff, 0xff, 0xb9, 0x77, 0x71, 0x05, 0xa5,
0x8b, 0x43, 0x77, 0x37, 0x73, 0x77, 0x37, 0x73, 0x7b, 0x07, 0xb0, 0x7b, 0x07, 0xb0, 0x7b, 0x07,
0xb0, 0x7b, 0x07, 0x92, 0x20, 0x6d, 0xff, 0xfe, 0xb4, 0xe9, 0xde, 0x13, 0xd7, 0xc9, 0xbc, 0x56,
0xf3, 0xee, 0xa8, 0xe4, 0x3f, 0xff, 0xa1, 0xf0, 0xfc, 0x2e, 0x5d, 0x4d, 0xac, 0xc7, 0x3f, 0x7b,
0x12, 0xf6, 0x0d, 0x0c, 0x84, 0xff, 0xfb, 0x0e, 0x4f, 0x86, 0x62, 0xe4, 0x29, 0xcd, 0x16, 0x0d,
0x1c, 0xd5, 0xf4, 0xff, 0xfc, 0xd6, 0x57, 0xd6, 0xef, 0x5c, 0x7f, 0xf4, 0x23, 0xc4, 0x3b, 0xff,
0x44, 0x22, 0xf3, 0xff, 0xf1, 0x4c, 0x00, 0x90, 0x41, 0x74, 0x0e, 0xdb, 0x83, 0xdb, 0x98, 0x09,
0xfd, 0xff, 0xf6, 0xfe, 0x6f, 0x2f, 0xb4, 0x11, 0xde, 0x8f, 0x3c, 0xed, 0x20, 0xdb, 0x5c, 0x20,
0xf4, 0xf5, 0x7f, 0xff, 0x37, 0xb3, 0x9d, 0x94, 0xa5, 0x29, 0xc9, 0x4a, 0x52, 0x94, 0xa4, 0x4d,
0xff, 0xf3, 0x75, 0xe0, 0xaa, 0xe3, 0x22, 0xfb, 0x9e, 0x89, 0xbf, 0x1c, 0x4c, 0x9f, 0xff, 0xfb,
0x72, 0x76, 0xc9, 0x19, 0x67, 0xaf, 0x6f, 0x7e, 0xf7, 0xef, 0x7e, 0xf7, 0xef, 0xb6, 0x7b, 0x67,
0xb6, 0x7b, 0x67, 0xb6, 0x7b, 0x67, 0xb6, 0x78, 0x08, 0xd8, 0x96, 0x00, 0xbe, 0x62, 0xfd, 0xef,
0x7b, 0xdf, 0x2a, 0x10, 0x84, 0x21, 0x06, 0xa8, 0x0d, 0x2f, 0xff, 0x8c, 0x55, 0xb0, 0xd2, 0xf0,
0x2a, 0xe6, 0xfa, 0xe9, 0xef, 0xff, 0xe8, 0xfa, 0x01, 0x39, 0xc2, 0x9c, 0x8f, 0x09, 0xe8, 0xf5,
0x30, 0xa2, 0xaf, 0xff, 0x1f, 0x61, 0x54, 0xea, 0xd1, 0xd6, 0xde, 0xc5, 0x29, 0x4f, 0xff, 0x9e,
0xf9, 0x05, 0x2a, 0x46, 0x6f, 0x36, 0x3c, 0x0c, 0xa8, 0xb2, 0x7f, 0xff, 0x10, 0x1f, 0xff, 0x8e,
0x40, 0x9d, 0x84, 0xfb, 0x4f, 0x3f, 0xff, 0x8b, 0xc9, 0x5b, 0x85, 0x13, 0x47, 0x02, 0xc4, 0x4e,
0xf7, 0x00, 0xbd, 0x7f, 0xfd, 0xc5, 0xa0, 0xda, 0x81, 0x9a, 0x24, 0xd8, 0x3d, 0x5c, 0x15, 0x53,
0xfb, 0xbf, 0xfe, 0xf7, 0xe2, 0x20, 0x48, 0xd8, 0xf0, 0xa3, 0xa0, 0x20, 0xd8, 0x91, 0x6c, 0x02,
0xb1, 0xff, 0xf7, 0xe3, 0x2f, 0x59, 0xc2, 0x49, 0x94, 0x57, 0x95, 0xcb, 0x93, 0x3a, 0xf8, 0xff,
0xfb, 0xc1, 0x6f, 0x38, 0x3c, 0x77, 0x60, 0x88, 0x59, 0xdf, 0x60, 0xb2, 0x38, 0xe9, 0xd9, 0x00,
0x1d, 0x78, 0xe9, 0xc8, 0x3a, 0x32, 0x69, 0x9c, 0x9d, 0xb6, 0xaa, 0xbf, 0xff, 0x43, 0x59, 0x62,
0x04, 0x19, 0x30, 0xcb, 0xb7, 0xbb, 0xb8, 0xbf, 0x13, 0x3f, 0xff, 0x4d, 0xaa, 0x30, 0x0e, 0x2d,
0xd4, 0xce, 0xc1, 0x74, 0x43, 0x75, 0xd4, 0xac, 0x51, 0xff, 0xf7, 0xb5, 0x7d, 0x74, 0x83, 0xc8,
0xce, 0x4e, 0xcc, 0xce, 0xd8, 0x34, 0xc9, 0xff, 0xf7, 0x9a, 0xf2, 0xaa, 0xcb, 0x45, 0x75, 0xe2,
0x6c, 0x41, 0xf5, 0xd9, 0xe3, 0xdd, 0x2b, 0xff, 0xfe, 0x2f, 0x25, 0x6e, 0x14, 0x4d, 0x1c, 0x0b,
0x11, 0x43, 0x57, 0xff, 0x8a, 0xf6, 0xdb, 0x90, 0x47, 0xf1, 0x2c, 0x72, 0x5f, 0x12, 0xf3, 0xbf,
0xfd, 0xb6, 0x8c, 0x1f, 0x2a, 0x79, 0x43, 0xec, 0x20, 0x9c, 0xcf, 0xff, 0x6c, 0x8a, 0x5d, 0x2e,
0xe4, 0x89, 0x32, 0x9a, 0xe5, 0xa6, 0xe8, 0xab, 0xff, 0xf1, 0xaa, 0x55, 0x4e, 0x89, 0x31, 0xd5,
0x8f, 0x95, 0xfa, 0x7f, 0xf8, 0x83, 0x93, 0x10, 0xaf, 0x77, 0xdb, 0x76, 0x14, 0x41, 0x6b, 0x2f,
0xff, 0xdb, 0xb3, 0xff, 0xee, 0xc9, 0xc6, 0xfe, 0x3a, 0x41, 0x29, 0xff, 0xed, 0x04, 0xf6, 0x57,
0xff, 0xad, 0x42, 0x1c, 0xbd, 0xb4, 0x6c, 0xa7, 0x9c, 0x87, 0xff, 0xfe, 0x68, 0xd4, 0xc9, 0x92,
0xa5, 0x0d, 0x13, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0xad, 0x6b, 0x5a, 0xd6, 0xb5, 0xad, 0x6b, 0x5a,
0xd6, 0xa0, 0xbf, 0xff, 0xe3, 0xc2, 0xd8, 0x6d, 0xe4, 0xc2, 0xf4, 0x25, 0xcb, 0x59, 0x2d, 0x72,
0x69, 0x07, 0x74, 0x08, 0x72, 0x03, 0xa8, 0x50, 0x05, 0xd6, 0xc4, 0x6f, 0x61, 0x7c, 0x5e, 0x8f,
0xb1, 0x6c, 0x68, 0x31, 0xa0, 0xc6, 0x83, 0x1a, 0x0c, 0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9b, 0x82,
0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9a, 0x13, 0x7a, 0xec, 0x03, 0x8b, 0x5c, 0xd7, 0xad, 0xfa, 0x2c,
0x33, 0xd1, 0x4e, 0x0c, 0x78, 0x31, 0xe0, 0xc7, 0x83, 0x1e, 0x10, 0x04, 0x40, 0x11, 0x00, 0x44,
0x01, 0x10, 0x04, 0x40, 0x11, 0x00, 0x43, 0x9c, 0x89, 0x17, 0xff, 0xfd, 0x9c, 0xe7, 0x03, 0xc3,
0xae, 0xde, 0x4e, 0xb4, 0xa4, 0x8b, 0xab, 0x48, 0x6a, 0xfd, 0x60, 0x7c, 0x57, 0x1f, 0xff, 0xd9,
0xb4, 0x6f, 0x2c, 0x0c, 0xc9, 0xc2, 0xcd, 0xf5, 0x60, 0x12, 0x59, 0xc5, 0xae, 0xf1, 0x27, 0x19,
0x97, 0x69, 0xa5, 0xff, 0xfe, 0xcc, 0xd6, 0x37, 0x17, 0x92, 0xb3, 0x66, 0x3f, 0xdb, 0x68, 0x44,
0x9e, 0x21, 0x12, 0x8a, 0x48, 0x6e, 0xbf, 0xff, 0xb3, 0x00, 0xda, 0x17, 0x61, 0x02, 0xfd, 0x26,
0x97, 0x1d, 0x83, 0x48, 0x87, 0x5a, 0x4e, 0xcb, 0x8f, 0x24, 0x08, 0xbf, 0xff, 0xa5, 0x31, 0x1b,
0x76, 0x35, 0x05, 0x4a, 0x6b, 0x42, 0x63, 0x65, 0x8e, 0x3d, 0x96, 0x3e, 0x36, 0xef, 0xff, 0xff,
0xe9, 0x3f, 0x11, 0xd7, 0xa0, 0x62, 0x26, 0x55, 0xb4, 0x6c, 0xb0, 0x97, 0x1f, 0x37, 0xbb, 0x6c,
0x1c, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0x34, 0xc7, 0x82, 0x42, 0x10, 0x85, 0x44, 0x21, 0x08,
0x42, 0x17, 0x27, 0xff, 0xd9, 0x5a, 0x82, 0x0e, 0xab, 0x3d, 0xa6, 0xf5, 0x32, 0x85, 0xb7, 0x7f,
0xff, 0xf8, 0xc7, 0xf1, 0x63, 0xc2, 0xcd, 0xea, 0x59, 0x25, 0x92, 0x59, 0x25, 0x92, 0x59, 0x7d,
0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x92, 0x8d, 0xf2, 0x00, 0x48, 0xbc, 0xcb,
0x5a, 0xd6, 0xb5, 0xc0, 0xa5, 0x29, 0x4a, 0x52, 0x06, 0x77, 0xcf, 0xff, 0xb8, 0x30, 0x59, 0x27,
0xf7, 0x62, 0x38, 0x0c, 0xa1, 0x35, 0xff, 0xec, 0x9b, 0xb6, 0x5d, 0x9a, 0x02, 0x56, 0xf3, 0xc6,
0x71, 0x48, 0xbb, 0x5f, 0xfe, 0xd6, 0x42, 0xaa, 0x1d, 0x7a, 0xde, 0x77, 0x06, 0xab, 0xaf, 0xff,
0x64, 0xdd, 0xb2, 0xec, 0xd0, 0x12, 0xb7, 0x9e, 0x33, 0x80, 0x15, 0xbf, 0xfd, 0xac, 0x85, 0x54,
0x3a, 0xf5, 0xbc, 0xee, 0x0d, 0x57, 0x5f, 0xfe, 0xc9, 0xbb, 0x65, 0xd9, 0xa0, 0x25, 0x6f, 0x3c,
0x67, 0x26, 0x41, 0xff, 0xff, 0xfd, 0x21, 0x61, 0xf6, 0xe8, 0x83, 0x3c, 0x43, 0x61, 0x53, 0x6b,
0xfc, 0x33, 0xa6, 0xbe, 0x35, 0x59, 0x89, 0xbf, 0xff, 0xca, 0x91, 0xd0, 0xc1, 0x31, 0xc1, 0xe7,
0xbb, 0x89, 0xf6, 0x15, 0x87, 0x70, 0x29, 0xba, 0x64, 0xba, 0x7e, 0x88, 0xbf, 0xff, 0xb2, 0x30,
0xd1, 0x95, 0xef, 0xe1, 0xec, 0x3b, 0xef, 0xd8, 0x40, 0x72, 0x7f, 0x53, 0x27, 0xc6, 0x4c, 0xfb,
0xff, 0xfb, 0x23, 0x0d, 0x19, 0x5e, 0xfe, 0x1e, 0xc3, 0xbe, 0xfd, 0x84, 0x07, 0x27, 0xf5, 0x32,
0x7c, 0x64, 0x69, 0x54, 0x07, 0x00, 0x21, 0xa1, 0xb8, 0x31, 0xe7, 0x92, 0x4d, 0x21, 0x98, 0xe1,
0xe9, 0x7f, 0xff, 0x8d, 0x04, 0x56, 0x50, 0xf3, 0x87, 0x8e, 0xe9, 0x2c, 0x8b, 0x9a, 0x5d, 0x0e,
0x69, 0x7a, 0xdf, 0xb3, 0x5f, 0xff, 0xe3, 0x40, 0xd3, 0x1c, 0xfe, 0x0e, 0x49, 0x93, 0x22, 0xcd,
0xe3, 0xac, 0xa4, 0x02, 0xfb, 0xa5, 0x1b, 0xab, 0x9c, 0x9f, 0xff, 0xd6, 0x74, 0xad, 0x35, 0x38,
0x54, 0x07, 0xfc, 0xf4, 0x47, 0x3e, 0xc8, 0x84, 0xfb, 0x24, 0x7a, 0xe0, 0x27, 0xff, 0xf5, 0x9d,
0x13, 0x49, 0x92, 0x11, 0x5f, 0xc8, 0xd6, 0x12, 0xc1, 0xc0, 0x69, 0x64, 0x95, 0x44, 0x79, 0x86,
0x49, 0xb4, 0x9f, 0xfe, 0x3f, 0x97, 0x6d, 0x34, 0x2f, 0xd0, 0xc9, 0x77, 0xb6, 0x27, 0xff, 0x8f,
0xe5, 0xdb, 0x4d, 0x0b, 0xf4, 0x32, 0x5d, 0xe3, 0x55, 0x7d, 0xbf, 0xfd, 0x07, 0x21, 0x7d, 0xd6,
0x5c, 0x66, 0xa7, 0xd9, 0x86, 0xaf, 0xff, 0x41, 0xc8, 0x5f, 0x75, 0x97, 0x19, 0xa9, 0xf6, 0x50,
0x7a, 0x79, 0xc9, 0xff, 0xe5, 0x1b, 0x2a, 0x9d, 0xe9, 0xe9, 0x3d, 0x4b, 0x9a, 0x64, 0xff, 0xf1,
0xfc, 0xbb, 0x69, 0xa1, 0x7e, 0x86, 0x4b, 0xbc, 0x6a, 0xfa, 0x17, 0xff, 0xa4, 0xbb, 0x55, 0x2a,
0x0d, 0xeb, 0x17, 0x5e, 0x75, 0x2b, 0xff, 0xd0, 0x72, 0x17, 0xdd, 0x65, 0xc6, 0x6a, 0x7d, 0x94,
0x1c, 0x72, 0x69, 0x71, 0xaf, 0xff, 0xe8, 0xc5, 0xf4, 0xd9, 0x5a, 0x12, 0x49, 0x24, 0xda, 0x28,
0xa2, 0x8a, 0x28, 0x9d, 0x85, 0xff, 0xf9, 0x58, 0x68, 0xe1, 0xbe, 0x89, 0x87, 0x0a, 0x41, 0xbd,
0x40, 0x5a, 0xbe, 0x24, 0x00, 0xa4, 0xd7, 0xb7, 0x6b, 0xc7, 0x0b, 0x74, 0x72, 0x39, 0x7e, 0x97,
0xe9, 0x7e, 0x97, 0xe9, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe7, 0xf1,
0x59, 0x7f, 0xff, 0xf5, 0x72, 0xea, 0x9c, 0xa5, 0xe0, 0xe5, 0xe5, 0x3e, 0x53, 0xe5, 0x3e, 0x53,
0xe5, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x54, 0x5d, 0x80, 0x5f, 0xff,
0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98, 0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xe9, 0xaf, 0xff, 0xca, 0xc3,
0x47, 0x0d, 0xf4, 0x4c, 0x38, 0x52, 0x0d, 0xea, 0x02, 0xd6, 0x96, 0x75, 0xfb, 0xff, 0xf4, 0xf5,
0xfe, 0xc9, 0x8b, 0xed, 0xa0, 0xd4, 0x3f, 0xd4, 0x4e, 0xf3, 0xbb, 0xff, 0xf4, 0xf2, 0xb7, 0x3b,
0x35, 0x34, 0x01, 0xbd, 0xcf, 0xc7, 0x6b, 0xc8, 0x86, 0xe2, 0xdf, 0xff, 0x95, 0xa6, 0x26, 0x6d,
0x66, 0xef, 0x24, 0x4f, 0x9c, 0x50, 0x2e, 0x0b, 0x5f, 0xff, 0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98,
0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xad, 0x73, 0x9c, 0xf8, 0xd7, 0xff, 0xe5, 0x69, 0x89, 0x9b, 0x5a,
0xd6, 0xc7, 0xef, 0x7b, 0xde, 0xf7, 0x16, 0xbf, 0xfd, 0x93, 0xe0, 0xd5, 0x22, 0xf4, 0x00, 0x3c,
0x0c, 0x4d, 0xbe, 0x7f, 0xff, 0xfd, 0x40, 0x7a, 0x70, 0x28, 0xb4, 0x34, 0xd9, 0x35, 0x93, 0x59,
0x35, 0x93, 0x59, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x13, 0x6a, 0x84,
0x01, 0x71, 0x0e, 0xa9, 0x4a, 0x52, 0x95, 0x06, 0xb5, 0xad, 0x6b, 0x51, 0x99, 0x0b, 0xdf, 0xfe,
0x83, 0xd1, 0xc1, 0x15, 0xe1, 0x93, 0x97, 0xb2, 0x8b, 0xd5, 0xff, 0xe8, 0x3d, 0x1c, 0x11, 0x5e,
0x19, 0x39, 0x7b, 0x28, 0x32, 0x5b, 0xf5, 0x6f, 0xff, 0x84, 0x61, 0x55, 0x1a, 0xb6, 0xa2, 0x05,
0x17, 0xea, 0x77, 0xff, 0xbf, 0xa1, 0xda, 0x6b, 0x8b, 0x14, 0x60, 0xb1, 0x38, 0x81, 0x17, 0x97,
0xff, 0xb5, 0x90, 0xaa, 0x87, 0x5e, 0xb7, 0x9d, 0xc1, 0x9a, 0xeb, 0xff, 0xd9, 0x3e, 0x0d, 0x52,
0x2f, 0x40, 0x03, 0xc0, 0xc4, 0xe5, 0x54, 0xa6, 0xdf, 0xff, 0xa7, 0x95, 0xb9, 0xd9, 0xa9, 0xa0,
0x0d, 0xee, 0x7e, 0x3b, 0x5e, 0x72, 0xef, 0xff, 0xd3, 0xca, 0xdc, 0xec, 0xd4, 0xd0, 0x06, 0xf7,
0x3f, 0x1d, 0xaf, 0x22, 0x29, 0xf5, 0x7f, 0xfe, 0x56, 0x1a, 0x38, 0x6f, 0xa2, 0x61, 0xc2, 0x90,
0x6f, 0x50, 0x17, 0xa6, 0xbf, 0xff, 0x2b, 0x0d, 0x1c, 0x37, 0xd1, 0x30, 0xe1, 0x48, 0x37, 0xa8,
0x0b, 0x55, 0x39, 0xaf, 0xc0, 0x10, 0x61, 0x2d, 0x46, 0x41, 0x11, 0x62, 0x85, 0x8e, 0x38, 0x4f,
0x9f, 0xff, 0xa0, 0xec, 0x48, 0x58, 0xc2, 0x3d, 0xd1, 0xc3, 0x19, 0xc3, 0x9c, 0x10, 0x5f, 0xff,
0x9a, 0x0a, 0x59, 0x0b, 0x58, 0xda, 0x57, 0xf6, 0xb4, 0x6d, 0xdb, 0xd2, 0xd0, 0x74, 0xff, 0xfb,
0x6e, 0x83, 0xdb, 0x08, 0x11, 0x73, 0xd9, 0x78, 0x19, 0x7e, 0x5b, 0xa4, 0xff, 0xfb, 0x6d, 0x28,
0x6c, 0x1c, 0x47, 0xe7, 0xaa, 0x54, 0x14, 0x29, 0x10, 0x54, 0x84, 0xaf, 0x1f, 0xfe, 0x55, 0xcd,
0xa1, 0xdc, 0xc5, 0x8c, 0x5f, 0x8b, 0xa9, 0x49, 0xff, 0xe5, 0x5c, 0xda, 0x1d, 0xcc, 0x58, 0xc5,
0xf8, 0xb9, 0xf9, 0xf4, 0xab, 0xff, 0xda, 0xe2, 0xc5, 0x4b, 0x14, 0xd2, 0x8e, 0xe6, 0xbb, 0x98,
0xbf, 0xfd, 0xae, 0x2c, 0x54, 0xb1, 0x4d, 0x28, 0xee, 0x6b, 0xab, 0xd2, 0x09, 0xed, 0xff, 0xee,
0x2e, 0x2a, 0xa2, 0x37, 0x77, 0x7d, 0xbd, 0x0c, 0x42, 0xff, 0xf6, 0xb8, 0xb1, 0x52, 0xc5, 0x34,
0xa3, 0xb9, 0xae, 0xae, 0xd0, 0x74, 0xff, 0xf3, 0x35, 0xea, 0xa4, 0x9a, 0x1c, 0x9b, 0xe5, 0xac,
0xa2, 0x7f, 0xf9, 0x57, 0x36, 0x87, 0x73, 0x16, 0x31, 0x7e, 0x2e, 0x7e, 0xd0, 0x50,
]
static let bars709Full: [UInt8] = [
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6e, 0x02, 0x02, 0x02,
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x31,
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xca, 0xe6,
0x94, 0x42, 0x38, 0x60, 0x38, 0x52, 0x45, 0x24, 0x52, 0x45, 0x24, 0x55, 0x59, 0x55, 0x95, 0x59,
0x55, 0x95, 0x59, 0x55, 0x95, 0x59, 0x56, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x91, 0xc3, 0x2c, 0x30, 0xc3, 0x0c, 0x31, 0x1c, 0x71, 0xc7,
0x1c, 0x71, 0xc7, 0x85, 0x3f, 0xff, 0xda, 0x86, 0xda, 0x58, 0x75, 0x4a, 0x3b, 0xc1, 0xa2, 0x82,
0x3d, 0xff, 0xff, 0x22, 0x76, 0xbf, 0xff, 0xfc, 0xd8, 0x79, 0x99, 0x24, 0xe8, 0x0f, 0x75, 0xff,
0x5f, 0xf5, 0xff, 0x5f, 0xf6, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe0,
0x9c, 0xff, 0xf6, 0x3d, 0xd0, 0x03, 0xeb, 0xce, 0x73, 0x9c, 0xe7, 0x3a, 0xd6, 0xb5, 0xad, 0x6b,
0x4c, 0xf4, 0xa0, 0x03, 0x93, 0xff, 0xe6, 0x75, 0xd0, 0x3d, 0xcf, 0xf0, 0x4e, 0xd2, 0x72, 0x8d,
0x74, 0x7f, 0xfe, 0x89, 0x44, 0x87, 0x74, 0x94, 0xe6, 0x55, 0x90, 0x59, 0x45, 0xa5, 0x9a, 0x2e,
0x4f, 0x7f, 0xfc, 0xeb, 0x9a, 0x21, 0xb2, 0xc8, 0x3c, 0x30, 0xf4, 0x24, 0x7a, 0x3f, 0xff, 0x44,
0xa2, 0x43, 0xba, 0x4a, 0x73, 0x2a, 0xc8, 0x2c, 0x9a, 0x79, 0xa3, 0xe3, 0xff, 0xeb, 0x8f, 0x95,
0x52, 0x72, 0x52, 0x34, 0xc8, 0xe9, 0x8b, 0x44, 0xff, 0xf6, 0xf0, 0x3a, 0x83, 0x7c, 0x5c, 0xce,
0xd4, 0xba, 0x89, 0x3f, 0x2b, 0x0c, 0x90, 0x70, 0xa0, 0x00, 0xf0, 0x2c, 0x88, 0xe9, 0x8e, 0x1f,
0xe0, 0x06, 0x79, 0xa1, 0x79, 0x80, 0x12, 0x3f, 0xff, 0x7d, 0xbc, 0x64, 0xb9, 0x97, 0xb8, 0xe8,
0x1b, 0x03, 0xeb, 0x6f, 0xff, 0xca, 0xe9, 0xb5, 0x52, 0xa6, 0x29, 0x29, 0xfc, 0xfb, 0x8c, 0x40,
0x1c, 0x5f, 0xff, 0x35, 0x77, 0x52, 0xf6, 0x79, 0xec, 0xa4, 0x7e, 0x2a, 0xdf, 0xaf, 0xff, 0x81,
0xc0, 0x3b, 0x0b, 0x8b, 0x27, 0xcf, 0x6a, 0xd7, 0x4c, 0xec, 0x11, 0x59, 0xff, 0xf6, 0xe2, 0x9f,
0xfe, 0xb6, 0x70, 0xc4, 0x83, 0x1c, 0x05, 0xbb, 0xff, 0xf0, 0x0d, 0x33, 0xc8, 0xb1, 0xeb, 0x05,
0x9f, 0x5c, 0xc7, 0x20, 0xdf, 0x8f, 0xff, 0x67, 0x88, 0xff, 0xea, 0x77, 0x5e, 0x4b, 0x79, 0xd4,
0xd2, 0x3f, 0xfe, 0xc4, 0x27, 0x79, 0x7b, 0xdc, 0x96, 0x40, 0xcd, 0xc7, 0x6f, 0xa8, 0x0a, 0x28,
0xcf, 0xff, 0xfe, 0xef, 0xdb, 0xd4, 0xd0, 0x3c, 0x78, 0xf1, 0xe3, 0xd0, 0x20, 0x40, 0x81, 0x02,
0x04, 0x07, 0x43, 0xff, 0xff, 0x55, 0x80, 0x26, 0x9f, 0xe0, 0xb3, 0x64, 0xa3, 0x87, 0x88, 0x9c,
0xee, 0x95, 0x9f, 0xda, 0x98, 0x80, 0x2c, 0xf5, 0xd9, 0x3a, 0xe6, 0xfa, 0xd0, 0x75, 0x46, 0xd0,
0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1,
0x03, 0xb7, 0xf9, 0xaf, 0xdf, 0xff, 0xfc, 0x4a, 0xae, 0x23, 0x6e, 0x1b, 0xcb, 0xd4, 0x2e, 0x02,
0xe0, 0x2e, 0x02, 0xe0, 0x2e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xe7,
0xc8, 0x1b, 0x7f, 0xff, 0xbb, 0x0c, 0x4b, 0xdb, 0x02, 0x66, 0xaa, 0x94, 0xf4, 0xed, 0xd6, 0x0d,
0xd4, 0x3f, 0xff, 0xb1, 0xa5, 0x3e, 0xed, 0x3c, 0xe9, 0x07, 0xa9, 0x44, 0xdf, 0xb9, 0x10, 0x00,
0x34, 0x32, 0x13, 0xff, 0xef, 0x84, 0xe7, 0xf8, 0x61, 0x80, 0x24, 0xdf, 0xcb, 0x21, 0xe5, 0xb1,
0x52, 0x09, 0xff, 0xfa, 0xbb, 0x0c, 0x7c, 0xe1, 0xdb, 0x30, 0xf6, 0x95, 0x55, 0xf5, 0x15, 0xdf,
0x61, 0x17, 0x9f, 0xff, 0x9d, 0xf9, 0xe9, 0x79, 0xe7, 0x7b, 0x14, 0x7a, 0x36, 0x0d, 0x1b, 0xc7,
0x1c, 0xf7, 0xff, 0xe1, 0xfd, 0x7f, 0x44, 0xd8, 0x9b, 0xcf, 0x7a, 0x6e, 0x17, 0x38, 0xcc, 0x8c,
0xc2, 0x0f, 0x4f, 0x57, 0xff, 0xf7, 0x55, 0x2b, 0x2a, 0xaa, 0xaa, 0xaa, 0xb2, 0x79, 0xe7, 0x9e,
0x79, 0xe7, 0x59, 0xbf, 0xfe, 0xe7, 0x49, 0x8d, 0x06, 0x01, 0xfc, 0x41, 0x0b, 0xa7, 0x35, 0xc7,
0x13, 0x27, 0xff, 0xff, 0x14, 0x1e, 0x21, 0x48, 0x15, 0xf9, 0xbc, 0xbd, 0xcb, 0xdc, 0xbd, 0xcb,
0xdc, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xab, 0xe2, 0x36, 0x25, 0x80,
0x2f, 0xfb, 0x08, 0xc6, 0x31, 0x8c, 0x6f, 0xad, 0x6b, 0x5a, 0xd6, 0x36, 0x03, 0x4b, 0xff, 0xeb,
0xc5, 0x63, 0x41, 0x65, 0x0a, 0x0c, 0x08, 0x18, 0xba, 0xd7, 0xff, 0xf7, 0xf2, 0x5f, 0x53, 0x0e,
0xbe, 0xd7, 0xe6, 0x1a, 0xd5, 0x75, 0x30, 0xa2, 0xaf, 0xff, 0x5e, 0x5e, 0x52, 0x94, 0x4f, 0x21,
0x29, 0x08, 0xa5, 0xbf, 0xbc, 0xff, 0xfb, 0xc1, 0xf6, 0xc9, 0x77, 0xc4, 0x88, 0x96, 0xee, 0x22,
0xbd, 0x16, 0x4f, 0xff, 0xea, 0x6c, 0x84, 0x20, 0xfc, 0xc6, 0x13, 0x44, 0x4d, 0x48, 0x56, 0x7f,
0xff, 0x5d, 0x66, 0xc1, 0x99, 0x29, 0xb7, 0xe2, 0x87, 0x73, 0x1f, 0xdc, 0x02, 0xf5, 0xff, 0xf8,
0x93, 0x6e, 0xdb, 0xb9, 0xee, 0x66, 0x4b, 0x3c, 0xfd, 0x63, 0xc6, 0x4c, 0x7b, 0xff, 0xf2, 0x3f,
0xb0, 0x4a, 0xa8, 0xc5, 0x0e, 0x87, 0x4e, 0x84, 0xf5, 0x99, 0x36, 0x80, 0xac, 0x7f, 0xfe, 0x4f,
0x7b, 0xad, 0x46, 0x83, 0x6c, 0xb9, 0xa6, 0x5d, 0x76, 0xca, 0x57, 0x08, 0xff, 0xfc, 0x76, 0xd9,
0x45, 0x85, 0x77, 0xf4, 0x9a, 0x09, 0xcb, 0xc2, 0x6e, 0x53, 0x03, 0xa7, 0x64, 0x00, 0x78, 0x92,
0xba, 0xea, 0xdd, 0xb9, 0xae, 0xec, 0xc9, 0xdb, 0x6a, 0xab, 0xff, 0xf6, 0x2c, 0x94, 0x07, 0x9e,
0x76, 0x5b, 0x4e, 0x17, 0x35, 0x0b, 0xad, 0xd6, 0x79, 0xff, 0xfb, 0x5b, 0x84, 0xd1, 0x00, 0x16,
0xc4, 0x5b, 0xa1, 0x12, 0xa1, 0xaf, 0x57, 0xd5, 0x8a, 0x3f, 0xff, 0x22, 0xe5, 0xff, 0xe7, 0x9e,
0x2a, 0x10, 0xbe, 0x98, 0xff, 0x4e, 0x32, 0x46, 0x9f, 0xff, 0x90, 0x1c, 0x19, 0x58, 0xeb, 0x75,
0xba, 0x0b, 0x50, 0xf1, 0xa6, 0xfd, 0x2c, 0xf7, 0x4a, 0xff, 0xff, 0xae, 0xb3, 0x60, 0xcc, 0x94,
0xdb, 0xf1, 0x43, 0xb9, 0x93, 0xab, 0xff, 0xd7, 0x10, 0x0f, 0x3b, 0x8a, 0xf4, 0xc9, 0x7c, 0x5d,
0xbb, 0xf2, 0xf3, 0xbf, 0xfe, 0x66, 0x6f, 0x82, 0x36, 0xed, 0xe9, 0xbf, 0x6d, 0x54, 0xc7, 0x9f,
0xff, 0x31, 0xaf, 0x10, 0x37, 0x79, 0xc3, 0xe4, 0x44, 0x04, 0xc7, 0x9b, 0xa2, 0xaf, 0xff, 0xd6,
0xbd, 0x8c, 0x63, 0x02, 0xd0, 0x1b, 0xdc, 0x6f, 0xdb, 0x96, 0x9f, 0xfe, 0xb0, 0xd5, 0x05, 0x68,
0x65, 0xb1, 0x62, 0xa5, 0xb2, 0x19, 0xb5, 0x97, 0xff, 0xf2, 0xf0, 0xd6, 0xb5, 0xa6, 0x77, 0xe3,
0x3d, 0x8d, 0x17, 0xb5, 0x4f, 0xff, 0x95, 0xc6, 0x26, 0x53, 0xc2, 0x7f, 0xfb, 0x3c, 0xb7, 0x47,
0xc6, 0xca, 0x79, 0xc8, 0x7f, 0xff, 0xea, 0x14, 0x53, 0xa7, 0x46, 0x89, 0xb3, 0x0b, 0x10, 0xb1,
0x0b, 0x10, 0xb1, 0x0c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xbf, 0xcb,
0xff, 0xfe, 0x78, 0xff, 0xb5, 0x5e, 0xcd, 0x7d, 0xb8, 0x9e, 0x9b, 0xf6, 0x77, 0x9c, 0xb6, 0x6b,
0xb3, 0xd6, 0xbc, 0x5a, 0x3a, 0x85, 0x00, 0x5d, 0x6e, 0x8f, 0x57, 0x3b, 0xf7, 0x0d, 0x2d, 0x5f,
0x2f, 0xa4, 0xbe, 0x92, 0xfa, 0x4b, 0xe9, 0x30, 0x02, 0x40, 0x09, 0x00, 0x24, 0x00, 0x90, 0x02,
0x40, 0x09, 0x00, 0x23, 0xe9, 0x97, 0xae, 0xc0, 0x38, 0xb7, 0x4c, 0x8b, 0x9f, 0x2b, 0x82, 0xc6,
0x93, 0xf6, 0xc3, 0xdb, 0x0f, 0x6c, 0x3d, 0xb0, 0xf6, 0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xbf, 0x26,
0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xb8, 0xd8, 0x91, 0x7f, 0xff, 0xde, 0xf2, 0x9f, 0x72, 0x5f, 0xa4,
0xdf, 0x42, 0xd1, 0x53, 0xe9, 0xe1, 0x81, 0x9b, 0x85, 0x88, 0x7b, 0xb1, 0xf1, 0xff, 0xfd, 0xed,
0xc1, 0xe8, 0x25, 0x65, 0xdd, 0x23, 0xcd, 0xe2, 0x3c, 0xbd, 0xd5, 0x70, 0xef, 0xfc, 0x2b, 0x7e,
0xd8, 0x76, 0x9a, 0x5f, 0xff, 0xef, 0x62, 0xf8, 0x1e, 0xc4, 0x28, 0xd5, 0xd5, 0x53, 0x4b, 0xb8,
0x01, 0x3b, 0x94, 0x00, 0x9d, 0xe8, 0x5e, 0x76, 0xaf, 0xff, 0xef, 0x57, 0x8e, 0x51, 0x21, 0xe7,
0xdc, 0x18, 0x7b, 0xe1, 0xc7, 0xea, 0x80, 0xfa, 0xfa, 0x1b, 0x98, 0x35, 0x31, 0x02, 0x2f, 0xff,
0xec, 0x5a, 0x9f, 0x51, 0x14, 0xaa, 0x31, 0xb1, 0x17, 0x53, 0x71, 0xf9, 0x54, 0xac, 0xfc, 0xaa,
0x7a, 0x67, 0x94, 0x7f, 0xff, 0xec, 0x4f, 0x2d, 0xf7, 0xe1, 0xd4, 0x9a, 0xcb, 0x35, 0xe5, 0xbc,
0x5a, 0xc5, 0x0a, 0xf2, 0xf4, 0xb6, 0x9e, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0xc2, 0x0e, 0xc5,
0x55, 0x55, 0x55, 0x56, 0x73, 0xcf, 0x3c, 0xf3, 0xcf, 0x44, 0x4f, 0xff, 0xc9, 0xed, 0xa4, 0x9b,
0x63, 0x01, 0x60, 0x76, 0xb4, 0x22, 0x96, 0xdd, 0xff, 0xff, 0xe8, 0x9f, 0xd0, 0x8f, 0x37, 0x39,
0x09, 0x7a, 0x97, 0xa9, 0x7a, 0x97, 0xa9, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf,
0x7b, 0xf7, 0xaa, 0x37, 0xc8, 0x01, 0x25, 0x4b, 0xfb, 0xde, 0xf7, 0xbe, 0x3c, 0x63, 0x18, 0xc6,
0x2e, 0xb9, 0xdf, 0x3f, 0xff, 0x37, 0x0d, 0x7b, 0x55, 0x6f, 0xef, 0x54, 0x55, 0x48, 0x47, 0xaf,
0xff, 0x93, 0x79, 0x1c, 0xfd, 0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x94, 0x8b, 0xb5, 0xff, 0xf2,
0x9c, 0x0c, 0x63, 0x11, 0xa5, 0x98, 0x4e, 0xe1, 0x5d, 0xe0, 0xaf, 0xff, 0x93, 0x79, 0x1c, 0xfd,
0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x80, 0x2b, 0x7f, 0xfc, 0xa7, 0x03, 0x18, 0xc4, 0x69, 0x66,
0x13, 0xb8, 0x57, 0x78, 0x2b, 0xff, 0xe4, 0xde, 0x47, 0x3f, 0x71, 0xad, 0xa0, 0x84, 0x2f, 0xbc,
0xa9, 0x90, 0x7f, 0xff, 0xff, 0x61, 0x11, 0x60, 0xbe, 0x7a, 0x34, 0x05, 0xfa, 0x7c, 0x2c, 0x02,
0x93, 0x7f, 0x8f, 0x3b, 0x49, 0x78, 0xe9, 0x6f, 0xff, 0xf4, 0x70, 0xc4, 0xb3, 0x3c, 0x00, 0xcf,
0x71, 0x94, 0xc8, 0x94, 0xdc, 0x24, 0xd1, 0x48, 0xe9, 0xf2, 0x10, 0xa7, 0xa2, 0x2f, 0xff, 0xef,
0x2a, 0x8c, 0x71, 0x0f, 0x59, 0xc2, 0x0c, 0x95, 0x81, 0x8b, 0xe2, 0x2b, 0xe1, 0xee, 0x90, 0x10,
0xb3, 0x86, 0xff, 0xfe, 0xf2, 0xa8, 0xc7, 0x10, 0xf5, 0x9c, 0x20, 0xc9, 0x58, 0x18, 0xbe, 0x22,
0xbe, 0x1e, 0xe9, 0x01, 0x0b, 0x1e, 0xd5, 0x01, 0xc0, 0x08, 0x99, 0x6e, 0xc0, 0x1b, 0x0a, 0x14,
0xed, 0x5f, 0x98, 0xe1, 0xe9, 0x7f, 0xff, 0x9c, 0x7e, 0xd2, 0x28, 0xb1, 0xf8, 0xa4, 0xa1, 0x96,
0xc9, 0x93, 0x29, 0x3e, 0xe9, 0x94, 0xa0, 0x2a, 0xab, 0x01, 0x7f, 0xff, 0x9c, 0x7d, 0xda, 0x96,
0xf9, 0xd2, 0x69, 0xb0, 0xa7, 0x3b, 0x45, 0x93, 0xda, 0xa0, 0x79, 0x82, 0x63, 0xd1, 0xee, 0x72,
0x7f, 0xff, 0x70, 0x31, 0x67, 0xd0, 0x12, 0x65, 0x89, 0x85, 0x1f, 0x93, 0x7d, 0xd7, 0x33, 0x1e,
0xeb, 0x9a, 0x96, 0x50, 0x7e, 0x7f, 0xff, 0x70, 0x30, 0x02, 0x04, 0xb5, 0xf0, 0xc5, 0xf9, 0xe0,
0x80, 0x2d, 0x60, 0x15, 0xf9, 0x1c, 0x28, 0xfe, 0xa0, 0x24, 0x9b, 0x49, 0xff, 0xec, 0x64, 0xeb,
0x1e, 0x03, 0xe5, 0xf2, 0x2e, 0xab, 0xf9, 0x94, 0xff, 0xf6, 0x32, 0x75, 0x8f, 0x01, 0xf2, 0xf9,
0x17, 0x55, 0xfa, 0x3d, 0x5f, 0x6f, 0xff, 0x7a, 0xee, 0x0e, 0xbd, 0xf3, 0xa6, 0xca, 0xe6, 0x65,
0xee, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73, 0x32, 0xe6, 0x3a, 0x79, 0xc9,
0xff, 0xec, 0xa5, 0xd2, 0x94, 0x9a, 0xdc, 0x53, 0x86, 0xce, 0x4c, 0x7d, 0x4f, 0xff, 0x63, 0x27,
0x58, 0xf0, 0x1f, 0x2f, 0x91, 0x75, 0x5f, 0xa3, 0xdf, 0x42, 0xff, 0xf7, 0xca, 0x69, 0x4a, 0x4e,
0x36, 0x07, 0xe4, 0x5f, 0xa6, 0x64, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73,
0x32, 0xe6, 0x31, 0xc9, 0xa5, 0xc6, 0xbf, 0xff, 0xb9, 0x36, 0x97, 0x97, 0x1b, 0x06, 0x0c, 0x18,
0x32, 0x9b, 0x36, 0x6c, 0xd9, 0xb3, 0x66, 0x6f, 0x5f, 0xff, 0xa7, 0x4d, 0x7d, 0x81, 0x4a, 0x12,
0x07, 0xa6, 0x03, 0x36, 0x99, 0x29, 0xfb, 0x89, 0x00, 0x29, 0x36, 0xb1, 0xcb, 0x53, 0xbb, 0x3f,
0x16, 0x2b, 0x94, 0x29, 0x42, 0x94, 0x29, 0x42, 0x94, 0xce, 0x4c, 0xe4, 0xce, 0x4c, 0xe4, 0xce,
0x4c, 0xe4, 0xce, 0x43, 0x05, 0x97, 0xff, 0xff, 0x71, 0xfd, 0xb8, 0xb5, 0xb7, 0x91, 0x60, 0x43,
0xbc, 0x3b, 0xc3, 0xbc, 0x3b, 0xc3, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0,
0xbb, 0xdf, 0x60, 0x17, 0xff, 0xe9, 0xd3, 0x5f, 0x60, 0x52, 0x84, 0x81, 0xe9, 0x80, 0xcd, 0xa6,
0x4a, 0xbc, 0xaf, 0xff, 0xd3, 0xa6, 0xbe, 0xc0, 0xa5, 0x09, 0x03, 0xd3, 0x01, 0x9b, 0x4c, 0x95,
0x00, 0x59, 0xd7, 0xef, 0xff, 0xdb, 0x33, 0xb2, 0x06, 0x18, 0x54, 0x3c, 0x70, 0xf1, 0xf0, 0x79,
0x40, 0x76, 0xf7, 0xff, 0xed, 0x96, 0x31, 0x59, 0x07, 0xe9, 0xf6, 0x47, 0xc7, 0x39, 0xb1, 0xe3,
0x67, 0x37, 0x16, 0xff, 0xfd, 0x3a, 0xf8, 0xc4, 0xf3, 0xce, 0x3c, 0xec, 0x73, 0x29, 0xb9, 0x9a,
0x4c, 0xa5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e, 0x98, 0x0c, 0xda, 0x64, 0xa8,
0x07, 0x39, 0xcf, 0x8d, 0x7f, 0xfe, 0x9d, 0x7c, 0x62, 0x79, 0xe7, 0x9e, 0x7b, 0x07, 0x1c, 0x71,
0xc7, 0x1c, 0x5a, 0x57, 0xff, 0xc0, 0xe0, 0x1d, 0x85, 0xc5, 0x93, 0xe7, 0xb5, 0x6b, 0x38, 0x79,
0xff, 0xff, 0xf6, 0xf8, 0x6d, 0xad, 0xb2, 0x85, 0x51, 0x0c, 0xb0, 0xcb, 0x0c, 0xb0, 0xcb, 0x0d,
0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xcb, 0x4a, 0x10, 0x05, 0xd0, 0x23,
0x08, 0x42, 0x10, 0x85, 0xa7, 0x39, 0xce, 0x73, 0x74, 0xe4, 0x2f, 0x7f, 0xfb, 0x29, 0x99, 0x54,
0x5f, 0x24, 0x27, 0x86, 0x0b, 0x58, 0x02, 0xff, 0xf6, 0x53, 0x32, 0xa8, 0xbe, 0x48, 0x4f, 0x0c,
0x16, 0xa7, 0x55, 0xbf, 0x56, 0xff, 0xf9, 0x82, 0x5a, 0xa9, 0xbf, 0xfa, 0xf8, 0x8b, 0xe9, 0x45,
0x1b, 0xff, 0xe5, 0xca, 0x81, 0xda, 0xd2, 0x08, 0x7d, 0x9a, 0x79, 0xdc, 0xd1, 0x79, 0x7f, 0xfc,
0x17, 0x7a, 0x21, 0x94, 0xd2, 0x7c, 0x99, 0xf6, 0xac, 0x65, 0xff, 0xf0, 0x38, 0x07, 0x61, 0x71,
0x64, 0xf9, 0xed, 0x5a, 0xce, 0xb5, 0x4a, 0x6d, 0xff, 0xfb, 0x65, 0x8c, 0x56, 0x41, 0xfa, 0x7d,
0x91, 0xf1, 0xce, 0x6c, 0x78, 0xe5, 0x7b, 0xff, 0xf6, 0xcb, 0x18, 0xac, 0x83, 0xf4, 0xfb, 0x23,
0xe3, 0x9c, 0xd8, 0xf1, 0xb3, 0xa9, 0xf5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e,
0x98, 0x0c, 0xda, 0x64, 0xab, 0xca, 0xff, 0xfd, 0x3a, 0x6b, 0xec, 0x0a, 0x50, 0x90, 0x3d, 0x30,
0x19, 0xb4, 0xc9, 0x4f, 0xb3, 0x9a, 0xfc, 0x01, 0x07, 0x82, 0xbd, 0x99, 0xa4, 0x39, 0xc6, 0x16,
0x38, 0xe1, 0x3e, 0x7f, 0xfe, 0xc3, 0x11, 0x1f, 0x0c, 0x30, 0x50, 0x9a, 0x8a, 0x51, 0x85, 0x2b,
0x30, 0xc4, 0xbf, 0xff, 0x56, 0x20, 0x9e, 0xf3, 0xfe, 0xee, 0xf8, 0x08, 0x93, 0x45, 0x49, 0x85,
0xce, 0x83, 0xa7, 0xff, 0xe1, 0x8b, 0x31, 0xd8, 0x61, 0x7b, 0x18, 0xf8, 0x2b, 0x60, 0x15, 0xeb,
0xd2, 0xd3, 0xff, 0xf0, 0xc2, 0x92, 0x82, 0xe0, 0xf7, 0x90, 0x77, 0x52, 0xa4, 0xdb, 0xac, 0x6b,
0xc8, 0x4a, 0xf1, 0xff, 0xea, 0x4e, 0xd2, 0xf7, 0x03, 0xe5, 0x41, 0xd4, 0x47, 0x86, 0x33, 0xff,
0xd4, 0x9d, 0xa5, 0xee, 0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe5, 0xbd, 0x2a, 0xff, 0xf8, 0x71, 0xaa,
0xe2, 0x37, 0xa6, 0x65, 0x92, 0xa9, 0xb2, 0x35, 0xff, 0xf0, 0xe3, 0x55, 0xc4, 0x6f, 0x4c, 0xcb,
0x25, 0x53, 0x56, 0xb2, 0x09, 0xed, 0xff, 0xf1, 0x07, 0x9b, 0xb9, 0x9e, 0x74, 0xf2, 0x13, 0xd8,
0xa6, 0xd7, 0xff, 0xc3, 0x8d, 0x57, 0x11, 0xbd, 0x33, 0x2c, 0x95, 0x4d, 0x5a, 0x50, 0x74, 0xff,
0xf5, 0x41, 0x72, 0x20, 0x9d, 0xa7, 0x0f, 0xfc, 0x4b, 0xab, 0x33, 0xff, 0xd4, 0x9d, 0xa5, 0xee,
0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe6, 0x10, 0x50,
]
}
@@ -0,0 +1,96 @@
import CoreVideo
import XCTest
import simd
@testable import PunktfunkKit
/// Mirrors pf-client-core's `csc_rows` tests (crates/pf-client-core/src/video.rs) the Swift port
/// must stay in LOCKSTEP with the Rust implementation, so these are the same fixtures with the
/// same tolerances. A divergence here means the two sides would render the same stream
/// differently.
final class CscRowsTests: XCTestCase {
private func apply(_ u: CscUniform, _ yuv: SIMD3<Float>) -> SIMD3<Float> {
SIMD3(
simd_dot(SIMD3(u.r0.x, u.r0.y, u.r0.z), yuv) + u.r0.w,
simd_dot(SIMD3(u.r1.x, u.r1.y, u.r1.z), yuv) + u.r1.w,
simd_dot(SIMD3(u.r2.x, u.r2.y, u.r2.z), yuv) + u.r2.w)
}
/// 10-bit limited MSB-packed (P010/x444): reference white Y=940, black Y=64, neutral
/// chroma 512 sampled as UNORM16 of `code << 6`.
func testBt2020TenBitLimitedWhiteBlack() {
let rows = CscRows.rows(.init(matrix: 9, fullRange: false), depth: 10, msbPacked: true)
func s(_ code: UInt32) -> Float { Float(code << 6) / 65535.0 }
let white = apply(rows, SIMD3(s(940), s(512), s(512)))
let black = apply(rows, SIMD3(s(64), s(512), s(512)))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 0.002, "white \(white)")
XCTAssertEqual(black[i], 0.0, accuracy: 0.002, "black \(black)")
}
}
/// Reference white (Y=235, U=V=128 limited) RGB 1.0; reference black (Y=16) 0.0.
func testBt709LimitedWhiteBlack() {
let rows = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
let white = apply(rows, SIMD3(235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
let black = apply(rows, SIMD3(16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 0.005, "white \(white)")
XCTAssertEqual(black[i], 0.0, accuracy: 0.005, "black \(black)")
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the matrix-code
/// dispatch the two matrices MUST differ measurably, that difference is the whole bug
/// class this port fixes).
func testFullRangeAndRedExcursion() {
let rows601 = CscRows.rows(.init(matrix: 5, fullRange: true), depth: 8, msbPacked: false)
let white = apply(rows601, SIMD3(1.0, 0.5, 0.5))
for i in 0..<3 {
XCTAssertEqual(white[i], 1.0, accuracy: 1e-5, "\(white)")
}
let red601 = apply(rows601, SIMD3(0.0, 0.5, 1.0))
XCTAssertEqual(red601[0], 2.0 * (1.0 - 0.299) * 0.5, accuracy: 1e-4, "\(red601)")
let rows709 = CscRows.rows(.init(matrix: 1, fullRange: true), depth: 8, msbPacked: false)
let red709 = apply(rows709, SIMD3(0.0, 0.5, 1.0))
XCTAssertEqual(red709[0], 2.0 * (1.0 - 0.2126) * 0.5, accuracy: 1e-4, "\(red709)")
XCTAssertGreaterThan(abs(red601[0] - red709[0]), 0.05)
}
/// Unspecified (2) and unknown matrix codes fall back to BT.709 the same default as the
/// Rust side and every punktfunk host's implicit SDR baseline.
func testUnspecifiedFallsBackTo709() {
let unspec = CscRows.rows(.init(matrix: 2, fullRange: false), depth: 8, msbPacked: false)
let bt709 = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
XCTAssertEqual(unspec, bt709)
}
/// `signal(of:)` reads the matrix off the buffer's attachment (what VideoToolbox propagates
/// from the VUI) and the range off the pixel format a 601-tagged buffer must come back as
/// matrix 5, an untagged one as unspecified (2), and a full-range sibling as fullRange.
func testSignalReadsAttachmentAndRange() throws {
func makeBuffer(_ format: OSType) throws -> CVPixelBuffer {
var pb: CVPixelBuffer?
let status = CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, format, nil, &pb)
guard status == kCVReturnSuccess, let pb else {
throw XCTSkip("could not allocate a \(format) pixel buffer")
}
return pb
}
let tagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
CVBufferSetAttachment(
tagged, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4,
.shouldPropagate)
XCTAssertEqual(CscRows.signal(of: tagged), CscRows.Signal(matrix: 5, fullRange: false))
let untagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
XCTAssertEqual(CscRows.signal(of: untagged), CscRows.Signal(matrix: 2, fullRange: false))
let full = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
CVBufferSetAttachment(
full, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020,
.shouldPropagate)
XCTAssertEqual(CscRows.signal(of: full), CscRows.Signal(matrix: 9, fullRange: true))
}
}
+44 -1
View File
@@ -111,6 +111,11 @@ struct Args {
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made. /// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>, discover: Option<u64>,
/// `--clock-resync` — after the connect-time skew handshake, immediately run a SECOND
/// handshake on the same control stream and assert both estimates are sane and consistent:
/// the headless validator for the host answering `ClockProbe` at any time (what the native
/// clients' mid-stream re-sync relies on). Aborts the session when the re-probe fails.
clock_resync: bool,
} }
fn parse_mode(m: &str) -> Option<Mode> { fn parse_mode(m: &str) -> Option<Mode> {
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
.iter() .iter()
.any(|a| a == "--discover") .any(|a| a == "--discover")
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)), .then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
} }
} }
@@ -523,7 +529,8 @@ async fn session(args: Args) -> Result<()> {
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test // Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
// take it): align our clock to the host's so the per-frame capture→received latency is valid // take it): align our clock to the host's so the per-frame capture→received latency is valid
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0). // across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await { let first_skew = punktfunk_core::quic::clock_sync(&mut send, &mut recv).await;
let clock_offset_ns = match &first_skew {
Some(skew) => { Some(skew) => {
tracing::info!( tracing::info!(
offset_ns = skew.offset_ns, offset_ns = skew.offset_ns,
@@ -536,6 +543,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.
+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);
+96 -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(),
))
} }
} }
+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);
} }
+5 -2
View File
@@ -330,7 +330,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 +365,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 +503,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);
+26 -3
View File
@@ -279,7 +279,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 +354,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 +566,37 @@ fn spawn_audio(
.name("punktfunk-audio-rx".into()) .name("punktfunk-audio-rx".into())
.spawn(move || { .spawn(move || {
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
while !stop.load(Ordering::SeqCst) { while !stop.load(Ordering::SeqCst) {
match connector.next_audio(Duration::from_millis(100)) { match connector.next_audio(Duration::from_millis(100)) {
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) { Ok(pkt) => {
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one
// that arrived: empty input synthesizes `frame_samples` of interpolation per
// missing packet — an inaudible fade instead of the click a hard gap makes.
for _ in 0..gaps.missing_before(pkt.seq) {
let plc = frame_samples * channels as usize;
if plc == 0 {
break; // no decoded frame yet to size the concealment from
}
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..samples * channels as usize]);
player.push(buf);
}
}
match dec.decode_float(&pkt.data, &mut pcm, false) {
// `samples` is per-channel; the interleaved frame is `samples * channels`. // `samples` is per-channel; the interleaved frame is `samples * channels`.
Ok(samples) => { Ok(samples) => {
frame_samples = samples;
let n = samples * channels as usize; let n = samples * channels as usize;
let mut buf = player.take_buffer(); let mut buf = player.take_buffer();
buf.extend_from_slice(&pcm[..n]); buf.extend_from_slice(&pcm[..n]);
player.push(buf); player.push(buf);
} }
Err(e) => tracing::debug!(error = %e, "opus decode"), Err(e) => tracing::debug!(error = %e, "opus decode"),
}, }
}
Err(PunktfunkError::NoFrame) => {} Err(PunktfunkError::NoFrame) => {}
Err(_) => break, // plane closed — the session is ending Err(_) => break, // plane closed — the session is ending
} }
+244 -2
View File
@@ -119,11 +119,13 @@ pub struct ColorDesc {
} }
impl ColorDesc { impl ColorDesc {
/// Read the CICP fields off a raw decoded frame. /// Read the CICP fields off a raw decoded frame. Public: the Windows client's raw-FFI
/// D3D11VA/software decoders build their per-frame `ColorDesc` with it too (same
/// `ffmpeg-next` major, so the `AVFrame` type unifies across the workspace).
/// ///
/// # Safety /// # Safety
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call). /// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
pub(crate) unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc { pub unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads. // SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
unsafe { unsafe {
ColorDesc { ColorDesc {
@@ -141,6 +143,57 @@ impl ColorDesc {
} }
} }
/// The YCbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
/// client's D3D11 constant buffer), so a stream's signaled matrix/range is honored identically
/// everywhere; the Apple client ports this function (and its tests) to Swift.
///
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix {
5 | 6 => (0.299, 0.114),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range {
(pack, 0.0f64, pack)
} else {
(
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
};
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
];
core::array::from_fn(|r| {
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
})
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride). /// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame { pub struct CpuFrame {
pub width: u32, pub width: u32,
@@ -1387,6 +1440,117 @@ unsafe extern "C" fn pick_vulkan(
mod tests { mod tests {
use super::*; use super::*;
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc {
primaries: 1,
transfer: 1,
matrix,
full_range,
}
}
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
core::array::from_fn(|r| {
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
})
}
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form.
#[test]
fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
assert!(b.abs() < 0.005, "black {black:?}");
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the
/// matrix-code dispatch), same as the GL presenter's test.
#[test]
fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
"{red709:?}"
);
assert!((red[0] - red709[0]).abs() > 0.05);
}
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
/// grid of inputs — same math, different packing.
#[test]
fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full);
let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let (sy, oy, sc) = if full {
(1.0f32, 0.0f32, 1.0f32)
} else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
};
let mat = [
sy,
sy,
sy,
0.0,
-2.0 * (1.0 - kb) * kb / kg * sc,
2.0 * (1.0 - kb) * sc,
2.0 * (1.0 - kr) * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
0.0,
];
let off = [oy, -0.5, -0.5];
for yuv in [
[0.1f32, 0.3, 0.7],
[0.9, 0.5, 0.5],
[0.5, 0.2, 0.8],
[16.0 / 255.0, 0.5, 0.5],
] {
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
let gl: [f32; 3] =
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
let ours = apply(&rows, yuv);
for (a, b) in gl.iter().zip(ours) {
assert!(
(a - b).abs() < 1e-5,
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
);
}
}
}
}
/// Lock the DRM FourCC magic numbers against typos — these are the exact values /// Lock the DRM FourCC magic numbers against typos — these are the exact values
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green. /// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
#[test] #[test]
@@ -1434,4 +1598,82 @@ mod tests {
assert!(f.color.is_pq()); assert!(f.color.is_pq());
assert_eq!((f.width, f.height), (64, 64)); assert_eq!((f.width, f.height), (64, 64));
} }
/// Golden colour fixtures: one 256×64 LOSSLESS x265 IDR of 8 fully-saturated colour bars per
/// signaling variant (generated offline with ffmpeg/libx265; the RGB→YUV conversion matched
/// to the VUI each fixture declares, so the original RGB is recoverable ±1 code). Decoding
/// through the real CPU path (`SoftwareDecoder` → per-frame `ColorDesc` → swscale with the
/// signaled matrix/range) must reproduce the bars — the end-to-end guard for the
/// signaling-driven CSC across BT.601/709 × limited/full. A hardcoded-709 regression fails
/// the 601 fixture by tens of code points; a range mix-up fails the full-range one.
#[test]
fn software_decode_reproduces_golden_bars() {
const BARS: [(u8, u8, u8); 8] = [
(255, 255, 255),
(255, 255, 0),
(0, 255, 255),
(0, 255, 0),
(255, 0, 255),
(255, 0, 0),
(0, 0, 255),
(0, 0, 0),
];
let fixtures: [(&str, &[u8], ColorDesc); 3] = [
(
"601-limited",
include_bytes!("../tests/bars-601-limited.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 5, // BT.470BG — what a Linux host's RGB-input NVENC signals
full_range: false,
},
),
(
"709-limited",
include_bytes!("../tests/bars-709-limited.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 1,
full_range: false,
},
),
(
"709-full",
include_bytes!("../tests/bars-709-full.h265"),
ColorDesc {
primaries: 1,
transfer: 1,
matrix: 1,
full_range: true, // the PUNKTFUNK_444_FULLRANGE experiment's signaling
},
),
];
for (name, au, want_color) in fixtures {
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
let mut got = dec.decode(au).expect("decode");
if got.is_none() {
dec.decoder.send_eof().ok();
let mut frame = AvFrame::empty();
if dec.decoder.receive_frame(&mut frame).is_ok() {
got = Some(dec.convert_rgba(&frame).expect("convert"));
}
}
let f = got.unwrap_or_else(|| panic!("{name}: no frame decoded"));
assert_eq!(f.color, want_color, "{name}: signaling");
assert_eq!((f.width, f.height), (256, 64), "{name}: dims");
for (i, (r, g, b)) in BARS.iter().enumerate() {
let (cx, cy) = (i * 32 + 16, 32usize);
let o = cy * f.stride + cx * 4;
let px = &f.rgba[o..o + 3];
for (got, want) in px.iter().zip([r, g, b]) {
assert!(
got.abs_diff(*want) <= 3,
"{name} bar {i}: got {px:?}, want ({r},{g},{b})"
);
}
}
}
}
} }
+13 -4
View File
@@ -52,10 +52,12 @@ use windows::Win32::Graphics::Direct3D11::{
D3D11_VPOV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D,
}; };
use windows::Win32::Graphics::Dxgi::Common::{ use windows::Win32::Graphics::Dxgi::Common::{
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, DXGI_SAMPLE_DESC, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
DXGI_SAMPLE_DESC,
}; };
use windows::Win32::Graphics::Dxgi::{ use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1, CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
@@ -629,9 +631,16 @@ impl D3d11vaDecoder {
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ // Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums. // stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
// BT.601 (5/6) matters in practice: a Linux host's RGB-input NVENC paths signal
// BT470BG limited (NVENC's fixed internal RGB→YUV is BT.601 — ffmpeg force-writes
// that VUI), and mapping it to P709 here was a constant hue error on those streams.
// DXGI has no full-range G2084 YCbCr enum, so PQ is studio regardless of range.
let in_cs = match (color.transfer, color.matrix, color.full_range) { let in_cs = match (color.transfer, color.matrix, color.full_range) {
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, (16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
(_, 9, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020, (_, 9 | 10, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
(_, 9 | 10, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
(_, 5 | 6, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
(_, 5 | 6, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709, (_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, _ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
}; };
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -62,7 +62,17 @@ vec3 srgb_oetf(vec3 c) {
} }
void main() { void main() {
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg); // 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
vec2 cuv = v_uv;
int cw = textureSize(u_c, 0).x;
if (cw < textureSize(u_y, 0).x) {
cuv.x += 0.25 / float(cw);
}
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
vec3 rgb = vec3( vec3 rgb = vec3(
dot(pc.r0.xyz, yuv) + pc.r0.w, dot(pc.r0.xyz, yuv) + pc.r0.w,
dot(pc.r1.xyz, yuv) + pc.r1.w, dot(pc.r1.xyz, yuv) + pc.r1.w,
Binary file not shown.
+4 -164
View File
@@ -9,55 +9,11 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use ash::vk; use ash::vk;
use pf_client_core::video::ColorDesc;
/// The push-constant block's matrix half: three vec4 rows, // The coefficient math lives in pf-client-core next to `ColorDesc` (one tested
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. // implementation shared with the Windows client's D3D11 constant buffer and mirrored by the
/// // Apple client's Swift port); re-exported here so presenter callers keep their import path.
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit: pub use pf_client_core::video::csc_rows;
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix {
5 | 6 => (0.299, 0.114),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range {
(pack, 0.0f64, pack)
} else {
(
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
};
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
[
sy,
-2.0 * (1.0 - kb) * kb / kg * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
],
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
];
core::array::from_fn(|r| {
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
})
}
/// The pass objects (everything except the per-video-size framebuffer, which lives with /// The pass objects (everything except the per-video-size framebuffer, which lives with
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's /// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
@@ -336,119 +292,3 @@ pub(crate) fn build_fullscreen_pipeline(
} }
Ok(pipeline?[0]) Ok(pipeline?[0])
} }
#[cfg(test)]
mod tests {
use super::*;
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc {
primaries: 1,
transfer: 1,
matrix,
full_range,
}
}
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
core::array::from_fn(|r| {
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
})
}
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form.
#[test]
fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
assert!(b.abs() < 0.005, "black {black:?}");
}
}
/// Full-range identity points + the 601-vs-709 red excursion (guards the
/// matrix-code dispatch), same as the GL presenter's test.
#[test]
fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
"{red709:?}"
);
assert!((red[0] - red709[0]).abs() > 0.05);
}
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
/// grid of inputs — same math, different packing.
#[test]
fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full);
let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32),
9 | 10 => (0.2627, 0.0593),
_ => (0.2126, 0.0722),
};
let kg = 1.0 - kr - kb;
let (sy, oy, sc) = if full {
(1.0f32, 0.0f32, 1.0f32)
} else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
};
let mat = [
sy,
sy,
sy,
0.0,
-2.0 * (1.0 - kb) * kb / kg * sc,
2.0 * (1.0 - kb) * sc,
2.0 * (1.0 - kr) * sc,
-2.0 * (1.0 - kr) * kr / kg * sc,
0.0,
];
let off = [oy, -0.5, -0.5];
for yuv in [
[0.1f32, 0.3, 0.7],
[0.9, 0.5, 0.5],
[0.5, 0.2, 0.8],
[16.0 / 255.0, 0.5, 0.5],
] {
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
let gl: [f32; 3] =
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
let ours = apply(&rows, yuv);
for (a, b) in gl.iter().zip(ours) {
assert!(
(a - b).abs() < 1e-5,
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
);
}
}
}
}
}
+10 -4
View File
@@ -154,7 +154,9 @@ struct StreamState {
canceled: bool, canceled: bool,
ready_announced: bool, ready_announced: bool,
mode_line: String, mode_line: String,
clock_offset_ns: i64, /// Live host↔client clock offset handle (None until Connected): loaded per present so
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
hdr: bool, hdr: bool,
// Presenter-side 1 s window (design/stats-unification.md): end-to-end // Presenter-side 1 s window (design/stats-unification.md): end-to-end
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
@@ -205,7 +207,7 @@ impl StreamState {
canceled: false, canceled: false,
ready_announced: false, ready_announced: false,
mode_line: String::new(), mode_line: String::new(),
clock_offset_ns: 0, clock_offset: None,
hdr: false, hdr: false,
win_e2e_us: Vec::with_capacity(256), win_e2e_us: Vec::with_capacity(256),
win_disp_us: Vec::with_capacity(256), win_disp_us: Vec::with_capacity(256),
@@ -657,7 +659,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
.set_title(&format!("{} · {}", opts.window_title, st.mode_line)) .set_title(&format!("{} · {}", opts.window_title, st.mode_line))
.ok(); .ok();
gamepad.attach(c.clone()); gamepad.attach(c.clone());
st.clock_offset_ns = c.clock_offset_ns; st.clock_offset = Some(c.clock_offset_shared());
let mut cap = Capture::new(c.clone()); let mut cap = Capture::new(c.clone());
cap.engage(); // capture engages when the stream starts (ui_stream parity) cap.engage(); // capture engages when the stream starts (ui_stream parity)
apply_capture(&mut window, &mouse, true); apply_capture(&mut window, &mouse, true);
@@ -960,7 +962,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
println!("{{\"ready\":true}}"); println!("{{\"ready\":true}}");
} }
// The `displayed` stamp (same clamp rules as the pump's windows). // The `displayed` stamp (same clamp rules as the pump's windows).
let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128) let clock_offset_ns = st
.clock_offset
.as_ref()
.map_or(0, |o| o.load(Ordering::Relaxed));
let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - pts_ns as i128)
.max(0) as u64; .max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 { if e2e > 0 && e2e < 10_000_000_000 {
st.win_e2e_us.push(e2e / 1000); st.win_e2e_us.push(e2e / 1000);
+8 -1
View File
@@ -77,7 +77,14 @@ libc = "0.2"
# windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the # windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the
# `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR. # `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR.
# Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature. # Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature.
windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_System_IO"] } # Win32_NetworkManagement_QoS + Win32_Foundation: the qWAVE flow API for real on-the-wire DSCP
# marking (transport/qos_windows.rs) — plain IP_TOS is stripped by the Windows stack.
windows-sys = { version = "0.59", features = [
"Win32_Networking_WinSock",
"Win32_System_IO",
"Win32_Foundation",
"Win32_NetworkManagement_QoS",
] }
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
@@ -71,6 +71,15 @@ fn bench_crypto(c: &mut Criterion) {
g.bench_function("open", |b| { g.bench_function("open", |b| {
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap())) b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
}); });
g.bench_function("open_in_place", |b| {
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
let mut buf = sealed.clone();
b.iter(|| {
buf.copy_from_slice(black_box(&sealed));
black_box(client.open_in_place(0, &mut buf).unwrap());
})
});
g.finish(); g.finish();
} }
+71
View File
@@ -125,6 +125,54 @@ pub fn normalize_channels(requested: u8) -> u8 {
} }
} }
/// Loss detector for the client audio plane, shared by every platform decoder.
///
/// The `0xC9` audio datagrams carry a per-packet sequence the host advances by 1 (wrapping), but
/// ride the lossy datagram plane with no FEC — a lost 5 ms Opus packet used to play out as a hard
/// gap (a click/pop; the jitter rings just emit silence). Feeding this tracker each received
/// packet's sequence tells the decoder how many packets went missing *immediately before it*, so
/// it can synthesize that many frames of libopus packet-loss concealment (`decode` with empty
/// input) before decoding the real one — turning clicks into an inaudible interpolation.
///
/// Reorders and duplicates conceal nothing (the plane has no reorder buffer; playing a late
/// packet where it lands is the existing behaviour), and a gap is capped at
/// [`MAX_CONCEAL_PACKETS`] (50 ms at the protocol's 5 ms frames) — libopus PLC fades to silence
/// after a few frames anyway, so past the cap the ring's underrun/re-prime path takes over as
/// before.
#[derive(Debug, Default)]
pub struct AudioGapTracker {
/// Sequence of the newest packet seen (`None` until the first).
last_seq: Option<u32>,
}
/// Most packets a single gap will ask concealment for (50 ms at the protocol's 5 ms frames).
/// Crate-internal: callers only ever see `missing_before`'s already-capped count (and cbindgen
/// must not export it — it's not part of the C ABI).
const MAX_CONCEAL_PACKETS: u32 = 10;
impl AudioGapTracker {
pub fn new() -> Self {
Self::default()
}
/// Feed the next received packet's sequence; returns how many packets are missing immediately
/// before it (`0` for in-order, the first packet, duplicates, and reorders), capped at
/// [`MAX_CONCEAL_PACKETS`]. Wrapping-safe: a sequence in the backward half of the u32 space is
/// a reorder, not a 2³¹-packet gap.
pub fn missing_before(&mut self, seq: u32) -> u32 {
let Some(last) = self.last_seq else {
self.last_seq = Some(seq);
return 0;
};
let delta = seq.wrapping_sub(last);
if delta == 0 || delta > u32::MAX / 2 {
return 0; // duplicate, or a reorder older than the newest — nothing to conceal
}
self.last_seq = Some(seq);
(delta - 1).min(MAX_CONCEAL_PACKETS)
}
}
// ---- per-platform channel-layout helpers (pure data; no platform deps) -------------------- // ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout. /// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
@@ -215,6 +263,29 @@ mod tests {
} }
} }
#[test]
fn gap_tracker_counts_only_forward_gaps() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(100), 0, "first packet");
assert_eq!(t.missing_before(101), 0, "in order");
assert_eq!(t.missing_before(104), 2, "102+103 lost");
assert_eq!(t.missing_before(104), 0, "duplicate");
assert_eq!(t.missing_before(103), 0, "late reorder conceals nothing");
assert_eq!(t.missing_before(105), 0, "reorder didn't move the anchor");
// A huge gap is capped; the stream continues from the new anchor.
assert_eq!(t.missing_before(105 + 1000), MAX_CONCEAL_PACKETS);
assert_eq!(t.missing_before(105 + 1001), 0);
}
#[test]
fn gap_tracker_survives_seq_wraparound() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(u32::MAX - 1), 0);
assert_eq!(t.missing_before(u32::MAX), 0, "in order at the edge");
assert_eq!(t.missing_before(1), 1, "seq 0 lost across the wrap");
assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap");
}
#[test] #[test]
fn wasapi_masks_are_correct() { fn wasapi_masks_are_correct() {
assert_eq!(wasapi_channel_mask(2), 0x3); assert_eq!(wasapi_channel_mask(2), 0x3);
+299 -111
View File
@@ -17,14 +17,14 @@ use crate::error::{PunktfunkError, Result};
use crate::input::InputEvent; use crate::input::InputEvent;
use crate::packet::FLAG_PROBE; use crate::packet::FLAG_PROBE;
use crate::quic::{ use crate::quic::{
endpoint, io, window_loss_ppm, BitrateChanged, ColorInfo, HdrMeta, Hello, HidOutput, accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RichInput, ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
SetBitrate, Start, Welcome, Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RichInput, SetBitrate, Start, Welcome,
}; };
use crate::session::{Frame, Session}; use crate::session::{Frame, Session};
use crate::transport::UdpTransport; use crate::transport::UdpTransport;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -53,28 +53,42 @@ enum CtrlRequest {
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's /// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
/// [`BitrateController`] when the user's bitrate setting is Automatic. /// [`BitrateController`] when the user's bitrate setting is Automatic.
SetBitrate(u32), SetBitrate(u32),
/// Start a mid-stream clock re-sync batch now (see [`ClockResync`]). Sent by the pump on
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
ClockResync,
} }
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated /// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's /// [`Welcome`]-resolved session parameters (mode, backends, encode/colour/audio geometry) plus the
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset /// host certificate fingerprint and the connect-time clock offset. Mirrored one-to-one onto the
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake). /// public `NativeClient` fields of the same names.
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc` #[derive(Clone, Copy)]
/// (1 = 4:2:0, 3 = 4:4:4), the resolved audio channel count (2/6/8), and the resolved video codec struct Negotiated {
/// (`quic::CODEC_*`), with [`ColorInfo`] the resolved colour signalling — all from the [`Welcome`]. mode: Mode,
type Negotiated = ( compositor: CompositorPref,
Mode, gamepad: GamepadPref,
CompositorPref, /// SHA-256 of the certificate the host actually presented (TOFU callers persist this).
GamepadPref, host_fingerprint: [u8; 32],
[u8; 32], /// The encoder bitrate the host actually configured (kbps); `0` = an older host.
u32, bitrate_kbps: u32,
i64, /// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
u8, clock_offset_ns: i64,
ColorInfo, /// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
u8, /// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
u8, /// compares each batch against this baseline ([`accept_resync`]).
u8, clock_rtt_ns: Option<u64>,
); /// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
bit_depth: u8,
/// Resolved CICP colour signalling.
color: ColorInfo,
/// Resolved chroma subsampling as the HEVC `chroma_format_idc` (1 = 4:2:0, 3 = 4:4:4).
chroma_format: u8,
/// Resolved audio channel count (2/6/8) — what the Opus decoders must be built from.
audio_channels: u8,
/// The single codec the host will emit (`quic::CODEC_*`).
codec: u8,
}
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the /// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
/// session's packet-level receive counters here; the control task finalizes the delivered figure /// session's packet-level receive counters here; the control task finalizes the delivered figure
@@ -85,7 +99,8 @@ type Negotiated = (
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived. /// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
#[derive(Default)] #[derive(Default)]
struct ProbeState { struct ProbeState {
/// A probe is in progress (set by `request_probe`, cleared by nothing — the latest one wins). /// A probe is in progress: set by `request_probe`, cleared when the host's [`ProbeResult`]
/// lands (a re-probe just overwrites the whole state — the latest one wins).
active: bool, active: bool,
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first /// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
/// tick while active) and latest, mirrored every pump iteration. /// tick while active) and latest, mirrored every pump iteration.
@@ -177,6 +192,41 @@ const FLUSH_AFTER_FRAMES: u32 = 30;
/// warning instead of a continuous flush/keyframe storm. /// warning instead of a continuous flush/keyframe storm.
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2); const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
/// shifted the skew-corrected latency by a constant — every future frame reads over-bound and the
/// detector would fire forever, one flush + recovery IDR per cooldown, dragging the bitrate
/// controller to its floor; or the delay is standing in an **upstream queue** (router bufferbloat),
/// which a local flush can't drain — the OWD signal already feeds the bitrate controller, the
/// actual remedy. Even at the 5 Mbps bitrate floor a genuine 400 ms backlog is ~170 datagrams, so
/// 64 cleanly separates "empty" from "real". See `NOOP_CLOCK_FLUSHES_TO_DISARM`.
const NOOP_FLUSH_DATAGRAMS: u64 = 64;
/// Consecutive no-op clock-triggered flushes (see [`NOOP_FLUSH_DATAGRAMS`]) before the clock-based
/// detector is disarmed. The clock-free standing-queue detector stays armed — it measures the
/// local queue directly and can't be fooled by a clock step. No longer for the rest of the
/// session: an applied mid-stream clock re-sync re-arms the detector (the disarm stays as the
/// final backstop between re-syncs).
const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
/// Cadence of the control task's periodic mid-stream clock re-sync (see [`ClockResync`]): often
/// enough to bound slow drift and pick up an NTP step within a minute, rare enough to be free
/// (8 tiny control messages per batch). The pump additionally fires one immediately after the
/// FIRST no-op clock flush — the moment a step is actually suspected.
const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
/// Outbound mic uplink queue depth: 5 ms Opus frames, so 64 is ~320 ms of audio — far beyond
/// any worker stall a live mic session survives anyway. On overflow the FRESH frame is dropped
/// (a tokio mpsc can't shed from the head; by the time 320 ms are queued the stream is broken
/// either way, and the bound is about memory, not audio quality) and logged at debug.
const MIC_QUEUE: usize = 64;
/// Outbound control-request queue depth. The requests are sparse (mode switches, keyframe
/// requests, ~1.3 loss reports/s, clock re-syncs) — 32 is hours of headroom; a full queue means
/// the control task is wedged, which callers treat as a closed session.
const CTRL_QUEUE: usize = 32;
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes /// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the /// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next /// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
@@ -315,11 +365,15 @@ pub struct NativeClient {
host_timing: Mutex<Receiver<crate::quic::HostTiming>>, host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>, input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>, /// Bounded ([`MIC_QUEUE`]): a wedged worker drops fresh frames (logged) instead of queueing
/// audio-latency (and memory) without limit — mic is best-effort end to end.
mic_tx: tokio::sync::mpsc::Sender<(u32, u64, Vec<u8>)>,
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker. /// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>, rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
/// Outbound control-stream requests (mode switch, speed test) → the worker's control task. /// Outbound control-stream requests (mode switch, speed test) → the worker's control task.
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, /// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
/// is wedged/dead, and callers treat it like a closed session.
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
/// Speed-test accumulator, shared with the data-plane pump + control task. /// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
@@ -343,6 +397,11 @@ pub struct NativeClient {
/// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without /// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without
/// `gettid` (see [`current_hot_tid`]). /// `gettid` (see [`current_hot_tid`]).
hot_tids: Arc<Mutex<Vec<i32>>>, hot_tids: Arc<Mutex<Vec<i32>>>,
/// The LIVE host↔client clock offset (ns): seeded with the connect-time estimate, then kept
/// fresh by the control task's mid-stream re-syncs (every [`CLOCK_RESYNC_INTERVAL`], plus on
/// the pump's first no-op clock flush). Shared with the pump and, via
/// [`clock_offset_shared`](Self::clock_offset_shared), with embedder latency-math threads.
clock_offset: Arc<AtomicI64>,
worker: Option<std::thread::JoinHandle<()>>, worker: Option<std::thread::JoinHandle<()>>,
/// The currently active session mode (the Welcome's, then updated by every accepted /// The currently active session mode (the Welcome's, then updated by every accepted
/// [`NativeClient::request_mode`]). /// [`NativeClient::request_mode`]).
@@ -364,7 +423,9 @@ pub struct NativeClient {
/// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local /// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local
/// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making /// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't /// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
/// answer, or genuinely synced clocks). /// answer, or genuinely synced clocks). This is the CONNECT-TIME estimate, kept for ABI/compat;
/// ongoing latency math should read [`clock_offset_now_ns`](Self::clock_offset_now_ns), which
/// follows mid-stream re-syncs after a wall-clock step or drift.
pub clock_offset_ns: i64, pub clock_offset_ns: i64,
/// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or /// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or
/// `10` for a Main10 / HDR session. `8` for an older host that didn't report it. /// `10` for a Main10 / HDR session. `8` for an older host that didn't report it.
@@ -504,9 +565,9 @@ impl NativeClient {
let (host_timing_tx, host_timing_rx) = let (host_timing_tx, host_timing_rx) =
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE); std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>(); let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>(); let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>(); let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>(); let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false)); let quit = Arc::new(AtomicBool::new(false));
@@ -515,6 +576,7 @@ impl NativeClient {
let frames_dropped = Arc::new(AtomicU64::new(0)); let frames_dropped = Arc::new(AtomicU64::new(0));
let fec_recovered = Arc::new(AtomicU64::new(0)); let fec_recovered = Arc::new(AtomicU64::new(0));
let hot_tids = Arc::new(Mutex::new(Vec::new())); let hot_tids = Arc::new(Mutex::new(Vec::new()));
let clock_offset = Arc::new(AtomicI64::new(0));
let host = host.to_string(); let host = host.to_string();
let frame_chan_w = frame_chan.clone(); let frame_chan_w = frame_chan.clone();
@@ -525,6 +587,7 @@ impl NativeClient {
let frames_dropped_w = frames_dropped.clone(); let frames_dropped_w = frames_dropped.clone();
let fec_recovered_w = fec_recovered.clone(); let fec_recovered_w = fec_recovered.clone();
let hot_tids_w = hot_tids.clone(); let hot_tids_w = hot_tids.clone();
let clock_offset_w = clock_offset.clone();
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
let worker = std::thread::Builder::new() let worker = std::thread::Builder::new()
.name("punktfunk-client".into()) .name("punktfunk-client".into())
@@ -577,23 +640,12 @@ impl NativeClient {
frames_dropped: frames_dropped_w, frames_dropped: frames_dropped_w,
fec_recovered: fec_recovered_w, fec_recovered: fec_recovered_w,
hot_tids: hot_tids_w, hot_tids: hot_tids_w,
clock_offset: clock_offset_w,
})); }));
}) })
.map_err(PunktfunkError::Io)?; .map_err(PunktfunkError::Io)?;
let ( let negotiated = match ready_rx.recv_timeout(timeout) {
negotiated,
resolved_compositor,
resolved_gamepad,
fingerprint,
resolved_bitrate_kbps,
clock_offset_ns,
bit_depth,
color,
chroma_format,
audio_channels,
codec,
) = match ready_rx.recv_timeout(timeout) {
Ok(Ok(t)) => t, Ok(Ok(t)) => t,
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
@@ -601,7 +653,7 @@ impl NativeClient {
return Err(PunktfunkError::Timeout); return Err(PunktfunkError::Timeout);
} }
}; };
*mode_slot.lock().unwrap() = negotiated; *mode_slot.lock().unwrap() = negotiated.mode;
Ok(NativeClient { Ok(NativeClient {
frames: frame_chan, frames: frame_chan,
audio: Mutex::new(audio_rx), audio: Mutex::new(audio_rx),
@@ -620,17 +672,18 @@ impl NativeClient {
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
hot_tids, hot_tids,
clock_offset,
mode: mode_slot, mode: mode_slot,
host_fingerprint: fingerprint, host_fingerprint: negotiated.host_fingerprint,
resolved_compositor, resolved_compositor: negotiated.compositor,
resolved_gamepad, resolved_gamepad: negotiated.gamepad,
resolved_bitrate_kbps, resolved_bitrate_kbps: negotiated.bitrate_kbps,
clock_offset_ns, clock_offset_ns: negotiated.clock_offset_ns,
bit_depth, bit_depth: negotiated.bit_depth,
color, color: negotiated.color,
chroma_format, chroma_format: negotiated.chroma_format,
audio_channels, audio_channels: negotiated.audio_channels,
codec, codec: negotiated.codec,
}) })
} }
@@ -788,7 +841,7 @@ impl NativeClient {
/// reflects it. A rejected request leaves the session unchanged. /// reflects it. A rejected request leaves the session unchanged.
pub fn request_mode(&self, mode: Mode) -> Result<()> { pub fn request_mode(&self, mode: Mode) -> Result<()> {
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Mode(mode)) .try_send(CtrlRequest::Mode(mode))
.map_err(|_| PunktfunkError::Closed) .map_err(|_| PunktfunkError::Closed)
} }
@@ -798,7 +851,7 @@ impl NativeClient {
/// lands, so requesting on every frame would flood the control stream). /// lands, so requesting on every frame would flood the control stream).
pub fn request_keyframe(&self) -> Result<()> { pub fn request_keyframe(&self) -> Result<()> {
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Keyframe) .try_send(CtrlRequest::Keyframe)
.map_err(|_| PunktfunkError::Closed) .map_err(|_| PunktfunkError::Closed)
} }
@@ -849,6 +902,23 @@ impl NativeClient {
self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default() self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default()
} }
/// The LIVE host↔client clock offset (ns): the connect-time skew estimate, kept fresh by
/// mid-stream re-syncs (every 60 s, plus immediately when a wall-clock step is suspected).
/// Prefer this over the connect-time [`clock_offset_ns`](Self::clock_offset_ns) field for any
/// ongoing latency math — after an NTP step or slow drift the connect-time value silently
/// corrupts every capture-clock comparison. `0` = no skew handshake (old host / synced clocks).
pub fn clock_offset_now_ns(&self) -> i64 {
self.clock_offset.load(Ordering::Relaxed)
}
/// Shared handle to the live clock offset for plane threads that outlive a `&self` borrow
/// (render/display trackers). Read with [`AtomicI64::load`]`(Ordering::Relaxed)` at each use —
/// never cache the value across frames. Holding this does NOT keep the session alive (unlike
/// an `Arc<NativeClient>`, whose drop disconnects).
pub fn clock_offset_shared(&self) -> Arc<AtomicI64> {
self.clock_offset.clone()
}
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at /// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the /// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its /// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
@@ -861,7 +931,7 @@ impl NativeClient {
..Default::default() ..Default::default()
}; };
self.ctrl_tx self.ctrl_tx
.send(CtrlRequest::Probe(ProbeRequest { .try_send(CtrlRequest::Probe(ProbeRequest {
target_kbps, target_kbps,
duration_ms, duration_ms,
})) }))
@@ -1007,9 +1077,17 @@ impl NativeClient {
/// uses them only for diagnostics). The host decodes it into a virtual microphone source. /// uses them only for diagnostics). The host decodes it into a virtual microphone source.
/// Best-effort — like every datagram, it's dropped under loss; no retransmit. /// Best-effort — like every datagram, it's dropped under loss; no retransmit.
pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> { pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> {
self.mic_tx use tokio::sync::mpsc::error::TrySendError;
.send((seq, pts_ns, opus)) match self.mic_tx.try_send((seq, pts_ns, opus)) {
.map_err(|_| PunktfunkError::Closed) Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => {
// Bounded queue full = the worker stalled for ~MIC_QUEUE x 5 ms. Shed this
// frame (mic is best-effort end to end) instead of queueing latency/memory.
tracing::debug!("mic uplink queue full — dropping frame");
Ok(())
}
Err(TrySendError::Closed(_)) => Err(PunktfunkError::Closed),
}
} }
/// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a /// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a
@@ -1061,10 +1139,10 @@ struct WorkerArgs {
hdr_meta_tx: SyncSender<HdrMeta>, hdr_meta_tx: SyncSender<HdrMeta>,
host_timing_tx: SyncSender<crate::quic::HostTiming>, host_timing_tx: SyncSender<crate::quic::HostTiming>,
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>, input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>, mic_rx: tokio::sync::mpsc::Receiver<(u32, u64, Vec<u8>)>,
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>, rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
ctrl_rx: tokio::sync::mpsc::UnboundedReceiver<CtrlRequest>, ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>, ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
@@ -1074,6 +1152,9 @@ struct WorkerArgs {
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
fec_recovered: Arc<AtomicU64>, fec_recovered: Arc<AtomicU64>,
hot_tids: Arc<Mutex<Vec<i32>>>, hot_tids: Arc<Mutex<Vec<i32>>>,
/// The live clock offset (see [`NativeClient::clock_offset`]): the worker seeds it with the
/// connect-time estimate; the control task's mid-stream re-syncs update it.
clock_offset: Arc<AtomicI64>,
} }
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking /// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
@@ -1112,6 +1193,7 @@ async fn worker_main(args: WorkerArgs) {
frames_dropped, frames_dropped,
fec_recovered, fec_recovered,
hot_tids, hot_tids,
clock_offset,
} = args; } = args;
let setup = async { let setup = async {
let remote: std::net::SocketAddr = join_host_port(&host, port) let remote: std::net::SocketAddr = join_host_port(&host, port)
@@ -1203,7 +1285,8 @@ async fn worker_main(args: WorkerArgs) {
// it): align our clock to the host's so the embedder can express receive/present instants in // it): align our clock to the host's so the embedder can express receive/present instants in
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock // the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
// assumption, as before). This is the substrate for glass-to-glass present-time measurement. // assumption, as before). This is the substrate for glass-to-glass present-time measurement.
let clock_offset_ns = match crate::quic::clock_sync(&mut send, &mut recv).await { let (clock_offset_ns, clock_rtt_ns) =
match crate::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => { Some(skew) => {
tracing::info!( tracing::info!(
offset_ns = skew.offset_ns, offset_ns = skew.offset_ns,
@@ -1211,9 +1294,9 @@ async fn worker_main(args: WorkerArgs) {
rounds = skew.rounds, rounds = skew.rounds,
"clock skew estimated (host-client)" "clock skew estimated (host-client)"
); );
skew.offset_ns (skew.offset_ns, Some(skew.rtt_ns))
} }
None => 0, None => (0, None),
}; };
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port); let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
@@ -1231,58 +1314,42 @@ async fn worker_main(args: WorkerArgs) {
session, session,
send, send,
recv, recv,
welcome.mode, Negotiated {
welcome.compositor, mode: welcome.mode,
welcome.gamepad, compositor: welcome.compositor,
fingerprint, gamepad: welcome.gamepad,
welcome.bitrate_kbps, host_fingerprint: fingerprint,
bitrate_kbps: welcome.bitrate_kbps,
clock_offset_ns, clock_offset_ns,
welcome.bit_depth, clock_rtt_ns,
welcome.color, bit_depth: welcome.bit_depth,
welcome.chroma_format, color: welcome.color,
welcome.audio_channels, chroma_format: welcome.chroma_format,
welcome.codec, audio_channels: welcome.audio_channels,
codec: welcome.codec,
},
welcome.host_caps, welcome.host_caps,
)) ))
}; };
let ( let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
conn, {
mut session,
mut ctrl_send,
mut ctrl_recv,
negotiated,
resolved_compositor,
resolved_gamepad,
fingerprint,
resolved_bitrate_kbps,
clock_offset_ns,
bit_depth,
color,
chroma_format,
audio_channels,
codec,
host_caps,
) = match setup.await {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
let _ = ready_tx.send(Err(e)); let _ = ready_tx.send(Err(e));
return; return;
} }
}; };
let _ = ready_tx.send(Ok(( // Copies the pump needs after `negotiated` is handed over to `connect`.
negotiated, let clock_rtt_ns = negotiated.clock_rtt_ns;
resolved_compositor, let resolved_bitrate_kbps = negotiated.bitrate_kbps;
resolved_gamepad, // Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
fingerprint, // client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
resolved_bitrate_kbps, clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
clock_offset_ns, // Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
bit_depth, // reset its staleness counters and re-arm the clock-based jump-to-live detector.
color, let clock_gen = Arc::new(AtomicU32::new(0));
chroma_format, let _ = ready_tx.send(Ok(negotiated));
audio_channels,
codec,
)));
// Input task: embedder events → QUIC datagrams. Toward a host that advertised // Input task: embedder events → QUIC datagrams. Toward a host that advertised
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are // HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
@@ -1365,7 +1432,20 @@ async fn worker_main(args: WorkerArgs) {
let mode_slot = mode_slot.clone(); let mode_slot = mode_slot.clone();
let probe = probe.clone(); let probe = probe.clone();
let bitrate_ack = bitrate_ack.clone(); let bitrate_ack = bitrate_ack.clone();
let clock_offset = clock_offset.clone();
let clock_gen = clock_gen.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
// its first no-op clock flush). Echoes interleave with the other control replies in
// the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes.
let mut resync = ClockResync::new();
let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL,
);
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
tokio::select! { tokio::select! {
req = ctrl_rx.recv() => { req = ctrl_rx.recv() => {
@@ -1376,11 +1456,23 @@ async fn worker_main(args: WorkerArgs) {
CtrlRequest::Keyframe => RequestKeyframe.encode(), CtrlRequest::Keyframe => RequestKeyframe.encode(),
CtrlRequest::Loss(r) => r.encode(), CtrlRequest::Loss(r) => r.encode(),
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(), CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
CtrlRequest::ClockResync => {
if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer
}
resync.begin(wall_clock_ns()).encode()
}
}; };
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() { if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
break; break;
} }
} }
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = io::read_msg(&mut ctrl_recv) => { msg = io::read_msg(&mut ctrl_recv) => {
let Ok(msg) = msg else { break }; // stream closed let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) { if let Ok(ack) = Reconfigured::decode(&msg) {
@@ -1404,6 +1496,7 @@ async fn worker_main(args: WorkerArgs) {
p.host_send_dropped = result.send_dropped; p.host_send_dropped = result.send_dropped;
p.host_duration_ms = result.duration_ms; p.host_duration_ms = result.duration_ms;
p.done = true; p.done = true;
p.active = false; // burst over — the pump stops mirroring counters
tracing::info!( tracing::info!(
host_goodput_bytes = result.bytes_sent, host_goodput_bytes = result.bytes_sent,
wire_packets_sent = result.wire_packets_sent, wire_packets_sent = result.wire_packets_sent,
@@ -1421,6 +1514,35 @@ async fn worker_main(args: WorkerArgs) {
"host re-targeted encoder bitrate" "host re-targeted encoder bitrate"
); );
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps); *bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
break;
}
}
ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read
// late exactly then) — keep the old estimate and let the next
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
offset_ns,
rtt_us = rtt_ns / 1000,
"mid-stream clock re-sync applied"
);
} else {
tracing::debug!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
}
}
ResyncStep::Idle => {}
}
} else { } else {
tracing::warn!("unknown control message — ignoring"); tracing::warn!("unknown control message — ignoring");
} }
@@ -1487,6 +1609,8 @@ async fn worker_main(args: WorkerArgs) {
let pump_shutdown = shutdown.clone(); let pump_shutdown = shutdown.clone();
let pump_probe = probe.clone(); let pump_probe = probe.clone();
let pump_hot_tids = hot_tids.clone(); let pump_hot_tids = hot_tids.clone();
let pump_clock_offset = clock_offset.clone();
let pump_clock_gen = clock_gen.clone();
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
@@ -1515,7 +1639,34 @@ async fn worker_main(args: WorkerArgs) {
let mut stale_frames: u32 = 0; let mut stale_frames: u32 = 0;
let mut standing_frames: u32 = 0; let mut standing_frames: u32 = 0;
let mut last_flush: Option<Instant> = None; let mut last_flush: Option<Instant> = None;
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
// the control task for an immediate re-sync (via the report tick): the flush finding no
// local backlog IS the "the wall clock stepped under me" signal.
let mut noop_clock_flushes: u32 = 0;
let mut clock_detector_armed = true;
let mut resync_wanted = false;
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
while !pump_shutdown.load(Ordering::SeqCst) { while !pump_shutdown.load(Ordering::SeqCst) {
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
// re-sync takes effect on the very next frame's latency math.
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
// An applied re-sync invalidates the staleness run measured under the OLD offset:
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
let gen = pump_clock_gen.load(Ordering::Relaxed);
if gen != seen_clock_gen {
seen_clock_gen = gen;
stale_frames = 0;
noop_clock_flushes = 0;
if !clock_detector_armed {
clock_detector_armed = true;
tracing::info!(
"clock re-sync applied — clock-based jump-to-live re-armed"
);
}
}
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery // Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
// loop, and (during a speed test) the packet-level receive counters for the throughput // loop, and (during a speed test) the packet-level receive counters for the throughput
// measurement. Updated every iteration (not just on a produced frame) so they stay current // measurement. Updated every iteration (not just on a produced frame) so they stay current
@@ -1534,13 +1685,19 @@ async fn worker_main(args: WorkerArgs) {
p.active && !p.done p.active && !p.done
}; };
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL { if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
if resync_wanted {
resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped); let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm( let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered), st.fec_recovered_shards.wrapping_sub(last_recovered),
st.packets_received.wrapping_sub(last_received), st.packets_received.wrapping_sub(last_received),
window_dropped, window_dropped,
); );
let _ = ctrl_tx.send(CtrlRequest::Loss(LossReport { loss_ppm })); let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then // Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
// feed the controller this window's congestion signals; a decision becomes a // feed the controller this window's congestion signals; a decision becomes a
// SetBitrate on the control stream. // SetBitrate on the control stream.
@@ -1558,7 +1715,7 @@ async fn worker_main(args: WorkerArgs) {
flush_in_window, flush_in_window,
) { ) {
tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target"); tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target");
let _ = ctrl_tx.send(CtrlRequest::SetBitrate(kbps)); let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
} }
flush_in_window = false; flush_in_window = false;
last_report = Instant::now(); last_report = Instant::now();
@@ -1605,7 +1762,10 @@ async fn worker_main(args: WorkerArgs) {
owd_sum_ns += lat_ns; owd_sum_ns += lat_ns;
owd_frames += 1; owd_frames += 1;
} }
if clock_offset_ns != 0 && lat_ns > FLUSH_LATENCY.as_nanos() as i128 { if clock_detector_armed
&& clock_offset_ns != 0
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
{
stale_frames += 1; stale_frames += 1;
} else { } else {
stale_frames = 0; stale_frames = 0;
@@ -1627,7 +1787,7 @@ async fn worker_main(args: WorkerArgs) {
flush_in_window = true; // strongest "link can't hold the rate" signal flush_in_window = true; // strongest "link can't hold the rate" signal
let flushed = session.flush_backlog().unwrap_or(0); let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear(); let dropped = frames.clear();
let _ = ctrl_tx.send(CtrlRequest::Keyframe); let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
tracing::warn!( tracing::warn!(
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 }, behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
queue_depth = depth, queue_depth = depth,
@@ -1635,6 +1795,34 @@ async fn worker_main(args: WorkerArgs) {
dropped_frames = dropped, dropped_frames = dropped,
"receive backlog stopped draining — jumped to live (flush + keyframe)" "receive backlog stopped draining — jumped to live (flush + keyframe)"
); );
// Clock-detector health check: a clock-only trigger whose flush found
// no local backlog is a false "behind" reading (a wall-clock step, or
// an upstream queue a local flush can't drain) — repeated, it would
// cost a recovery IDR every cooldown forever. Disarm after two in a
// row; the clock-free queue detector keeps covering real backlogs.
if clock_behind && !queue_behind
&& flushed < NOOP_FLUSH_DATAGRAMS
&& dropped == 0
{
noop_clock_flushes += 1;
if noop_clock_flushes == 1 {
// First no-op flush = a wall-clock step is the prime
// suspect: ask for an immediate re-sync (sent on the next
// report tick). Applied, it resets these counters and
// re-arms the detector before the disarm below triggers.
resync_wanted = true;
}
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
clock_detector_armed = false;
tracing::warn!(
"clock-based jump-to-live disarmed — its flushes found no \
local backlog (clock step or upstream queueing suspected); \
the queue-depth detector stays armed"
);
}
} else {
noop_clock_flushes = 0;
}
continue; // this frame is part of the stale past — don't render it continue; // this frame is part of the stale past — don't render it
} }
} }
+58
View File
@@ -90,6 +90,31 @@ impl SessionCrypto {
) )
.map_err(|_| PunktfunkError::Crypto) .map_err(|_| PunktfunkError::Crypto)
} }
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
/// mirror of [`seal_in_place`](Self::seal_in_place).
pub fn open_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<usize> {
if buf.len() < TAG_LEN {
return Err(PunktfunkError::BadPacket);
}
let nonce = nonce(self.recv_salt, seq);
let split = buf.len() - TAG_LEN;
let (ciphertext, tag) = buf.split_at_mut(split);
self.cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce),
&seq.to_be_bytes(),
ciphertext,
aes_gcm::Tag::from_slice(tag),
)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(split)
}
} }
fn direction(role: Role) -> u8 { fn direction(role: Role) -> u8 {
@@ -164,6 +189,39 @@ mod tests {
); );
} }
#[test]
fn open_in_place_matches_open_and_rejects_tampering() {
let key = random_key();
let salt = random_salt();
let host = SessionCrypto::new(&key, salt, Role::Host);
let client = SessionCrypto::new(&key, salt, Role::Client);
for msg in [
&b""[..],
b"x",
b"the quick brown fox jumps over 13 lazy dogs!!",
] {
let sealed = host.seal(9, msg).unwrap();
let mut buf = sealed.clone();
let n = client.open_in_place(9, &mut buf).unwrap();
assert_eq!(
&buf[..n],
msg,
"in-place open must be byte-identical to open"
);
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
let mut buf = sealed.clone();
assert!(client.open_in_place(8, &mut buf).is_err());
// A flipped ciphertext/tag bit → authentication failure.
let mut buf = sealed.clone();
let last = buf.len() - 1;
buf[last] ^= 1;
assert!(client.open_in_place(9, &mut buf).is_err());
}
// Shorter than a tag can't be a sealed packet at all.
let mut runt = vec![0u8; TAG_LEN - 1];
assert!(client.open_in_place(0, &mut runt).is_err());
}
#[test] #[test]
fn seal_in_place_matches_seal_and_opens() { fn seal_in_place_matches_seal_and_opens() {
let key = random_key(); let key = random_key();
+1 -1
View File
@@ -12,7 +12,7 @@ impl ErasureCoder for Gf16Coder {
FecScheme::Gf16 FecScheme::Gf16
} }
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
if recovery_count == 0 { if recovery_count == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
+9 -9
View File
@@ -15,7 +15,7 @@ impl ErasureCoder for Gf8Coder {
FecScheme::Gf8 FecScheme::Gf8
} }
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> { fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
if recovery_count == 0 { if recovery_count == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -24,13 +24,12 @@ impl ErasureCoder for Gf8Coder {
let shard_len = data[0].len(); let shard_len = data[0].len();
let rs = ReedSolomon::new(k, recovery_count) let rs = ReedSolomon::new(k, recovery_count)
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?; .map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
// fec-rs fills parity in place: shards = data || zeroed parity. // `encode_sep` reads the data shards by reference and fills the parity in place —
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(k + recovery_count); // same Cauchy codec as `encode`, without copying the data into a shards scratch.
shards.extend_from_slice(data); let mut parity: Vec<Vec<u8>> = (0..recovery_count).map(|_| vec![0u8; shard_len]).collect();
shards.resize_with(k + recovery_count, || vec![0u8; shard_len]); rs.encode_sep(data, &mut parity)
rs.encode(&mut shards)
.map_err(|_| FecError::Backend("gf8 encode"))?; .map_err(|_| FecError::Backend("gf8 encode"))?;
Ok(shards.split_off(k)) Ok(parity)
} }
fn reconstruct( fn reconstruct(
@@ -84,7 +83,7 @@ mod tests {
fn nanors_exact_parity_vectors() { fn nanors_exact_parity_vectors() {
let coder = Gf8Coder; let coder = Gf8Coder;
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0]. // The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
let data = vec![vec![10u8], vec![20], vec![30], vec![40]]; let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]];
let parity = coder.encode(&data, 2).unwrap(); let parity = coder.encode(&data, 2).unwrap();
assert_eq!(parity, vec![vec![136u8], vec![0u8]]); assert_eq!(parity, vec![vec![136u8], vec![0u8]]);
@@ -106,7 +105,8 @@ mod tests {
fn recovers_erased_data_shards() { fn recovers_erased_data_shards() {
let coder = Gf8Coder; let coder = Gf8Coder;
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect(); let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
let parity = coder.encode(&data, 3).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let parity = coder.encode(&refs, 3).unwrap();
let mut received: Vec<Option<Vec<u8>>> = data let mut received: Vec<Option<Vec<u8>>> = data
.iter() .iter()
.cloned() .cloned()
+8 -4
View File
@@ -30,7 +30,9 @@ pub trait ErasureCoder: Send + Sync {
/// Encode `data` (K original shards) into `recovery_count` (M) parity shards. /// Encode `data` (K original shards) into `recovery_count` (M) parity shards.
/// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`. /// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`.
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>; /// Takes shard *references* so the packetizer can point straight into the frame
/// buffer instead of copying every data byte into per-shard `Vec`s first.
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are /// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost. /// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
@@ -79,7 +81,7 @@ pub(crate) fn validate_block_shape(
} }
/// Validate `encode` inputs: at least one data shard, all of equal length. /// Validate `encode` inputs: at least one data shard, all of equal length.
pub(crate) fn validate_encode_shape(data: &[Vec<u8>]) -> Result<(), FecError> { pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
let first = data let first = data
.first() .first()
.ok_or(FecError::Config("no data shards"))? .ok_or(FecError::Config("no data shards"))?
@@ -100,7 +102,8 @@ mod tests {
let data: Vec<Vec<u8>> = (0..k) let data: Vec<Vec<u8>> = (0..k)
.map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect()) .map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect())
.collect(); .collect();
let recovery = coder.encode(&data, m).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = coder.encode(&refs, m).unwrap();
assert_eq!(recovery.len(), m); assert_eq!(recovery.len(), m);
let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m); let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m);
@@ -128,7 +131,8 @@ mod tests {
#[test] #[test]
fn gf8_too_much_loss_errors() { fn gf8_too_much_loss_errors() {
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect(); let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
let recovery = Gf8Coder.encode(&data, 2).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = Gf8Coder.encode(&refs, 2).unwrap();
let mut received: Vec<Option<Vec<u8>>> = data let mut received: Vec<Option<Vec<u8>>> = data
.iter() .iter()
.cloned() .cloned()
+57 -20
View File
@@ -104,6 +104,11 @@ pub struct Packetizer {
shard_payload: usize, shard_payload: usize,
fec: crate::config::FecConfig, fec: crate::config::FecConfig,
version: u8, version: u8,
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
/// exact `shard_payload` multiple (and for the single all-zero shard of an empty frame).
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
tail: Vec<u8>,
} }
impl Packetizer { impl Packetizer {
@@ -114,6 +119,7 @@ impl Packetizer {
shard_payload: config.shard_payload, shard_payload: config.shard_payload,
fec: config.fec, fec: config.fec,
version: config.phase as u8, version: config.phase as u8,
tail: Vec::new(),
} }
} }
@@ -129,7 +135,9 @@ impl Packetizer {
self.fec.fec_percent self.fec.fec_percent
} }
/// Packetize one access unit into wire packets (header + shard payload each). /// Packetize one access unit into owned wire packets (header ++ shard payload each).
/// Thin wrapper over [`packetize_each`](Self::packetize_each) — the allocation-free
/// streaming path's reference implementation (tests and the loss harness use this).
pub fn packetize( pub fn packetize(
&mut self, &mut self,
frame: &[u8], frame: &[u8],
@@ -137,6 +145,31 @@ impl Packetizer {
user_flags: u32, user_flags: u32,
coder: &dyn ErasureCoder, coder: &dyn ErasureCoder,
) -> Result<Vec<Vec<u8>>> { ) -> Result<Vec<Vec<u8>>> {
let mut packets = Vec::new();
self.packetize_each(frame, pts_ns, user_flags, coder, |hdr, body| {
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
pkt.extend_from_slice(hdr.as_bytes());
pkt.extend_from_slice(body);
packets.push(pkt);
Ok(())
})?;
Ok(packets)
}
/// Packetize one access unit, yielding each packet to `emit` as a `(header, shard bytes)`
/// pair — in exact wire order, which is also the order the session's nonce counter
/// advances. No per-packet allocation happens here, so the caller can write header and
/// shard straight into a pooled wire buffer and seal in place
/// ([`Session::seal_frame`](crate::session::Session::seal_frame)). An `emit` error aborts
/// the frame mid-way (packet numbering has already advanced — callers treat it as fatal).
pub fn packetize_each(
&mut self,
frame: &[u8],
pts_ns: u64,
user_flags: u32,
coder: &dyn ErasureCoder,
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
) -> Result<()> {
let payload = self.shard_payload; let payload = self.shard_payload;
let frame_index = self.next_frame_index; let frame_index = self.next_frame_index;
self.next_frame_index = self.next_frame_index.wrapping_add(1); self.next_frame_index = self.next_frame_index.wrapping_add(1);
@@ -159,23 +192,31 @@ impl Packetizer {
)); ));
} }
let mut packets = Vec::new(); // Stage the frame's one possibly-partial shard (the last) in the reusable
// zero-padded scratch; every full shard is referenced in place below.
let full_shards = frame.len() / payload;
self.tail.clear();
self.tail.resize(payload, 0);
let rem = frame.len() % payload;
if rem > 0 {
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
}
let tail = &self.tail;
let shard_at = |s: usize| -> &[u8] {
if s < full_shards {
&frame[s * payload..(s + 1) * payload]
} else {
tail.as_slice()
}
};
for b in 0..block_count { for b in 0..block_count {
let first = b * max_block; let first = b * max_block;
let last = ((b + 1) * max_block).min(total_data); let last = ((b + 1) * max_block).min(total_data);
let block_data_count = last - first; let block_data_count = last - first;
// Build this block's data shards (each `payload` bytes, last zero-padded). // This block's data shards: references into `frame` (plus the staged tail).
let mut data_shards: Vec<Vec<u8>> = Vec::with_capacity(block_data_count); let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect();
for s in first..last {
let start = s * payload;
let end = (start + payload).min(frame.len());
let mut shard = vec![0u8; payload];
if start < frame.len() {
shard[..end - start].copy_from_slice(&frame[start..end]);
}
data_shards.push(shard);
}
let recovery_count = self.fec.recovery_for(block_data_count); let recovery_count = self.fec.recovery_for(block_data_count);
let recovery = coder.encode(&data_shards, recovery_count)?; let recovery = coder.encode(&data_shards, recovery_count)?;
@@ -186,7 +227,7 @@ impl Packetizer {
for shard_index in 0..total_shards { for shard_index in 0..total_shards {
let body: &[u8] = if shard_index < block_data_count { let body: &[u8] = if shard_index < block_data_count {
&data_shards[shard_index] data_shards[shard_index]
} else { } else {
&recovery[shard_index - block_data_count] &recovery[shard_index - block_data_count]
}; };
@@ -219,14 +260,10 @@ impl Packetizer {
fec_scheme: coder.scheme() as u8, fec_scheme: coder.scheme() as u8,
flags, flags,
}; };
emit(&hdr, body)?;
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
pkt.extend_from_slice(hdr.as_bytes());
pkt.extend_from_slice(body);
packets.push(pkt);
} }
} }
Ok(packets) Ok(())
} }
} }
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
//! Wall-clock skew: the connect-time handshake ([`clock_sync`]), the NTP-style offset
//! estimator ([`clock_offset_ns`]), and the mid-stream re-sync state machine
//! ([`ClockResync`]).
use super::{io, ClockEcho, ClockProbe};
/// Estimate the host↔client clock offset (**host minus client**, ns) and RTT (ns) from skew-handshake
/// samples `(t1, t2, t3, t4)` — NTP's formula, taking the **minimum-RTT** sample (least queuing
/// noise; also discards the first round's host-setup latency). Offset is positive when the host
/// clock is ahead of the client's; add it to a client timestamp to express it in the host clock.
/// Returns `None` for an empty sample set.
pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
samples
.iter()
.map(|&(t1, t2, t3, t4)| {
let rtt = ((t4 as i128 - t1 as i128) - (t3 as i128 - t2 as i128)).max(0) as u64;
let offset = (((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) / 2) as i64;
(offset, rtt)
})
.min_by_key(|&(_, rtt)| rtt)
}
/// One wall-clock skew-handshake outcome (see [`clock_sync`]).
pub struct ClockSkew {
/// Host clock minus client clock, ns: add it to a client timestamp to express it in host time.
pub offset_ns: i64,
/// Round-trip time of the minimum-RTT sample, ns.
pub rtt_ns: u64,
/// How many probe rounds the host answered.
pub rounds: usize,
}
/// Run the wall-clock skew handshake from the client side over the (already-open) control stream:
/// `ROUNDS` [`ClockProbe`]/[`ClockEcho`] round-trips, returning the host↔client offset from the
/// minimum-RTT sample. `None` if the host never answers (an old host) — the caller then assumes a
/// shared clock. Each read is bounded so a silent host can't wedge session start. Shared by the
/// reference client and the embeddable connector; uses the realtime clock the host stamps `pts_ns`
/// with, so the offset aligns a client receive instant to the host's capture clock.
pub async fn clock_sync(
send: &mut quinn::SendStream,
recv: &mut quinn::RecvStream,
) -> Option<ClockSkew> {
use std::time::Duration;
const ROUNDS: usize = 8;
let read_timeout = Duration::from_secs(2);
let mut samples: Vec<(u64, u64, u64, u64)> = Vec::with_capacity(ROUNDS);
for _ in 0..ROUNDS {
let t1 = wall_clock_ns();
let probe = ClockProbe { t1_ns: t1 }.encode();
if io::write_msg(send, &probe).await.is_err() {
break;
}
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
let echo = match read {
Ok(Ok(b)) => match ClockEcho::decode(&b) {
Ok(e) => e,
Err(_) => break,
},
_ => break, // timeout or stream error -> old host / no skew support
};
samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, wall_clock_ns()));
}
clock_offset_ns(&samples).map(|(offset_ns, rtt_ns)| ClockSkew {
offset_ns,
rtt_ns,
rounds: samples.len(),
})
}
/// Wall-clock now (ns since the Unix epoch) — the clock the skew handshake stamps and the host
/// stamps AU `pts_ns` with (CLOCK_REALTIME basis, deliberately NOT monotonic: steps/slew are
/// exactly what the handshake measures across machines).
pub fn wall_clock_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
/// What [`ClockResync::on_echo`] asks the driver to do next.
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle,
/// Send this next-round probe and keep feeding echoes.
Probe(ClockProbe),
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 },
}
/// Mid-stream wall-clock re-sync (networking-audit deferred plan §2): the same 8-round
/// probe/echo estimate as the connect-time [`clock_sync`], restructured as a state machine so
/// the client's control task can drive it from its `select!` loop without blocking the stream —
/// echoes interleave with other control traffic; rounds are matched by the echoed `t1`.
///
/// A step or slow drift of either wall clock after connect silently corrupts the clock-based
/// jump-to-live signal, the ABR one-way-delay signal, and every latency stat. Re-syncing
/// restores them; the disarm heuristic stays as the final backstop.
pub struct ClockResync {
/// `t1_ns` of the probe in flight; `None` = no batch active. An echo whose `t1` doesn't
/// match is stale (an abandoned batch) and ignored.
pending_t1: Option<u64>,
samples: Vec<(u64, u64, u64, u64)>,
}
impl ClockResync {
/// Rounds per batch — matches the connect-time [`clock_sync`].
pub const ROUNDS: usize = 8;
pub fn new() -> ClockResync {
ClockResync {
pending_t1: None,
samples: Vec::with_capacity(Self::ROUNDS),
}
}
/// Start a (new) batch, abandoning any batch still in flight — its late echoes won't match
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear();
self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns }
}
/// Feed an inbound [`ClockEcho`] received at `now_ns` (the round's `t4`).
pub fn on_echo(&mut self, echo: &ClockEcho, now_ns: u64) -> ResyncStep {
if self.pending_t1 != Some(echo.t1_ns) {
return ResyncStep::Idle; // stale (abandoned batch) or unsolicited
}
self.samples
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS {
self.pending_t1 = Some(now_ns);
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
self.pending_t1 = None;
match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
}
}
}
impl Default for ClockResync {
fn default() -> Self {
Self::new()
}
}
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
/// biases the offset by its queueing delay, and frames already read late exactly then; better
/// to keep the old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
}
+418
View File
@@ -0,0 +1,418 @@
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC90xCF):
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
/// (0xCE, host→client).
pub const AUDIO_MAGIC: u8 = 0xC9;
pub const RUMBLE_MAGIC: u8 = 0xCA;
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
/// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
pub const MIC_MAGIC: u8 = 0xCB;
/// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
/// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
/// kind-tagged (see [`RichInput`]).
pub const RICH_INPUT_MAGIC: u8 = 0xCC;
/// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
/// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
/// [`HidOutput`].
pub const HIDOUT_MAGIC: u8 = 0xCD;
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(AUDIO_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != AUDIO_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b
}
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
Some((u16at(1), u16at(3), u16at(5)))
}
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
let mut b = Vec::with_capacity(13 + opus.len());
b.push(MIC_MAGIC);
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&pts_ns.to_le_bytes());
b.extend_from_slice(opus);
b
}
/// Parse a mic datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
if b.len() < 13 || b[0] != MIC_MAGIC {
return None;
}
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
Some((seq, pts_ns, &b[13..]))
}
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
pub(super) const RICH_MOTION: u8 = 0x02;
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
/// `[0xCC][kind][fields…]` — variable-length and kind-tagged (forward-compatible: an unknown
/// kind decodes to `None` and is dropped).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RichInput {
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
/// the finger.
Touchpad {
pad: u8,
finger: u8,
active: bool,
x: u16,
y: u16,
},
/// Motion sensors: `gyro` (pitch/yaw/roll) + `accel`, raw signed-16 in the sensor's own
/// units — passed straight into the DualSense report.
Motion {
pad: u8,
gyro: [i16; 3],
accel: [i16; 3],
},
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
/// `TouchpadEx` (`0x03`) indefinitely.
TouchpadEx {
pad: u8,
surface: u8,
finger: u8,
touch: bool,
click: bool,
x: i16,
y: i16,
pressure: u16,
},
}
impl RichInput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![RICH_INPUT_MAGIC];
match *self {
RichInput::Touchpad {
pad,
finger,
active,
x,
y,
} => {
out.extend_from_slice(&[RICH_TOUCHPAD, pad, finger, active as u8]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
}
RichInput::Motion { pad, gyro, accel } => {
out.extend_from_slice(&[RICH_MOTION, pad]);
for v in gyro.iter().chain(accel.iter()) {
out.extend_from_slice(&v.to_le_bytes());
}
}
RichInput::TouchpadEx {
pad,
surface,
finger,
touch,
click,
x,
y,
pressure,
} => {
let state = (touch as u8) | ((click as u8) << 1);
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
out.extend_from_slice(&x.to_le_bytes());
out.extend_from_slice(&y.to_le_bytes());
out.extend_from_slice(&pressure.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<RichInput> {
if b.first() != Some(&RICH_INPUT_MAGIC) {
return None;
}
match *b.get(1)? {
RICH_TOUCHPAD if b.len() >= 9 => Some(RichInput::Touchpad {
pad: b[2],
finger: b[3],
active: b[4] != 0,
x: u16::from_le_bytes([b[5], b[6]]),
y: u16::from_le_bytes([b[7], b[8]]),
}),
RICH_MOTION if b.len() >= 15 => {
let i16at = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
Some(RichInput::Motion {
pad: b[2],
gyro: [i16at(3), i16at(5), i16at(7)],
accel: [i16at(9), i16at(11), i16at(13)],
})
}
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
pad: b[2],
surface: b[3],
finger: b[4],
touch: b[5] & 0x01 != 0,
click: b[5] & 0x02 != 0,
x: i16::from_le_bytes([b[6], b[7]]),
y: i16::from_le_bytes([b[8], b[9]]),
pressure: u16::from_le_bytes([b[10], b[11]]),
}),
_ => None,
}
}
}
const HIDOUT_LED: u8 = 0x01;
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
const HIDOUT_TRIGGER: u8 = 0x03;
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
/// rumble itself stays on [`RUMBLE_MAGIC`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HidOutput {
/// Lightbar RGB.
Led { pad: u8, r: u8, g: u8, b: u8 },
/// Player-indicator LEDs (low 5 bits).
PlayerLeds { pad: u8, bits: u8 },
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
/// trigger parameter block (mode + params) for the client to replay on a real controller.
Trigger { pad: u8, which: u8, effect: Vec<u8> },
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
TrackpadHaptic {
pad: u8,
side: u8,
amplitude: u16,
period: u16,
count: u16,
},
}
impl HidOutput {
pub fn encode(&self) -> Vec<u8> {
let mut out = vec![HIDOUT_MAGIC];
match self {
HidOutput::Led { pad, r, g, b } => {
out.extend_from_slice(&[HIDOUT_LED, *pad, *r, *g, *b])
}
HidOutput::PlayerLeds { pad, bits } => {
out.extend_from_slice(&[HIDOUT_PLAYER_LEDS, *pad, *bits])
}
HidOutput::Trigger { pad, which, effect } => {
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
out.extend_from_slice(effect);
}
HidOutput::TrackpadHaptic {
pad,
side,
amplitude,
period,
count,
} => {
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
out.extend_from_slice(&amplitude.to_le_bytes());
out.extend_from_slice(&period.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
}
}
out
}
pub fn decode(b: &[u8]) -> Option<HidOutput> {
if b.first() != Some(&HIDOUT_MAGIC) {
return None;
}
match *b.get(1)? {
HIDOUT_LED if b.len() >= 6 => Some(HidOutput::Led {
pad: b[2],
r: b[3],
g: b[4],
b: b[5],
}),
HIDOUT_PLAYER_LEDS if b.len() >= 4 => Some(HidOutput::PlayerLeds {
pad: b[2],
bits: b[3],
}),
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
pad: b[2],
which: b[3],
effect: b[4..].to_vec(),
}),
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
pad: b[2],
side: b[3],
amplitude: u16::from_le_bytes([b[4], b[5]]),
period: u16::from_le_bytes([b[6], b[7]]),
count: u16::from_le_bytes([b[8], b[9]]),
}),
_ => None,
}
}
}
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
/// is larger and can change mid-stream when the source's mastering intent changes; the host
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
/// for HLG (scene-referred — no mastering metadata).
///
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct HdrMeta {
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
/// is G, B, R).
pub display_primaries: [[u16; 2]; 3],
/// White point (x, y) in 1/50000 units.
pub white_point: [u16; 2],
/// Max display mastering luminance, 0.0001 cd/m² units.
pub max_display_mastering_luminance: u32,
/// Min display mastering luminance, 0.0001 cd/m² units.
pub min_display_mastering_luminance: u32,
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
pub max_cll: u16,
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
pub max_fall: u16,
}
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
pub const HDR_META_MAGIC: u8 = 0xCE;
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32
/// luminance + 2×u16 CLL/FALL = 29 bytes.
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4;
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
let mut b = Vec::with_capacity(HDR_META_LEN);
b.push(HDR_META_MAGIC);
for p in m.display_primaries.iter() {
b.extend_from_slice(&p[0].to_le_bytes());
b.extend_from_slice(&p[1].to_le_bytes());
}
b.extend_from_slice(&m.white_point[0].to_le_bytes());
b.extend_from_slice(&m.white_point[1].to_le_bytes());
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
b.extend_from_slice(&m.max_cll.to_le_bytes());
b.extend_from_slice(&m.max_fall.to_le_bytes());
b
}
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
Some(HdrMeta {
display_primaries: [
[u16at(1), u16at(3)],
[u16at(5), u16at(7)],
[u16at(9), u16at(11)],
],
white_point: [u16at(13), u16at(15)],
max_display_mastering_luminance: u32at(17),
min_display_mastering_luminance: u32at(21),
max_cll: u16at(25),
max_fall: u16at(27),
})
}
/// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
/// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
/// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
pub const HOST_TIMING_MAGIC: u8 = 0xCF;
/// One access unit's host-side processing time: capture → fully sent (the whole host pipeline —
/// capture read/convert, encode, FEC+seal, paced send). The client correlates it to the AU by
/// `pts_ns` (the AU's capture stamp, unique per frame) and derives
/// `network = (received + clock_offset pts_ns) host_us`, so the unified-stats equation's
/// `host+network` stage splits into two per-frame-tiling terms. Best-effort like every side-plane
/// datagram: a lost 0xCF just means that frame contributes no host/network sample.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HostTiming {
/// The AU's capture stamp (host capture clock — matches the AU's `pts_ns` exactly).
pub pts_ns: u64,
/// Host capture→sent duration, µs (saturated at `u32::MAX` ≈ 71 min — far past the 10 s
/// client-side sanity clamp anyway).
pub host_us: u32,
}
/// Wire length of a [`HOST_TIMING_MAGIC`] datagram: tag + u64 pts + u32 µs = 13 bytes.
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram.
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
let mut b = Vec::with_capacity(HOST_TIMING_LEN);
b.push(HOST_TIMING_MAGIC);
b.extend_from_slice(&t.pts_ns.to_le_bytes());
b.extend_from_slice(&t.host_us.to_le_bytes());
b
}
/// Parse a [`HOST_TIMING_MAGIC`] datagram → [`HostTiming`]. `None` on bad tag or a short buffer
/// (the fixed length bounds every read before it happens).
pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
if b.len() < HOST_TIMING_LEN || b[0] != HOST_TIMING_MAGIC {
return None;
}
Some(HostTiming {
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
})
}
+362
View File
@@ -0,0 +1,362 @@
use std::sync::{Arc, Mutex};
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
/// idle timer run out and quinn closes the session — surfacing to the embedder as
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
fn stream_transport() -> Arc<quinn::TransportConfig> {
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
}
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
/// the keep-alive holds it open through quiet control periods.
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
use std::time::Duration;
let idle = idle.max(Duration::from_secs(1));
let keep_alive = (idle / 2).min(Duration::from_secs(4));
let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
));
t.keep_alive_interval(Some(keep_alive));
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
// standing delay that never drains while video stays live. Capping the buffer makes the
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
// surround bitrates), so sustained congestion costs concealable drops, never lag.
t.datagram_send_buffer_size(4 * 1024);
Arc::new(t)
}
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
/// persist an identity and use [`server_with_identity`] so clients can pin it).
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
}
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
pub fn server_with_identity(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
) -> anyhow_result::Result<quinn::Endpoint> {
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
}
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
/// keep-alive scales with it so a live session never false-closes.
pub fn server_with_identity_idle(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
server_from_der(cert_der, key_der, addr, idle)
}
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
/// together (acceptable while the protocol/ABI is still evolving).
const QUIC_ALPN: &[u8] = b"pkf1";
fn server_from_der(
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
// one that presents none still connects (and is rejected at the app layer when
// pairing is required).
let mut rustls_cfg = rustls::ServerConfig::builder()
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
.with_single_cert(vec![cert_der], key_der)
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
Ok(quinn::Endpoint::server(server_config, addr)?)
}
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
/// client persists once and presents on every connect so hosts can recognize it.
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
}
/// Fingerprint of the client certificate a connection presented (host side), if any.
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
let identity = conn.peer_identity()?;
let certs = identity
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
.ok()?;
certs.first().map(|c| cert_fingerprint(c.as_ref()))
}
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(cert_der).into()
}
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
/// must match what the client's verifier computes from the DER on the wire).
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
Ok(cert_fingerprint(der.as_ref()))
}
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
/// observed fingerprint off the slot and pin it on the next connect).
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
client_pinned(None).0
}
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
/// observed host fingerprint into during the handshake.
pub type PinnedClient = (
anyhow_result::Result<quinn::Endpoint>,
Arc<Mutex<Option<[u8; 32]>>>,
);
/// Client endpoint that verifies the host by certificate fingerprint.
///
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
client_pinned_with_identity(pin, None)
}
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
/// key) via TLS client auth — how a paired client identifies itself to the host.
pub fn client_pinned_with_identity(
pin: Option<[u8; 32]>,
identity: Option<(&str, &str)>,
) -> PinnedClient {
let observed = Arc::new(Mutex::new(None));
let ep = (|| {
let _ = rustls::crypto::ring::default_provider().install_default();
let builder = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify {
pin,
observed: observed.clone(),
}));
let mut rustls_cfg = match identity {
None => builder.with_no_client_auth(),
Some((cert_pem, key_pem)) => {
use rustls::pki_types::pem::PemObject;
let cert =
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("client cert pem: {e}")))?;
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
builder
.with_client_auth_cert(vec![cert], key)
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
}
};
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
ep.set_default_client_config(client_cfg);
Ok(ep)
})();
(ep, observed)
}
/// Minimal error plumbing without pulling anyhow into punktfunk-core's public API.
pub mod anyhow_result {
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Error(String);
impl Error {
pub fn msg(s: String) -> Self {
Error(s)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error(e.to_string())
}
}
}
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
/// verify the handshake signature for real — possession of the presented cert's key is
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
/// Authorization (is this fingerprint paired?) happens at the application layer.
#[derive(Debug)]
struct AcceptAnyClientCert;
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&[]
}
fn client_auth_mandatory(&self) -> bool {
false // unpaired/legacy clients still connect; gating is per-feature
}
fn verify_client_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
Ok(rustls::server::danger::ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
observed: Arc<Mutex<Option<[u8; 32]>>>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let fp = cert_fingerprint(end_entity.as_ref());
*self.observed.lock().unwrap() = Some(fp);
if let Some(expected) = self.pin {
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
// The handshake signatures MUST be verified for real even though we pin the cert:
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
// skip it and an active MITM can replay the host's (public) certificate, match the
// pin, and complete the handshake with its own key.
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2];
recv.read_exact(&mut len)
.await
.map_err(std::io::Error::other)?;
let n = u16::from_le_bytes(len) as usize;
let mut buf = vec![0u8; n];
recv.read_exact(&mut buf)
.await
.map_err(std::io::Error::other)?;
Ok(buf)
}
/// Write one framed message.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload))
.await
.map_err(std::io::Error::other)
}
+64
View File
@@ -0,0 +1,64 @@
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
//!
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
//! length-prefixed binary handshake on one bidirectional stream:
//!
//! ```text
//! client → host Hello { abi_version }
//! host → client Welcome { abi_version, session: full data-plane Config + mode + UDP port }
//! client → host Start { client_udp_port }
//! ```
//!
//! after which both sides bring up a [`crate::session::Session`] over a plain
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
//! plane is exactly the hardened core `Session`.
//!
//! Transport security: the host presents a long-lived self-signed certificate
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
//! reported back for persisting). The data plane adds AES-GCM on top.
//! All integers little-endian; every message is `u16 length || payload`.
//!
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
//! 0xC90xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
//! existing `crate::quic::X` paths compile unchanged.
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
pub const MAGIC: &[u8; 4] = b"PKF1";
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
/// byte sits where a type byte would) can never be misparsed as a control message, and
/// vice-versa, regardless of field values.
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
mod clock;
mod datagram;
mod msgs;
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
pub mod endpoint;
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
pub mod io;
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
/// the identities are ordered; each side binds **both** certificate fingerprints as the
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
/// cannot reach a shared key).
pub mod pake;
pub use clock::*;
pub use datagram::*;
pub use msgs::*;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
use crate::error::{PunktfunkError, Result};
use hmac::{Hmac, Mac};
use spake2::{Ed25519Group, Identity, Password, Spake2};
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
pub struct PairingPake {
state: Spake2<Ed25519Group>,
transcript: Vec<u8>,
}
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
/// client passes what it observed via TOFU; the host passes its own + the client's
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
pub fn start(
is_client: bool,
pin: &str,
client_fp: &[u8; 32],
host_fp: &[u8; 32],
) -> (PairingPake, Vec<u8>) {
let pw = Password::new(pin.as_bytes());
let id_client = Identity::new(client_fp);
let id_host = Identity::new(host_fp);
let (state, msg) = if is_client {
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
} else {
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
};
let mut transcript = Vec::with_capacity(64);
transcript.extend_from_slice(client_fp);
transcript.extend_from_slice(host_fp);
(PairingPake { state, transcript }, msg)
}
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
let mut mac =
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
mac.update(label);
mac.update(transcript);
mac.finalize().into_bytes().into()
}
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
/// our recomputed tag the same way.
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
pub struct Confirmations {
/// MAC the host sends (client verifies).
pub host: [u8; 32],
/// MAC the client sends (host verifies).
pub client: [u8; 32],
}
impl PairingPake {
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
/// *different* key, so the confirmation MACs simply won't match).
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
let key = self
.state
.finish(peer_msg)
.map_err(|_| PunktfunkError::Crypto)?;
Ok(Confirmations {
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
})
}
}
/// Constant-time tag comparison for the confirmation step.
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
ct_eq(expected, got)
}
+989
View File
@@ -0,0 +1,989 @@
use super::*;
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode, Role};
#[test]
fn welcome_roundtrip() {
let w = Welcome {
abi_version: 1,
udp_port: 9999,
mode: Mode {
width: 2560,
height: 1440,
refresh_hz: 240,
},
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 4096,
},
shard_payload: 1200,
encrypt: true,
key: [7u8; 16],
salt: [1, 2, 3, 4],
frames: 600,
compositor: CompositorPref::Gamescope,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 50_000,
bit_depth: 10,
color: ColorInfo::HDR10_BT2020_PQ,
chroma_format: CHROMA_IDC_444,
audio_channels: 2,
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
host_caps: HOST_CAP_GAMEPAD_STATE,
};
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
// Client-side reassembler ceiling derives from the negotiated rate: 4x the average frame at
// 50 Mbps/240 Hz is ~104 KB, so the 8 MiB floor governs. The host keeps the p1_defaults
// bound (it never reassembles video), as does a client of a bitrate-0 (older) host.
let cc = w.session_config(Role::Client);
assert_eq!(cc.max_frame_bytes, 8 << 20);
cc.validate().expect("derived client config validates");
assert_eq!(w.session_config(Role::Host).max_frame_bytes, 64 << 20);
let old_host = Welcome {
bitrate_kbps: 0,
..w
};
assert_eq!(
old_host.session_config(Role::Client).max_frame_bytes,
64 << 20
);
// A high-rate mode scales past the floor: 1.5 Gbps at 60 Hz = 4 x 3.125 MB = 12.5 MB.
let fat = Welcome {
bitrate_kbps: 1_500_000,
mode: Mode {
width: 5120,
height: 1440,
refresh_hz: 60,
},
..w
};
let derived = fat.session_config(Role::Client).max_frame_bytes;
assert_eq!(derived, 4 * 1_500_000 * 125 / 60);
assert!(derived > (8 << 20) && derived < (64 << 20));
}
#[test]
fn codec_negotiation_and_back_compat() {
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
Some(CODEC_HEVC)
);
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
Some(CODEC_AV1)
);
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
// A software host (H.264 only) + an HEVC-only client share nothing → refuse.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
// An older client (0 = no codec byte) is treated as HEVC-only.
assert_eq!(
resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
Some(CODEC_HEVC)
);
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
// Soft preference: honored when the host can also emit it, overriding precedence...
assert_eq!(
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
Some(CODEC_H264)
);
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
Some(CODEC_AV1)
);
// ...but falls back to precedence when the preferred codec isn't in the shared set.
assert_eq!(
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
Some(CODEC_HEVC)
);
// A preference the host can't emit still can't rescue a no-shared-codec case.
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
let h = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
video_codecs: CODEC_H264 | CODEC_HEVC,
preferred_codec: CODEC_H264,
};
let enc = h.encode();
let dec = Hello::decode(&enc).unwrap();
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
assert_eq!(dec.preferred_codec, CODEC_H264);
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
let no_pref = &enc[..enc.len() - 1];
assert_eq!(
Hello::decode(no_pref).unwrap().video_codecs,
CODEC_H264 | CODEC_HEVC
);
assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
// A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
let legacy = &enc[..enc.len() - 2];
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
// A pre-codec Welcome (no codec byte) decodes to HEVC.
let mut w = Welcome::decode(
&Welcome {
abi_version: 2,
udp_port: 1,
mode: h.mode,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 0,
max_data_per_block: 1024,
},
shard_payload: 1024,
encrypt: false,
key: [0; 16],
salt: [0; 4],
frames: 0,
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
bit_depth: 8,
color: ColorInfo::SDR_BT709,
chroma_format: CHROMA_IDC_420,
audio_channels: 2,
codec: CODEC_H264,
host_caps: 0,
}
.encode(),
)
.unwrap();
assert_eq!(w.codec, CODEC_H264);
w.codec = CODEC_HEVC;
let wenc = w.encode();
assert_eq!(
Welcome::decode(&wenc[..wenc.len() - 1]).unwrap().codec,
CODEC_HEVC
);
}
#[test]
fn hdr_meta_datagram_roundtrip_and_truncation() {
let m = HdrMeta {
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
white_point: [15635, 16450], // D65
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
min_display_mastering_luminance: 1, // 0.0001 nits
max_cll: 1000,
max_fall: 400,
};
let d = encode_hdr_meta_datagram(&m);
assert_eq!(d[0], HDR_META_MAGIC);
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
// Truncated buffers and a wrong tag are rejected (never partially read).
for n in 0..d.len() {
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
}
let mut bad = d.clone();
bad[0] = HIDOUT_MAGIC;
assert_eq!(decode_hdr_meta_datagram(&bad), None);
}
#[test]
fn host_timing_datagram_roundtrip_and_truncation() {
let t = HostTiming {
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
host_us: 4_321,
};
let d = encode_host_timing_datagram(&t);
assert_eq!(d[0], HOST_TIMING_MAGIC);
assert_eq!(d.len(), 13);
assert_eq!(decode_host_timing_datagram(&d), Some(t));
// Truncated buffers and a wrong tag are rejected (never partially read).
for n in 0..d.len() {
assert_eq!(decode_host_timing_datagram(&d[..n]), None);
}
let mut bad = d.clone();
bad[0] = HDR_META_MAGIC;
assert_eq!(decode_host_timing_datagram(&bad), None);
}
#[test]
fn hello_start_roundtrip() {
let h = Hello {
abi_version: 1,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 120,
},
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 25_000,
name: Some("Test Device".into()),
launch: Some("steam:570".into()),
video_caps: VIDEO_CAP_10BIT,
audio_channels: 2,
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
preferred_codec: CODEC_HEVC,
};
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start {
client_udp_port: 1234,
};
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
}
#[test]
fn compositor_pref_wire_and_names() {
for p in [
CompositorPref::Auto,
CompositorPref::Kwin,
CompositorPref::Wlroots,
CompositorPref::Mutter,
CompositorPref::Gamescope,
] {
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
}
// Aliases + unknowns.
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
assert_eq!(
CompositorPref::from_name("sway"),
Some(CompositorPref::Wlroots)
);
assert_eq!(CompositorPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
}
#[test]
fn gamepad_pref_wire_and_names() {
for p in [
GamepadPref::Auto,
GamepadPref::Xbox360,
GamepadPref::DualSense,
GamepadPref::XboxOne,
GamepadPref::DualShock4,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)
);
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
assert_eq!(GamepadPref::from_name("nope"), None);
// Unknown wire byte degrades to Auto (forward-compatible).
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
}
#[test]
fn hello_welcome_compositor_back_compat() {
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
// new message ignores the trailing bytes. Simulate both directions by truncation.
let h = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Mutter,
gamepad: GamepadPref::DualSense,
bitrate_kbps: 80_000,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
let enc = h.encode();
assert_eq!(enc.len(), 26);
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
let legacy = Hello::decode(&enc[..20]).unwrap();
assert_eq!(legacy.compositor, CompositorPref::Auto);
assert_eq!(legacy.gamepad, GamepadPref::Auto);
assert_eq!(legacy.bitrate_kbps, 0);
assert_eq!(legacy.mode, h.mode);
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
let mid = Hello::decode(&enc[..21]).unwrap();
assert_eq!(mid.compositor, CompositorPref::Mutter);
assert_eq!(mid.gamepad, GamepadPref::Auto);
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
assert_eq!(pre_bitrate.bitrate_kbps, 0);
// Full message → bitrate intact.
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
let w = Welcome {
abi_version: 2,
udp_port: 7000,
mode: h.mode,
fec: FecConfig {
scheme: FecScheme::Gf16,
fec_percent: 20,
max_data_per_block: 4096,
},
shard_payload: 1200,
encrypt: true,
key: [3u8; 16],
salt: [9, 8, 7, 6],
frames: 0,
compositor: CompositorPref::Kwin,
gamepad: GamepadPref::Xbox360,
bitrate_kbps: 120_000,
bit_depth: 10,
color: ColorInfo::HDR10_BT2020_PQ,
chroma_format: CHROMA_IDC_444,
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
codec: CODEC_HEVC,
host_caps: HOST_CAP_GAMEPAD_STATE,
};
let wenc = w.encode();
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
assert_eq!(legacy_w.bitrate_kbps, 0);
assert_eq!(legacy_w.frames, 0);
assert_eq!(legacy_w.key, w.key);
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
assert_eq!(legacy_w.bit_depth, 8);
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
assert_eq!(pre_color_w.bit_depth, 10);
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
assert_eq!(pre_audio_w.audio_channels, 2);
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
assert_eq!(
Welcome::decode(&wenc).unwrap().color,
ColorInfo::HDR10_BT2020_PQ
);
assert_eq!(
Welcome::decode(&wenc).unwrap().chroma_format,
CHROMA_IDC_444
); // full form carries 4:4:4
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
assert_eq!(
Welcome::decode(&wenc).unwrap().host_caps,
HOST_CAP_GAMEPAD_STATE
);
}
#[test]
fn hello_name_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: Some("Enrico's MacBook".into()),
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
let enc = base.encode();
assert_eq!(
Hello::decode(&enc).unwrap().name.as_deref(),
Some("Enrico's MacBook")
);
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
// host reading a bitrate-era Hello decodes name = None.
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
let unnamed = Hello {
name: None,
..base.clone()
};
assert_eq!(unnamed.encode().len(), 26);
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
let long = Hello {
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
..base.clone()
};
let dec = Hello::decode(&long.encode()).unwrap();
let n = dec.name.expect("truncated name still present");
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
let mut bad_len = unnamed.encode();
bad_len.push(40); // claims 40 name bytes, none follow
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
let mut bad_utf8 = unnamed.encode();
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
}
#[test]
fn hello_launch_roundtrip_and_back_compat() {
let base = Hello {
abi_version: 2,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
};
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello {
launch: Some("steam:570".into()),
..base.clone()
};
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
// launch + name together.
let both = Hello {
name: Some("Enrico's Mac".into()),
launch: Some("custom:abc123".into()),
..base.clone()
};
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
// name but no launch (a name-era client): launch decodes None.
let name_only = Hello {
name: Some("Enrico's Mac".into()),
..base.clone()
};
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
assert_eq!(base.encode().len(), 26);
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
assert_eq!(
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
None
);
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
let long = Hello {
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
..base.clone()
};
let dec = Hello::decode(&long.encode())
.unwrap()
.launch
.expect("present");
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
}
#[test]
fn reconfigure_roundtrip() {
let rq = Reconfigure {
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 144,
},
};
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
for accepted in [true, false] {
let rs = Reconfigured {
accepted,
mode: rq.mode,
};
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
}
// The type byte separates the post-handshake messages from each other.
assert!(Reconfigure::decode(
&Reconfigured {
accepted: true,
mode: rq.mode
}
.encode()
)
.is_err());
}
#[test]
fn request_keyframe_roundtrip() {
let bytes = RequestKeyframe.encode();
assert!(RequestKeyframe::decode(&bytes).is_ok());
// Distinct from the other control messages — its type byte must not collide.
let mode = Mode {
width: 1280,
height: 720,
refresh_hz: 60,
};
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
assert!(Reconfigure::decode(&bytes).is_err());
// Length is exact (no trailing bytes accepted).
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
}
#[test]
fn loss_report_roundtrip() {
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
let r = LossReport { loss_ppm };
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
}
// Disjoint from the other control messages (type byte + length).
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
assert!(
LossReport::decode(&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat())
.is_err()
);
}
#[test]
fn window_loss_ppm_estimates_and_caps() {
// No traffic → 0. A clean window (nothing recovered) → 0.
assert_eq!(window_loss_ppm(0, 0, 0), 0);
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
}
#[test]
fn bitrate_messages_roundtrip() {
let req = SetBitrate {
bitrate_kbps: 14_000,
};
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
let ack = BitrateChanged {
bitrate_kbps: 14_000,
};
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
assert!(LossReport::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&ack.encode()).is_err());
assert!(BitrateChanged::decode(&req.encode()).is_err());
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
}
#[test]
fn probe_messages_roundtrip() {
let req = ProbeRequest {
target_kbps: 250_000,
duration_ms: 2000,
};
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
let res = ProbeResult {
bytes_sent: 62_500_000,
packets_sent: 480,
duration_ms: 2003,
wire_packets_sent: 41_000,
send_dropped: 1_200,
};
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
assert_eq!(res.encode().len(), 29);
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
let legacy = {
let full = res.encode();
full[..21].to_vec()
};
let decoded = ProbeResult::decode(&legacy).unwrap();
assert_eq!(decoded.wire_packets_sent, 0);
assert_eq!(decoded.send_dropped, 0);
assert_eq!(decoded.bytes_sent, res.bytes_sent);
// Type bytes keep the control messages disjoint from each other.
assert!(ProbeRequest::decode(&res.encode()).is_err());
assert!(Reconfigure::decode(&req.encode()).is_err());
assert!(ProbeResult::decode(&req.encode()).is_err());
}
#[test]
fn clock_messages_roundtrip() {
let probe = ClockProbe {
t1_ns: 1_700_000_000_123,
};
assert_eq!(ClockProbe::decode(&probe.encode()).unwrap(), probe);
let echo = ClockEcho {
t1_ns: 1_700_000_000_123,
t2_ns: 1_700_000_050_456,
t3_ns: 1_700_000_050_789,
};
assert_eq!(ClockEcho::decode(&echo.encode()).unwrap(), echo);
// Disjoint from the other control messages (distinct type bytes).
assert!(ClockProbe::decode(&echo.encode()).is_err());
assert!(ProbeRequest::decode(&probe.encode()).is_err());
assert!(ClockEcho::decode(&probe.encode()).is_err());
}
#[test]
fn clock_offset_picks_min_rtt_and_recovers_offset() {
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
// present but must be ignored by the min-RTT selection.
const OFF: i64 = 1_000_000;
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
let t1 = 0u64;
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
let t3 = t2 + 50_000;
let t4 = (t3 as i64 - OFF + 200_000) as u64;
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
let n1 = 1_000_000u64;
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
let n3 = n2 + 50_000;
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
let (offset, rtt) = clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
assert_eq!(offset, OFF);
assert_eq!(rtt, 400_000);
assert!(clock_offset_ns(&[]).is_none());
}
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
#[test]
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
const OFF: i64 = 1_000_000;
let echo_for = |t1: u64, one_way: u64| ClockEcho {
t1_ns: t1,
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
};
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
let mut rs = ClockResync::new();
// An unsolicited echo before any batch is ignored.
assert_eq!(
rs.on_echo(&echo_for(42, 100_000), 500_000),
ResyncStep::Idle
);
let mut probe = rs.begin(1_000_000);
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
assert_eq!(
rs.on_echo(&echo_for(42, 100_000), 500_000),
ResyncStep::Idle
);
for round in 0..ClockResync::ROUNDS {
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p;
}
ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3t2) excluded
}
ResyncStep::Idle => panic!("matched echo must advance the batch"),
}
}
// The batch is done: even a matching-t1 replay no longer advances anything.
assert_eq!(
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
ResyncStep::Idle
);
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
let old = rs.begin(2_000_000);
let fresh = rs.begin(3_000_000);
assert_eq!(
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle
);
assert!(matches!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_)
));
}
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
/// replace the offset — its queueing delay biases the estimate exactly when frames
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
/// reject every later batch over normal jitter.
#[test]
fn clock_resync_acceptance_guard() {
// Generous connect RTT (10 ms): accept up to 1.5×.
assert!(accept_resync(14_000_000, 10_000_000));
assert!(!accept_resync(16_000_000, 10_000_000));
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
assert!(accept_resync(1_900_000, 200_000));
assert!(!accept_resync(2_100_000, 200_000));
// Boundary: exactly at the bound is accepted.
assert!(accept_resync(2_000_000, 0));
assert!(accept_resync(15_000_000, 10_000_000));
}
#[test]
fn control_messages_disjoint_from_hello() {
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
// any abi_version — can be misparsed as a control message, and vice-versa.
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
let h = Hello {
abi_version: abi,
mode: Mode {
width: 1280,
height: 720,
refresh_hz: 60,
},
compositor: CompositorPref::Auto,
gamepad: GamepadPref::Auto,
bitrate_kbps: 0,
name: None,
launch: None,
video_caps: 0,
audio_channels: 2,
video_codecs: 0,
preferred_codec: 0,
}
.encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
assert!(Reconfigure::decode(&h).is_err());
}
// And a PairRequest never parses as a Hello.
let pr = PairRequest {
name: "x".into(),
spake_a: vec![0u8; 33],
}
.encode();
assert!(Hello::decode(&pr).is_err());
}
#[test]
fn pair_messages_roundtrip() {
let pr = PairRequest {
name: "Enrico's Mac".into(),
spake_a: vec![1, 2, 3, 4, 5],
};
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
let pc = PairChallenge {
spake_b: vec![9; 33],
confirm: [7u8; 32],
};
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
let pp = PairProof { confirm: [3u8; 32] };
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
for ok in [true, false] {
assert_eq!(
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
ok
);
}
// Length-exact: a truncated/padded PairProof is rejected.
let mut bad = pp.encode();
bad.push(0);
assert!(PairProof::decode(&bad).is_err());
}
#[test]
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
let cfp = [0x11u8; 32];
let hfp = [0x22u8; 32];
// Right PIN, same fingerprint views on both sides → both confirmations agree.
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(!pake::verify(&a.client, &b.client));
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
let attacker_hfp = [0x33u8; 32];
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
let a = ca.finish(&mb).unwrap();
let b = cb.finish(&ma).unwrap();
assert!(!pake::verify(&a.client, &b.client));
}
#[test]
fn audio_datagram_roundtrip() {
let opus = [0x42u8; 97];
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
assert_eq!(d[0], AUDIO_MAGIC);
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
assert_eq!((seq, pts), (7, 1_000_000_123));
assert_eq!(payload, opus);
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
// Empty payload is legal (DTX) — header-only datagram.
let header_only = encode_audio_datagram(0, 0, &[]);
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
assert!(empty.is_empty());
}
#[test]
fn rumble_datagram_roundtrip() {
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
assert_eq!(d[0], RUMBLE_MAGIC);
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
assert!(decode_rumble_datagram(&d[..6]).is_none());
}
#[test]
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
let opus = [0x5Au8; 80];
let d = encode_mic_datagram(42, 9_999, &opus);
assert_eq!(d[0], MIC_MAGIC);
let (seq, pts, payload) = decode_mic_datagram(&d).unwrap();
assert_eq!((seq, pts), (42, 9_999));
assert_eq!(payload, opus);
assert!(decode_mic_datagram(&d[..12]).is_none()); // truncated
// Tag separation: a mic datagram is not an audio datagram and vice-versa.
assert!(decode_audio_datagram(&d).is_none());
assert!(decode_mic_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
// Empty payload (DTX) is legal.
assert!(decode_mic_datagram(&encode_mic_datagram(0, 0, &[]))
.unwrap()
.2
.is_empty());
}
#[test]
fn rich_input_roundtrip() {
for ev in [
RichInput::Touchpad {
pad: 1,
finger: 0,
active: true,
x: 40000,
y: 12345,
},
RichInput::Motion {
pad: 0,
gyro: [-100, 200, -300],
accel: [16384, -8192, 1],
},
RichInput::TouchpadEx {
pad: 2,
surface: 1,
finger: 1,
touch: true,
click: false,
x: -12345,
y: 30000,
pressure: 4000,
},
] {
let d = ev.encode();
assert_eq!(d[0], RICH_INPUT_MAGIC);
assert_eq!(RichInput::decode(&d), Some(ev));
}
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
// short
}
#[test]
fn hid_output_roundtrip() {
let cases = [
HidOutput::Led {
pad: 2,
r: 0xAA,
g: 0xBB,
b: 0xCC,
},
HidOutput::PlayerLeds {
pad: 0,
bits: 0b10101,
},
HidOutput::Trigger {
pad: 1,
which: 1,
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
},
HidOutput::TrackpadHaptic {
pad: 0,
side: 1,
amplitude: 0x1234,
period: 0x5678,
count: 9,
},
];
for ev in &cases {
let d = ev.encode();
assert_eq!(d[0], HIDOUT_MAGIC);
assert_eq!(HidOutput::decode(&d).as_ref(), Some(ev));
}
assert!(HidOutput::decode(&[HIDOUT_MAGIC, 0x7F]).is_none()); // unknown kind
// A rich-input datagram is not a HID-output datagram.
assert!(HidOutput::decode(
&RichInput::Motion {
pad: 0,
gyro: [0; 3],
accel: [0; 3]
}
.encode()
)
.is_none());
}
#[test]
fn fingerprint_is_sha256_of_der() {
// Stable across calls, distinct for distinct certs.
let a = endpoint::cert_fingerprint(b"cert-a");
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
}
+173 -21
View File
@@ -16,6 +16,7 @@ use crate::input::InputEvent;
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES}; use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
use crate::stats::{Stats, StatsCounters}; use crate::stats::{Stats, StatsCounters};
use crate::transport::Transport; use crate::transport::Transport;
use zerocopy::IntoBytes;
/// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder. /// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder.
pub struct Frame { pub struct Frame {
@@ -166,18 +167,57 @@ impl Session {
"seal_frame called on a client session", "seal_frame called on a client session",
)); ));
} }
let packets = self // Packetize straight into the pooled wire buffers (reused across frames via
.packetizer // `reclaim_wires`) and seal each in place: the plaintext `header ++ shard` is written
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?; // once, at its final wire offset — no intermediate per-packet Vec at all. Byte-identical
StatsCounters::add(&self.stats.frames_submitted, 1); // to the wrapper (`packetize` + seal) path: same plaintext, same emission order, and the
// Reuse the wire-buffer pool the caller returns via `reclaim_wires`: one buffer per packet, // nonce counter advances per emitted packet exactly as before (pinned by the
// sealed in place — after warmup there is no per-packet ciphertext/wire allocation. (`wires` // wire-equivalence tests below). Destructure into disjoint field borrows first — the
// is a local, so `seal_into`'s `&mut self` doesn't alias the `&mut` iteration over it.) // emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
let mut wires = std::mem::take(&mut self.wire_pool); let Session {
wires.resize_with(packets.len(), Vec::new); packetizer,
for (wire, pkt) in wires.iter_mut().zip(packets.iter()) { coder,
self.seal_into(pkt, wire)?; crypto,
next_seq,
wire_pool,
..
} = self;
let mut wires = std::mem::take(wire_pool);
let mut used = 0usize;
let result = packetizer.packetize_each(data, pts_ns, user_flags, coder.as_ref(), {
let wires = &mut wires;
let used = &mut used;
move |hdr, body| {
if *used == wires.len() {
wires.push(Vec::new());
} }
let wire = &mut wires[*used];
*used += 1;
let seq = *next_seq;
*next_seq = next_seq.wrapping_add(1);
wire.clear();
match crypto {
Some(c) => {
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
wire.extend_from_slice(&seq.to_be_bytes());
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
c.seal_in_place(seq, &mut wire[8..])?;
}
None => {
wire.extend_from_slice(hdr.as_bytes());
wire.extend_from_slice(body);
}
}
Ok(())
}
});
result?;
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
// as the previous `resize_with(packets.len(), ..)` did.
wires.truncate(used);
StatsCounters::add(&self.stats.frames_submitted, 1);
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum(); let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64); StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
StatsCounters::add(&self.stats.bytes_sent, bytes); StatsCounters::add(&self.stats.bytes_sent, bytes);
@@ -296,24 +336,42 @@ impl Session {
if len > MAX_DATAGRAM_BYTES { if len > MAX_DATAGRAM_BYTES {
continue; continue;
} }
let pkt = match self.open_from_wire(&self.recv_scratch[i][..len]) { // Open in place inside the ring buffer — no per-datagram allocation at line rate
Ok(p) => p, // (~125k pkt/s at 1 Gbps; the recv ring killed the recv alloc, this kills the decrypt
Err(_) => continue, // one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
// `recv_scratch` alive across the replay/reassembler calls below.
let (pkt_range, seq) = match &self.crypto {
Some(c) => {
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
if len < 8 + crate::crypto::TAG_LEN {
continue;
}
let seq = u64::from_be_bytes(self.recv_scratch[i][..8].try_into().unwrap());
match c.open_in_place(seq, &mut self.recv_scratch[i][8..len]) {
Ok(n) => (8..8 + n, Some(seq)),
Err(_) => continue, // undecryptable noise — drop, keep draining
}
}
None => (0..len, None),
}; };
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated // Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
// sequence was already seen. Video also dedups per-frame downstream, but filtering here // sequence was already seen. Video also dedups per-frame downstream, but filtering here
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded. // is uniform and cheap.
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) { if let (Some(w), Some(seq)) = (self.replay.as_mut(), seq) {
if !w.accept(seq) {
StatsCounters::add(&self.stats.packets_dropped, 1); StatsCounters::add(&self.stats.packets_dropped, 1);
continue; continue;
} }
}
let pkt = &self.recv_scratch[i][pkt_range];
StatsCounters::add(&self.stats.packets_received, 1); StatsCounters::add(&self.stats.packets_received, 1);
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64); StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
// The reassembler validates the packet via its parsed header (`magic`), // The reassembler validates the packet via its parsed header (`magic`),
// ignoring anything that isn't a well-formed video packet. // ignoring anything that isn't a well-formed video packet.
if let Some(frame) = self if let Some(frame) = self
.reassembler .reassembler
.push(&pkt, self.coder.as_ref(), &self.stats)? .push(pkt, self.coder.as_ref(), &self.stats)?
{ {
StatsCounters::add(&self.stats.frames_completed, 1); StatsCounters::add(&self.stats.frames_completed, 1);
return Ok(frame); return Ok(frame);
@@ -387,10 +445,14 @@ fn seq_of(wire: &[u8]) -> u64 {
} }
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per /// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the /// datagram, so this must cover the reassembler's 120 ms loss window
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded /// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
/// for the sparse input stream — while bounding how far back a replay could hide. /// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
const REPLAY_WINDOW: u64 = 4096; /// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
/// could hide; the bitmap costs 4 KiB per session.
const REPLAY_WINDOW: u64 = 32768;
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize; const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts /// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
@@ -469,6 +531,96 @@ impl ReplayWindow {
} }
} }
#[cfg(test)]
mod wire_equivalence_tests {
use super::*;
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
use crate::transport::loopback_pair;
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
Config {
role: Role::Host,
phase: match scheme {
FecScheme::Gf8 => ProtocolPhase::P1GameStream,
FecScheme::Gf16 => ProtocolPhase::P2Punktfunk,
},
fec: FecConfig {
scheme,
fec_percent,
max_data_per_block: 8,
},
shard_payload: 64,
max_frame_bytes: 8 * 1024 * 1024,
encrypt,
key: [7u8; 16],
salt: [3, 1, 4, 1],
loopback_drop_period: 0,
}
}
fn host_session(cfg: Config) -> Session {
let (h, _c) = loopback_pair(0, 0);
Session::new(cfg, Box::new(h)).unwrap()
}
/// The reference wire path: build owned packets via the `packetize` wrapper, then seal
/// each into its own buffer — the pre-zero-copy implementation of `seal_frame`, spelled
/// out with the session's own private pieces so the two paths share nothing but state.
fn seal_via_wrapper(sess: &mut Session, frame: &[u8], pts_ns: u64, flags: u32) -> Vec<Vec<u8>> {
let packets = sess
.packetizer
.packetize(frame, pts_ns, flags, sess.coder.as_ref())
.unwrap();
let mut wires = Vec::new();
for pkt in &packets {
let mut wire = Vec::new();
sess.seal_into(pkt, &mut wire).unwrap();
wires.push(wire);
}
wires
}
/// `seal_frame`'s packetize-straight-into-the-wire-pool path must produce byte-identical
/// sealed output to the wrapper path (same plaintext = header ++ shard, same nonce
/// sequence) — for multi-block frames, partial tail shards, exact-multiple frames, the
/// empty frame, fec 0%/50%, both schemes, crypto on and off (plan §1.4).
#[test]
fn zero_copy_seal_matches_wrapper_path() {
for scheme in [FecScheme::Gf8, FecScheme::Gf16] {
for fec_percent in [0u8, 50] {
for encrypt in [true, false] {
let mut opt = host_session(host_cfg(scheme, fec_percent, encrypt));
let mut refr = host_session(host_cfg(scheme, fec_percent, encrypt));
// shard_payload 64 × max_data_per_block 8: >512 bytes spans FEC blocks.
let frames: Vec<Vec<u8>> = vec![
pattern(3000), // multi-block + partial tail shard
pattern(1024), // exact multiple (2 full blocks)
pattern(100), // single block, partial tail
Vec::new(), // empty frame → 1 zeroed shard
pattern(64), // exactly one full shard
];
for (i, frame) in frames.iter().enumerate() {
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
let want = seal_via_wrapper(&mut refr, frame, 1000 * i as u64, i as u32);
assert_eq!(
got, want,
"wire mismatch: scheme={scheme:?} fec={fec_percent}% encrypt={encrypt} frame#{i}"
);
// Return the buffers so later frames exercise the pooled-reuse path
// (including a bigger frame after a smaller one and vice versa).
opt.reclaim_wires(got);
}
}
}
}
}
fn pattern(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 31 + 7) as u8).collect()
}
}
#[cfg(test)] #[cfg(test)]
mod replay_tests { mod replay_tests {
use super::*; use super::*;
+3 -1
View File
@@ -3,10 +3,12 @@
mod loopback; mod loopback;
mod qos; mod qos;
#[cfg(windows)]
mod qos_windows;
mod udp; mod udp;
pub use loopback::{loopback_pair, LoopbackTransport}; pub use loopback::{loopback_pair, LoopbackTransport};
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass}; pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass, QosFlow};
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected /// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
/// socket (the GameStream video sender) rather than going through [`UdpTransport`]. /// socket (the GameStream video sender) rather than going through [`UdpTransport`].
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+34 -10
View File
@@ -8,9 +8,10 @@
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it //! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It //! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client //! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on //! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the //! ISPs/routers. On Windows a plain `IP_TOS` is silently stripped from the wire, so the marking
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows). //! goes through qWAVE flows instead (see [`super::qos_windows`]) — the caller holds the returned
//! [`QosFlow`] guard for as long as the socket sends media.
use std::net::UdpSocket; use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -60,7 +61,7 @@ pub enum MediaClass {
impl MediaClass { impl MediaClass {
/// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte). /// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte).
const fn dscp(self) -> u32 { pub(super) const fn dscp(self) -> u32 {
match self { match self {
MediaClass::Video => 40, // CS5 MediaClass::Video => 40, // CS5
MediaClass::Audio => 48, // CS6 MediaClass::Audio => 48, // CS6
@@ -92,20 +93,43 @@ pub(crate) fn dscp_enabled() -> bool {
} }
} }
/// RAII token for a socket's QoS marking. On Windows it is the qWAVE flow membership
/// ([`super::qos_windows::QosFlow`]) — dropping it removes the marking, so hold it for as long
/// as the socket sends media. Elsewhere DSCP rides the socket option itself and the token is
/// inert (and never constructed — [`set_media_qos`] returns `None`).
#[cfg(windows)]
pub use super::qos_windows::QosFlow;
#[cfg(not(windows))]
pub struct QosFlow {
_never: std::convert::Infallible,
}
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op /// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS /// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
/// is a nicety, not required for correctness. /// is a nicety, not required for correctness.
/// ///
/// IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't tagged. On Windows /// The socket must already be `connect`ed (Windows derives the qWAVE flow from the connected
/// the `IP_TOS` set succeeds but the OS doesn't tag the wire without a qWAVE policy (follow-up). /// 5-tuple). IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) { /// tagged. Returns the [`QosFlow`] guard on Windows — keep it alive with the socket; `None`
if dscp_enabled() { /// elsewhere (the marking is a plain socket option) and whenever a step refused.
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
if !dscp_enabled() {
return None;
}
#[cfg(windows)]
{
super::qos_windows::add_media_flow(socket, class)
}
#[cfg(not(windows))]
{
apply_media_qos(socket, class); apply_media_qos(socket, class);
None
} }
} }
/// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable /// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable
/// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues). /// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues).
#[cfg_attr(windows, allow(dead_code))]
fn apply_media_qos(socket: &UdpSocket, class: MediaClass) { fn apply_media_qos(socket: &UdpSocket, class: MediaClass) {
let sock = socket2::SockRef::from(socket); let sock = socket2::SockRef::from(socket);
// DSCP occupies the high 6 bits of the TOS byte → shift left 2. // DSCP occupies the high 6 bits of the TOS byte → shift left 2.
@@ -143,8 +167,8 @@ mod tests {
fn qos_and_buffer_growth_are_best_effort_and_never_panic() { fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
let sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless. // No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
set_media_qos(&sock, MediaClass::Video); assert!(set_media_qos(&sock, MediaClass::Video).is_none());
set_media_qos(&sock, MediaClass::Audio); assert!(set_media_qos(&sock, MediaClass::Audio).is_none());
grow_socket_buffers(&sock); grow_socket_buffers(&sock);
} }
@@ -0,0 +1,135 @@
//! qWAVE (qos2.h) DSCP marking — the Windows path of [`super::qos::set_media_qos`].
//!
//! On Windows a plain `IP_TOS` setsockopt succeeds but the stack strips the mark from the wire:
//! marking requires membership in a qWAVE flow, which is how Apollo/Sunshine tag (qwave.dll).
//! [`QOSAddSocketToFlow`] with a traffic type yields the OS default marking (AudioVideo → DSCP
//! 40, Voice → 56, both WMM-mapped); the follow-up `QOSSetFlow(QOSSetOutgoingDSCPValue)` pins
//! the exact CS5/CS6 code points the other platforms mark. The pin needs an elevated process or
//! the "allow non-admin DSCP" group policy and silently keeps the traffic-type default
//! otherwise — the host runs as the SYSTEM service (`PunktfunkHost`), so the exact-DSCP path
//! applies exactly where it matters (the video egress); user-mode clients keep traffic-type
//! defaults (still WMM-useful).
//!
//! Same contract as the rest of [`super::qos`]: opt-in (`dscp_enabled`), and every step
//! debug-logs and continues — QoS is a nicety, never required for correctness.
use super::qos::MediaClass;
use std::net::UdpSocket;
use std::os::windows::io::AsRawSocket;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{GetLastError, HANDLE};
use windows_sys::Win32::NetworkManagement::QoS::{
QOSAddSocketToFlow, QOSCreateHandle, QOSRemoveSocketFromFlow, QOSSetFlow,
QOSSetOutgoingDSCPValue, QOSTrafficTypeAudioVideo, QOSTrafficTypeVoice, QOS_NON_ADAPTIVE_FLOW,
QOS_VERSION,
};
/// The process-wide qWAVE handle (`QOSCreateHandle` once, cached). `None` = qWAVE unavailable
/// (e.g. the QWAVE service is disabled) — every flow request then no-ops. Deliberately never
/// closed: it lives as long as the process, like the media sockets whose flows it carries.
fn qos_handle() -> Option<HANDLE> {
static SLOT: OnceLock<Option<usize>> = OnceLock::new();
SLOT.get_or_init(|| {
let version = QOS_VERSION {
MajorVersion: 1,
MinorVersion: 0,
};
let mut handle: HANDLE = std::ptr::null_mut();
// SAFETY: both pointers are valid for the duration of the synchronous call.
if unsafe { QOSCreateHandle(&version, &mut handle) } == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
"QOSCreateHandle failed — qWAVE DSCP marking unavailable"
);
None
} else {
Some(handle as usize)
}
})
.map(|h| h as HANDLE)
}
/// RAII qWAVE flow membership: while held, the socket's egress carries the flow's marking;
/// dropping removes the socket from the flow. (Closing the socket also removes it implicitly —
/// the guard makes teardown explicit and ordered, and must outlive the socket's traffic.)
pub struct QosFlow {
/// Raw `SOCKET` value only — never dereferenced; qWAVE tolerates an already-closed socket
/// on remove (the error is deliberately ignored).
socket: u64,
flow_id: u32,
}
impl Drop for QosFlow {
fn drop(&mut self) {
if let Some(handle) = qos_handle() {
// SAFETY: handle/flow_id came from the successful add; a stale socket just errors.
unsafe { QOSRemoveSocketFromFlow(handle, self.socket as _, self.flow_id, 0) };
}
}
}
/// Put a **connected** media socket on a qWAVE flow of its class (video →
/// `QOSTrafficTypeAudioVideo`, audio → `QOSTrafficTypeVoice`), then best-effort pin the exact
/// DSCP the other platforms mark (CS5 = 40 / CS6 = 48). Returns the flow guard, or `None` when
/// a required step refused (logged at debug).
pub(super) fn add_media_flow(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
let handle = qos_handle()?;
let traffic_type = match class {
MediaClass::Video => QOSTrafficTypeAudioVideo,
MediaClass::Audio => QOSTrafficTypeVoice,
};
let raw = socket.as_raw_socket();
let mut flow_id = 0u32;
// NULL destination = derive the flow's 5-tuple from the connected socket (every media
// socket is `connect`ed before it is tagged).
// SAFETY: the socket is live for the call; `flow_id` is a valid out-pointer.
let ok = unsafe {
QOSAddSocketToFlow(
handle,
raw as _,
std::ptr::null(),
traffic_type,
QOS_NON_ADAPTIVE_FLOW,
&mut flow_id,
)
};
if ok == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
?class,
"QOSAddSocketToFlow failed — DSCP marking skipped"
);
return None;
}
// Construct the guard FIRST so an early return below still removes the flow membership.
// (`raw` is already `u64` — std's `RawSocket` — so no cast; win64 clippy denies same-type casts.)
let flow = QosFlow {
socket: raw,
flow_id,
};
// Pin the exact code point. Succeeds for elevated processes or under the "allow non-admin
// DSCP" policy; otherwise the traffic-type default marking stands (40 / 56 — WMM-useful).
let dscp: u32 = class.dscp();
// SAFETY: `buffer` points at 4 valid bytes for the synchronous (no OVERLAPPED) call.
let ok = unsafe {
QOSSetFlow(
handle,
flow_id,
QOSSetOutgoingDSCPValue,
4,
&dscp as *const u32 as *const _,
0,
std::ptr::null_mut(),
)
};
if ok == 0 {
tracing::debug!(
err = unsafe { GetLastError() },
?class,
"QOSSetFlow(OutgoingDSCPValue) refused — traffic-type default marking stands"
);
} else {
tracing::debug!(?class, dscp, flow_id, "qWAVE flow pinned to exact DSCP");
}
Some(flow)
}
+18 -5
View File
@@ -446,6 +446,9 @@ pub fn spawn_data_punch(sock: UdpSocket, stop: std::sync::Arc<std::sync::atomic:
} }
pub struct UdpTransport { pub struct UdpTransport {
/// qWAVE flow guard (Windows, opt-in DSCP): declared before `socket` so drop order removes
/// the flow membership before the socket closes. Always `None` off-Windows.
_qos_flow: Option<super::qos::QosFlow>,
socket: UdpSocket, socket: UdpSocket,
} }
@@ -464,10 +467,14 @@ impl UdpTransport {
socket.connect(peer)?; socket.connect(peer)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
// The native data plane is video-dominant — tag it as the video class (opt-in via // The native data plane is video-dominant — tag it as the video class (opt-in via
// PUNKTFUNK_DSCP). Each end marks its own egress. // PUNKTFUNK_DSCP). Each end marks its own egress; the socket is connected by now, as
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video); // the Windows qWAVE flow requires.
let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
socket.set_nonblocking(true)?; socket.set_nonblocking(true)?;
Ok(UdpTransport { socket }) Ok(UdpTransport {
_qos_flow: qos_flow,
socket,
})
} }
/// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN /// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN
@@ -524,9 +531,15 @@ impl UdpTransport {
socket.connect(target.as_deref().unwrap_or(fallback_peer))?; socket.connect(target.as_deref().unwrap_or(fallback_peer))?;
socket.set_read_timeout(None)?; socket.set_read_timeout(None)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video); let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
socket.set_nonblocking(true)?; socket.set_nonblocking(true)?;
Ok((UdpTransport { socket }, punched)) Ok((
UdpTransport {
_qos_flow: qos_flow,
socket,
},
punched,
))
} }
/// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`]) /// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`])
+2 -1
View File
@@ -205,7 +205,8 @@ proptest! {
let data: Vec<Vec<u8>> = (0..k) let data: Vec<Vec<u8>> = (0..k)
.map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect()) .map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect())
.collect(); .collect();
let recovery = coder.encode(&data, m).unwrap(); let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
let recovery = coder.encode(&refs, m).unwrap();
let mut received: Vec<Option<Vec<u8>>> = let mut received: Vec<Option<Vec<u8>>> =
data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect(); data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect();
+20 -11
View File
@@ -62,10 +62,13 @@ pub struct OutputFormat {
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source). /// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
/// `false` = 8-bit SDR. /// `false` = 8-bit SDR.
pub hdr: bool, pub hdr: bool,
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB** /// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
/// (`Bgra` / `Rgb10a2`), NOT the subsampled `Nv12`/`P010` the Windows video-engine path produces by /// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
/// default — because 4:4:4 can only be recovered from a full-chroma source. NVENC then does the /// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
/// RGB→YUV444 CSC at encode (chroma_format_idc=3). `false` on every 4:2:0 session. /// on-glass (RTX 5070 Ti): ARGB + `chromaFormatIDC=3` yields TRUE 4:4:4 and the conversion
/// follows the configured VUI matrix (BT.709 limited since the VUI is always written). On
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
/// 4:2:0 session.
pub chroma_444: bool, pub chroma_444: bool,
} }
@@ -404,10 +407,11 @@ pub fn capture_virtual_output(
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's // Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display // swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and // is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the // selects the per-frame conversion (FP16 → P010 vs BGRA → NV12, or BGRA → AYUV for a
// keepalive (it owns the virtual display). There is NO fallback (DDA + the WGC relay were removed): // `want.chroma_444` SDR session). `IddPushCapturer` takes the keepalive (it owns the virtual
// if it can't open or the driver doesn't attach, the session fails cleanly and the client reconnects. // display). There is NO fallback (DDA + the WGC relay were removed): if it can't open or the
idd_push::IddPushCapturer::open(target, pref, want.hdr, keep) // driver doesn't attach, the session fails cleanly and the client reconnects.
idd_push::IddPushCapturer::open(target, pref, want.hdr, want.chroma_444, keep)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
} }
@@ -422,9 +426,14 @@ pub(crate) fn capturer_supports_444() -> bool {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444() -> bool {
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows // IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0. // VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4
false // (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
// display can't be known here (the virtual display's mode settles after the Welcome); that
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn capturer_supports_444() -> bool { pub(crate) fn capturer_supports_444() -> bool {
@@ -464,21 +464,25 @@ float main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
} }
"; ";
/// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV). Averages /// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV).
/// the 2x2 scRGB source footprint of this chroma sample (box filter) IN scRGB-linear space before the /// **Left-cosited** (H.273 chroma_loc type 0 — the default every decoder infers when
/// PQ encode, then forms Cb/Cr from the averaged-then-PQ-encoded RGB. `inv_src` = (1/srcW, 1/srcH). /// chroma_loc_info is unsignaled, and what the clients' sampling corrections assume): the chroma
/// sample sits ON the even luma column, vertically centered between its two rows — so the filter
/// is the 2-row average of that ONE column, IN scRGB-linear space before the PQ encode, then
/// Cb/Cr from the averaged-then-PQ-encoded RGB. (The old 2×2 box was CENTER-sited — a
/// half-luma-pixel chroma shift against what decoders reconstruct; the narrow column decimation
/// also keeps desktop text/edge chroma crisp, and block-uniform inputs stay exact for
/// `hdr_p010_selftest`.) `inv_src` = (1/srcW, 1/srcH).
const HDR_P010_UV_PS: &str = r" const HDR_P010_UV_PS: &str = r"
#include_common #include_common
cbuffer C : register(b0) { float2 inv_src; float2 pad; }; cbuffer C : register(b0) { float2 inv_src; float2 pad; };
float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET { float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
// `uv` is the chroma-sample centre in [0,1]; the 4 co-sited luma texels sit at uv ± half a luma // `uv` is the chroma RT texel centre = the middle of the 2x2 luma block; the left-cosited
// texel in each axis. Average their scRGB (linear) values, then run the SAME PQ/CSC as the Y pass. // target is the block's LEFT column, whose two texel centres sit at uv + (-h.x, ±h.y).
float2 h = inv_src * 0.5; float2 h = inv_src * 0.5;
float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0); float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0);
float3 b = max(tx.Sample(sm, uv + float2( h.x, -h.y)).rgb, 0.0); float3 b = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0);
float3 c = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0); float3 scrgb = (a + b) * 0.5;
float3 d = max(tx.Sample(sm, uv + float2( h.x, h.y)).rgb, 0.0);
float3 scrgb = (a + b + c + d) * 0.25;
float3 nits = scrgb * 80.0; float3 nits = scrgb * 80.0;
float3 lin2020 = mul(BT709_TO_BT2020, nits); float3 lin2020 = mul(BT709_TO_BT2020, nits);
float3 pq = pq_oetf(lin2020 / 10000.0); float3 pq = pq_oetf(lin2020 / 10000.0);
@@ -565,6 +565,81 @@ impl Drop for DescriptorPoller {
} }
} }
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
/// desktop was actively composing right beforehand (see [`StallWatch`]).
struct Stall {
/// How long the hole lasted (last fresh frame → the frame that ended it).
gap: Duration,
/// `Some(mean period)` when this stall completes a metronomic cycle (see
/// [`crate::metronome::Metronome`]).
metronomic: Option<Duration>,
}
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
/// path BELOW capture and only while no physical output is active).
///
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
/// below; the caller does the logging.
struct StallWatch {
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
recent: std::collections::VecDeque<Instant>,
cadence: crate::metronome::Metronome,
}
impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
/// reported 300700 ms freezes, above encode/present jitter.
const STALL_MIN: Duration = Duration::from_millis(150);
fn new() -> Self {
Self {
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
cadence: crate::metronome::Metronome::new(),
}
}
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
/// the reset the first post-recreate frame would read as one).
fn reset(&mut self) {
self.recent.clear();
}
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
let was_active = self.recent.len() == Self::RECENT
&& self
.recent
.back()
.zip(self.recent.front())
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
let gap = self.recent.back().map(|last| now.duration_since(*last));
self.recent.push_back(now);
if self.recent.len() > Self::RECENT {
self.recent.pop_front();
}
let gap = gap?;
if !was_active || gap < Self::STALL_MIN {
return None;
}
Some(Stall {
gap,
metronomic: self.cadence.note(now),
})
}
}
pub struct IddPushCapturer { pub struct IddPushCapturer {
device: ID3D11Device, device: ID3D11Device,
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
@@ -594,6 +669,13 @@ pub struct IddPushCapturer {
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion. /// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]). /// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
display_hdr: bool, display_hdr: bool,
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
/// TRUE 4:4:4 and the conversion follows the VUI matrix (BT.709 limited, always written).
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
want_444: bool,
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads /// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
/// its snapshot instead of running CCD queries inline on the frame path. /// its snapshot instead of running CCD queries inline on the frame path.
desc_poller: DescriptorPoller, desc_poller: DescriptorPoller,
@@ -615,6 +697,10 @@ pub struct IddPushCapturer {
last_liveness: Instant, last_liveness: Instant,
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only). /// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
last_kick: Instant, last_kick: Instant,
/// Capture-stall watch (see [`StallWatch`]): flags multi-hundred-ms DWM composition holes
/// during active flow and warns when they turn metronomic — the sole-virtual-display
/// periodic-stutter diagnostic.
stall_watch: StallWatch,
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame /// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the /// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`: /// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
@@ -745,9 +831,10 @@ impl IddPushCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool,
keepalive: Box<dyn Send>, keepalive: Box<dyn Send>,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> { ) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
match Self::open_inner(target, preferred, client_10bit) { match Self::open_inner(target, preferred, client_10bit, want_444) {
Ok(mut me) => { Ok(mut me) => {
me._keepalive = keepalive; me._keepalive = keepalive;
Ok(me) Ok(me)
@@ -760,6 +847,7 @@ impl IddPushCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool,
) -> Result<Self> { ) -> Result<Self> {
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor // selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
@@ -774,7 +862,7 @@ impl IddPushCapturer {
LowPart: (target.adapter_luid & 0xffff_ffff) as u32, LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
HighPart: (target.adapter_luid >> 32) as i32, HighPart: (target.adapter_luid >> 32) as i32,
}); });
match Self::open_on(target.clone(), preferred, client_10bit, luid) { match Self::open_on(target.clone(), preferred, client_10bit, want_444, luid) {
Ok(me) => Ok(me), Ok(me) => Ok(me),
Err(e) => { Err(e) => {
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the // Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
@@ -799,7 +887,7 @@ impl IddPushCapturer {
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \ "IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
driver's reported adapter" driver's reported adapter"
); );
Self::open_on(target, preferred, client_10bit, drv) Self::open_on(target, preferred, client_10bit, want_444, drv)
.context("IDD-push rebind to the driver's reported render adapter") .context("IDD-push rebind to the driver's reported render adapter")
} }
} }
@@ -809,6 +897,7 @@ impl IddPushCapturer {
target: WinCaptureTarget, target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>, preferred: Option<(u32, u32, u32)>,
client_10bit: bool, client_10bit: bool,
want_444: bool,
luid: LUID, luid: LUID,
) -> Result<Self> { ) -> Result<Self> {
let (pw, ph, _hz) = preferred let (pw, ph, _hz) = preferred
@@ -963,6 +1052,7 @@ impl IddPushCapturer {
mode = format!("{w}x{h}"), mode = format!("{w}x{h}"),
display_hdr, display_hdr,
client_10bit, client_10bit,
want_444,
ring_fp16 = display_hdr, ring_fp16 = display_hdr,
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \ "IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
to attach + publish" to attach + publish"
@@ -981,6 +1071,7 @@ impl IddPushCapturer {
generation, generation,
client_10bit, client_10bit,
display_hdr, display_hdr,
want_444,
desc_poller: DescriptorPoller::spawn( desc_poller: DescriptorPoller::spawn(
target.target_id, target.target_id,
DisplayDescriptor { DisplayDescriptor {
@@ -995,6 +1086,7 @@ impl IddPushCapturer {
last_fresh: Instant::now(), last_fresh: Instant::now(),
last_liveness: Instant::now(), last_liveness: Instant::now(),
last_kick: Instant::now(), last_kick: Instant::now(),
stall_watch: StallWatch::new(),
out_ring: Vec::new(), out_ring: Vec::new(),
out_idx: 0, out_idx: 0,
video_conv: None, video_conv: None,
@@ -1139,15 +1231,24 @@ impl IddPushCapturer {
} }
} }
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR /// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client /// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so /// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
/// NVENC skips its internal RGB→YUV CSC on the contended SM (plan §5.A). We do NOT gate HDR on the /// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit /// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// only when its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path. /// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) { fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
if self.display_hdr { if self.display_hdr {
if self.want_444 {
warn_444_hdr_downgrade_once();
}
(DXGI_FORMAT_P010, PixelFormat::P010) (DXGI_FORMAT_P010, PixelFormat::P010)
} else if self.want_444 {
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
} else { } else {
(DXGI_FORMAT_NV12, PixelFormat::Nv12) (DXGI_FORMAT_NV12, PixelFormat::Nv12)
} }
@@ -1317,6 +1418,7 @@ impl IddPushCapturer {
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an /// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM. /// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
fn ensure_converter(&mut self) -> Result<()> { fn ensure_converter(&mut self) -> Result<()> {
if self.display_hdr { if self.display_hdr {
if self.hdr_p010_conv.is_none() { if self.hdr_p010_conv.is_none() {
@@ -1325,6 +1427,8 @@ impl IddPushCapturer {
// belong to, and `?` propagates any failure before the converter is stored. // belong to, and `?` propagates any failure before the converter is stored.
self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? }); self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? });
} }
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() { } else if self.video_conv.is_none() {
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live // SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus // borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
@@ -1429,6 +1533,11 @@ impl IddPushCapturer {
self.height, self.height,
)?; )?;
} }
} else if self.want_444 {
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
// copy-engine move; the slot releases back to the driver immediately.
self.context.CopyResource(&out, &s.tex);
} else { } else {
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC. // SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
if let Some(conv) = self.video_conv.as_ref() { if let Some(conv) = self.video_conv.as_ref() {
@@ -1441,8 +1550,34 @@ impl IddPushCapturer {
self.out_idx = (i + 1) % self.out_ring.len(); self.out_idx = (i + 1) % self.out_ring.len();
self.last_seq = seq; self.last_seq = seq;
self.last_present = Some((out.clone(), pf)); self.last_present = Some((out.clone(), pf));
self.recovering_since = None; // a fresh frame resumed → recovered let now = Instant::now();
self.last_fresh = Instant::now(); // feeds the driver-death watch if self.recovering_since.take().is_some() {
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
// recreate, already logged by the recreate path) — reset the stall watch so it
// doesn't read as a DWM stall.
self.stall_watch.reset();
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
// debug (not warn): a single hole also happens when content legitimately pauses;
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
// at debug level, and the web-console debug ring captures these.
tracing::debug!(
gap_ms = stall.gap.as_millis() as u64,
"IDD-push capture stall — the desktop was composing at speed, then DWM \
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
tracing::warn!(
period_s = format!("{:.2}", period.as_secs_f64()),
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
on a stable period, i.e. a periodic display-path disturbance BELOW \
capture (DWM present clock / GPU driver / display-poller software). \
Correlate with 'slow display-descriptor poll'; if that never fires, the \
disturbance is outside punktfunk try display topology=primary or \
extend (keep a physical output active), or a different refresh rate"
);
}
}
self.last_fresh = now; // feeds the driver-death watch
Ok(Some(CapturedFrame { Ok(Some(CapturedFrame {
width: self.width, width: self.width,
height: self.height, height: self.height,
@@ -1566,6 +1701,21 @@ impl Capturer for IddPushCapturer {
} }
} }
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
/// desktop needs the PQ tone curve, which the P010 shader provides at 4:2:0), so the stream
/// honestly downgrades — the encoder's `chroma_444` caps cross-check reports it and the in-band
/// SPS keeps the client decoding correctly. Once per process: the state can flap mid-session.
fn warn_444_hdr_downgrade_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::warn!(
"4:4:4 negotiated but the display is HDR — no 10-bit full-chroma source exists; \
encoding HDR 4:2:0 (P010) instead (disable HDR on the virtual display for 4:4:4)"
);
}
}
impl Drop for IddPushCapturer { impl Drop for IddPushCapturer {
fn drop(&mut self) { fn drop(&mut self) {
self.slots.clear(); self.slots.clear();
@@ -1576,3 +1726,99 @@ impl Drop for IddPushCapturer {
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display. // `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
/// return what each `note_fresh` produced.
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
let base = Instant::now();
let mut w = StallWatch::new();
offsets_ms
.iter()
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
.collect()
}
/// 60 fps flow (16 ms cadence) for `frames` frames starting at `start_ms`, appended to `out`.
fn flow(out: &mut Vec<u64>, start_ms: u64, frames: u64) {
out.extend((0..frames).map(|i| start_ms + i * 16));
}
#[test]
fn stall_detected_after_active_flow() {
// 20 frames of 60 fps flow, then a 300 ms hole — the resuming frame reads as a stall.
let mut t = Vec::new();
flow(&mut t, 0, 20); // last frame at 304 ms
t.push(604);
let out = watch_run(&t);
assert!(out[..20].iter().all(Option::is_none));
let stall = out[20].as_ref().expect("hole after active flow is a stall");
assert_eq!(stall.gap.as_millis(), 300);
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
}
#[test]
fn idle_desktop_gaps_are_not_stalls() {
// Caret-blink damage: frames ~530 ms apart — the activity gate never opens, so neither
// the blink gaps nor a long idle hole count.
let t: Vec<u64> = (0..12).map(|i| i * 530).chain([20_000]).collect();
assert!(watch_run(&t).iter().all(Option::is_none));
}
#[test]
fn thirty_fps_content_still_qualifies_as_active() {
// A 30 fps-capped game (33 ms cadence): 8 pre-gap frames span 231 ms ≤ ACTIVE_SPAN, so a
// 200 ms hole still reads as a stall.
let mut t: Vec<u64> = (0..10).map(|i| i * 33).collect(); // last at 297 ms
t.push(497);
let out = watch_run(&t);
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
}
#[test]
fn metronomic_stalls_self_diagnose() {
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
// cycle BOUNDARIES (5 cycles → 4 stalls); the 4th completes the metronome streak and
// reports the ~4 s period.
let mut t = Vec::new();
for cycle in 0..5u64 {
// ~3.7 s of flow, then the hole to the next cycle start.
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
}
let out = watch_run(&t);
let stalls: Vec<&Stall> = out.iter().flatten().collect();
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
let period = stalls[3]
.metronomic
.expect("the 4th evenly-spaced event completes the metronome streak");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.3,
"period={period:?}"
);
}
#[test]
fn reset_swallows_the_recreate_gap() {
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
// frame must NOT read as a stall, and detection re-arms afterwards.
let base = Instant::now();
let at = |ms: u64| base + Duration::from_millis(ms);
let mut w = StallWatch::new();
for i in 0..20u64 {
assert!(w.note_fresh(at(i * 16)).is_none());
}
w.reset();
assert!(w.note_fresh(at(1_104)).is_none(), "recreate gap swallowed");
for i in 1..20u64 {
assert!(w.note_fresh(at(1_104 + i * 16)).is_none());
}
assert!(
w.note_fresh(at(1_104 + 19 * 16 + 300)).is_some(),
"detection re-armed after the reset"
);
}
}
+22 -8
View File
@@ -326,11 +326,19 @@ impl NvencEncoder {
}; };
} }
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 *limited* range // NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
// (swscale), so signal that in the bitstream VUI (colorspace/range/primaries/transfer) — // signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
// otherwise the client decoder assumes a default and the picture comes out washed-out / // client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
// wrong-contrast. The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes // The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
// its own VUI). Matches the Windows NV12 path's BT.709 limited-range signalling. // Matches the Windows NV12 path's BT.709 limited-range signalling.
//
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
// recovers the ~12% of code space limited-range quantization gives up, for the exact
// text/UI chroma 4:4:4 exists for. Every punktfunk client honors the signaled range
// (csc_rows / the Apple rows port); ship as default only if the on-glass A/B shows a
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
let full_range_444 =
want_444 && std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
if matches!(format, PixelFormat::Nv12) || want_444 { if matches!(format, PixelFormat::Nv12) || want_444 {
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly- // SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum // aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
@@ -339,7 +347,11 @@ impl NvencEncoder {
unsafe { unsafe {
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; // limited/studio (*raw).color_range = if full_range_444 {
ffi::AVColorRange::AVCOL_RANGE_JPEG // full
} else {
ffi::AVColorRange::AVCOL_RANGE_MPEG // limited/studio
};
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709; (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709; (*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
} }
@@ -401,10 +413,12 @@ impl NvencEncoder {
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709 // SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static, // coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar // reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
// CSC settings into `sws` (limited-range dst: dstRange = 0). No Rust memory is passed. // CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
// PUNKTFUNK_444_FULLRANGE experiment). No Rust memory is passed.
unsafe { unsafe {
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709); let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16); let dst_range = i32::from(full_range_444);
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, dst_range, 0, 1 << 16, 1 << 16);
} }
Some(sws) Some(sws)
} else { } else {
@@ -204,8 +204,9 @@ unsafe fn open_vaapi_encoder_mode(
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).rc_buffer_size = vbv_bits as i32; (*raw).rc_buffer_size = vbv_bits as i32;
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
// We hand the encoder BT.709 *limited* NV12 (swscale CSC, or scale_vaapi which preserves the // We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
// input range we tag), so signal that VUI — else the client decoder washes the picture out. // to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; (*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709; (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
@@ -718,6 +719,11 @@ impl DmabufInner {
(*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int; (*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*par).width = width as c_int; (*par).width = width as c_int;
(*par).height = height as c_int; (*par).height = height as c_int;
// Declare the link's colour up front (full-range RGB — the compositor's desktop) so
// the per-frame tags in `submit` match the negotiated link instead of reading as a
// mid-stream property change.
(*par).color_space = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*par).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*par).time_base = ffi::AVRational { (*par).time_base = ffi::AVRational {
num: 1, num: 1,
den: fps as c_int, den: fps as c_int,
@@ -751,7 +757,14 @@ impl DmabufInner {
} }
init!(src, ptr::null(), "buffer"); init!(src, ptr::null(), "buffer");
init!(hwmap, c"mode=read".as_ptr(), "hwmap"); init!(hwmap, c"mode=read".as_ptr(), "hwmap");
init!(scale, c"format=nv12".as_ptr(), "scale_vaapi"); // Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited).
// Without the explicit options the conversion matrix is whatever the driver defaults
// to for an unspecified output (Mesa: BT.601) — a hue shift against the signaled VUI.
init!(
scale,
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
"scale_vaapi"
);
init!(sink, ptr::null(), "buffersink"); init!(sink, ptr::null(), "buffersink");
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int { let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
@@ -879,6 +892,12 @@ impl DmabufInner {
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int; (*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm).width = self.width as c_int; (*drm).width = self.width as c_int;
(*drm).height = self.height as c_int; (*drm).height = self.height as c_int;
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames); (*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
(*drm).data[0] = Box::into_raw(desc) as *mut u8; (*drm).data[0] = Box::into_raw(desc) as *mut u8;
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame, // Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
+118 -44
View File
@@ -1,7 +1,13 @@
//! Software H.264 encoder (openh264) — the GPU-less encode path for the Windows host (and a //! Software H.264 encoder (openh264) — the GPU-less encode path for the Windows host (and a
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference, //! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range. //! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR.
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue). //! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
//!
//! The RGB→YUV conversion is OURS, BT.709 limited range: openh264 writes no colour description
//! into the VUI (unspecified), so decoders fall back to their default — BT.709 limited on every
//! punktfunk client — and the pixels must match that default. The crate's own `YUVBuffer`
//! converter is BT.601 (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue
//! error; that's why it is NOT used here.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -12,19 +18,20 @@ use openh264::encoder::{
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod, BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
Profile, RateControlMode, SpsPpsStrategy, UsageType, Profile, RateControlMode, SpsPpsStrategy, UsageType,
}; };
use openh264::formats::{BgraSliceU8, RgbSliceU8, YUVBuffer}; use openh264::formats::YUVSlices;
use openh264::OpenH264API; use openh264::OpenH264API;
pub struct OpenH264Encoder { pub struct OpenH264Encoder {
enc: Oh264, enc: Oh264,
yuv: YUVBuffer,
width: u32, width: u32,
height: u32, height: u32,
fps: u32, fps: u32,
src_format: PixelFormat, src_format: PixelFormat,
/// BGRA scratch for the 3-bpp (Bgr) and R/B-swapped (Rgba/Rgbx) formats openh264 can't wrap /// The converted I420 planes (our BT.709-limited CSC — see the module doc), reused across
/// directly. Reused across frames. /// frames: full-res luma + quarter-res Cb/Cr, tightly packed (stride = width, width/2).
scratch: Vec<u8>, y_plane: Vec<u8>,
u_plane: Vec<u8>,
v_plane: Vec<u8>,
frame_idx: i64, frame_idx: i64,
force_kf: bool, force_kf: bool,
/// At most one AU per submit (no lookahead), handed back by the next `poll`. /// At most one AU per submit (no lookahead), handed back by the next `poll`.
@@ -33,7 +40,7 @@ pub struct OpenH264Encoder {
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread. // openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the // SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars, // openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (the plane `Vec`s, scalars,
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls // `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by // `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
// reference across threads, so the C handle is only ever touched from a single thread. Moving the // reference across threads, so the C handle is only ever touched from a single thread. Moving the
@@ -62,50 +69,74 @@ impl OpenH264Encoder {
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze) .scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
.adaptive_quantization(true) .adaptive_quantization(true)
.complexity(Complexity::Low) // latency over BD-rate .complexity(Complexity::Low) // latency over BD-rate
.profile(Profile::Baseline); // no B-frames; BT.709 limited is the crate default VUI .profile(Profile::Baseline); // no B-frames; the VUI carries no colour description
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature) let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?; let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
let yuv = YUVBuffer::new(width as usize, height as usize); let (w, h) = (width as usize, height as usize);
tracing::info!( tracing::info!(
"openh264 software encoder: {width}x{height}@{fps} {} Mbps (Baseline, screen-content)", "openh264 software encoder: {width}x{height}@{fps} {} Mbps (Baseline, screen-content)",
bps / 1_000_000 bps / 1_000_000
); );
Ok(Self { Ok(Self {
enc, enc,
yuv,
width, width,
height, height,
fps, fps,
src_format: format, src_format: format,
scratch: Vec::new(), y_plane: vec![0; w * h],
u_plane: vec![0; (w / 2) * (h / 2)],
v_plane: vec![0; (w / 2) * (h / 2)],
frame_idx: 0, frame_idx: 0,
force_kf: false, force_kf: false,
pending: None, pending: None,
}) })
} }
/// Normalize a packed source buffer into the reused BGRA `scratch` ([B,G,R,A]). `rgb_order` /// Convert one packed full-range RGB frame into the I420 planes, BT.709 limited range.
/// = source is R,G,B (swap into B,G,R); otherwise source is already B,G,R. /// `bpp` is the source pixel stride; `ri`/`gi`/`bi` the channel byte offsets within a pixel.
fn normalize_to_bgra(&mut self, src: &[u8], src_bpp: usize, rgb_order: bool) { /// Luma per pixel; Cb/Cr from the 2×2 block's averaged RGB (the same box filter the crate's
/// converter used, so only the matrix changed).
fn convert_bt709(&mut self, src: &[u8], bpp: usize, ri: usize, gi: usize, bi: usize) {
let w = self.width as usize; let w = self.width as usize;
let h = self.height as usize; let h = self.height as usize;
self.scratch.resize(w * h * 4, 0); let cw = w / 2;
for px in 0..(w * h) { for by in 0..h / 2 {
let s = &src[px * src_bpp..px * src_bpp + 3]; for bx in 0..cw {
let d = &mut self.scratch[px * 4..px * 4 + 4]; let mut sum = (0f32, 0f32, 0f32);
if rgb_order { for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
d[0] = s[2]; let (px, py) = (bx * 2 + dx, by * 2 + dy);
d[1] = s[1]; let s = &src[(py * w + px) * bpp..];
d[2] = s[0]; let (r, g, b) = (f32::from(s[ri]), f32::from(s[gi]), f32::from(s[bi]));
} else { self.y_plane[py * w + px] = luma709(r, g, b);
d[0] = s[0]; sum = (sum.0 + r, sum.1 + g, sum.2 + b);
d[1] = s[1];
d[2] = s[2];
} }
d[3] = 0xff; let (cb, cr) = chroma709(sum.0 / 4.0, sum.1 / 4.0, sum.2 / 4.0);
self.u_plane[by * cw + bx] = cb;
self.v_plane[by * cw + bx] = cr;
} }
} }
} }
}
/// BT.709 luma coefficients (Kg = 1 Kr Kb).
const KR: f32 = 0.2126;
const KB: f32 = 0.0722;
const KG: f32 = 1.0 - KR - KB;
/// One full-range RGB pixel (0..=255 channels) → the BT.709 limited-range 8-bit luma code
/// (16..=235). Kept in lockstep with the client-side inverse (`pf-client-core::video::csc_rows`).
fn luma709(r: f32, g: f32, b: f32) -> u8 {
let y = KR * r + KG * g + KB * b; // full-scale luma, 0..=255
(16.0 + y * (219.0 / 255.0) + 0.5) as u8 // `as` saturates — no manual clamp needed
}
/// (Averaged) full-range RGB → the BT.709 limited-range Cb/Cr codes (16..=240, neutral 128).
fn chroma709(r: f32, g: f32, b: f32) -> (u8, u8) {
let y = KR * r + KG * g + KB * b;
let cb = 128.0 + (b - y) * (224.0 / 255.0) / (2.0 * (1.0 - KB));
let cr = 128.0 + (r - y) * (224.0 / 255.0) / (2.0 * (1.0 - KR));
((cb + 0.5) as u8, (cr + 0.5) as u8)
}
impl Encoder for OpenH264Encoder { impl Encoder for OpenH264Encoder {
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> { fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
@@ -139,21 +170,13 @@ impl Encoder for OpenH264Encoder {
self.src_format self.src_format
); );
match self.src_format { // Source pixel stride + R/G/B byte offsets within a pixel — one converter for every
PixelFormat::Rgb => self // packed-RGB layout the capturers emit (no BGRA normalization pass needed).
.yuv let (bpp, ri, gi, bi) = match self.src_format {
.read_rgb(RgbSliceU8::new(&bytes[..w * h * 3], (w, h))), PixelFormat::Rgb => (3, 0, 1, 2),
PixelFormat::Bgra | PixelFormat::Bgrx => self PixelFormat::Bgr => (3, 2, 1, 0),
.yuv PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
.read_rgb(BgraSliceU8::new(&bytes[..w * h * 4], (w, h))), PixelFormat::Bgra | PixelFormat::Bgrx => (4, 2, 1, 0),
PixelFormat::Rgba | PixelFormat::Rgbx => {
self.normalize_to_bgra(bytes, 4, true);
self.yuv.read_rgb(BgraSliceU8::new(&self.scratch, (w, h)));
}
PixelFormat::Bgr => {
self.normalize_to_bgra(bytes, 3, false);
self.yuv.read_rgb(BgraSliceU8::new(&self.scratch, (w, h)));
}
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder // 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC). // can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
PixelFormat::Rgb10a2 => { PixelFormat::Rgb10a2 => {
@@ -166,13 +189,19 @@ impl Encoder for OpenH264Encoder {
"software encoder cannot encode YUV GPU textures (NV12/P010 → NVENC only)" "software encoder cannot encode YUV GPU textures (NV12/P010 → NVENC only)"
) )
} }
} };
self.convert_bt709(bytes, bpp, ri, gi, bi);
if self.force_kf { if self.force_kf {
self.enc.force_intra_frame(); self.enc.force_intra_frame();
self.force_kf = false; self.force_kf = false;
} }
let bs = self.enc.encode(&self.yuv).context("openh264 encode")?; let slices = YUVSlices::new(
(&self.y_plane, &self.u_plane, &self.v_plane),
(w, h),
(w, w / 2, w / 2),
);
let bs = self.enc.encode(&slices).context("openh264 encode")?;
let mut data = Vec::new(); let mut data = Vec::new();
bs.write_vec(&mut data); // AnnexB start codes; SPS/PPS prepended on IDR bs.write_vec(&mut data); // AnnexB start codes; SPS/PPS prepended on IDR
if !data.is_empty() { if !data.is_empty() {
@@ -225,6 +254,51 @@ mod tests {
use super::*; use super::*;
use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
/// The BT.709 limited-range anchor points: reference white → (235,128,128), black →
/// (16,128,128), pure red's Cr must hit the positive extreme 240 (it does exactly:
/// 255(1Kr)·(224/255)/(2(1Kr)) = 112). ±1 code for float rounding.
#[test]
fn bt709_conversion_anchor_points() {
assert_eq!(luma709(255.0, 255.0, 255.0), 235);
assert_eq!(luma709(0.0, 0.0, 0.0), 16);
assert_eq!(chroma709(255.0, 255.0, 255.0), (128, 128));
assert_eq!(chroma709(0.0, 0.0, 0.0), (128, 128));
let (cb, cr) = chroma709(255.0, 0.0, 0.0);
assert_eq!(cr, 240, "pure red must reach the Cr extreme");
assert!((101..=103).contains(&cb), "red Cb ~102, got {cb}");
let (cb, _) = chroma709(0.0, 0.0, 255.0);
assert_eq!(cb, 240, "pure blue must reach the Cb extreme");
}
/// The 601-vs-709 luma split on pure green (Kg 0.587 vs 0.7152) — guards against anyone
/// "simplifying" the coefficients back to the crate's BT.601 converter (the hue-shift bug
/// this module's own conversion exists to prevent).
#[test]
fn bt709_is_not_bt601() {
// BT.601 green luma: 16 + 219·0.587 = 144.5; BT.709: 16 + 219·0.7152 = 172.6.
let y = luma709(0.0, 255.0, 0.0);
assert!((172..=174).contains(&y), "709 green luma ~173, got {y}");
}
/// A flat gray frame converts to neutral chroma and mid luma across every plane byte
/// (exercises the block loop + plane sizing, not just the per-pixel math).
#[test]
fn converts_flat_gray_to_neutral_planes() {
let (w, h) = (16u32, 8u32);
let mut enc =
OpenH264Encoder::open(PixelFormat::Bgrx, w, h, 60, 1_000_000).expect("open openh264");
let bytes = vec![0x80u8; (w * h * 4) as usize];
enc.convert_bt709(&bytes, 4, 2, 1, 0);
// 16 + 128·(219/255) = 125.9 → 126.
assert!(
enc.y_plane.iter().all(|&y| y == 126),
"{:?}",
&enc.y_plane[..4]
);
assert!(enc.u_plane.iter().all(|&u| u == 128));
assert!(enc.v_plane.iter().all(|&v| v == 128));
}
#[test] #[test]
fn encodes_synthetic_frame_to_annexb_idr() { fn encodes_synthetic_frame_to_annexb_idr() {
let (w, h, fps) = (1280u32, 720u32, 60u32); let (w, h, fps) = (1280u32, 720u32, 60u32);
+210 -15
View File
@@ -708,11 +708,13 @@ impl NvencD3d11Encoder {
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is // input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
// forced to RGB for a 4:4:4 session, and we guard on the input format here too). // forced to RGB for a 4:4:4 session, and we guard on the input format here too).
// //
// ON-GLASS TODO (RTX box): confirm ARGB + chromaFormatIDC=3 + FREXT yields a *true* 4:4:4 // ON-GLASS MEASURED (RTX 5070 Ti, driver 610.43, 2026-07-10 — `nvenc_444_on_glass_probe`
// stream. NVENC's RGB→YUV CSC is documented to honor chromaFormatIDC (unlike libavcodec's // below + colour-bar analysis): ARGB + chromaFormatIDC=3 + FREXT yields a TRUE 4:4:4
// wrapper, which always subsamples RGB to 4:2:0 — hence the Linux path feeds planar YUV444 // stream (1-px chroma stripes survive, adjacent-column |dU| ≈ 138), and NVENC's internal
// instead). If on-glass shows 4:2:0, the follow-up is a BGRA→AYUV shader feeding the native // RGB→YUV conversion FOLLOWS THE CONFIGURED VUI MATRIX (bars match BT.709 within ±1 code
// `NV_ENC_BUFFER_FORMAT_AYUV` 4:4:4 input format. // with our 709 VUI; the same driver produces exact BT.601 when libavcodec's nvenc wrapper
// sets its BT470BG VUI on Linux). The always-written SDR VUI above therefore makes the
// pixels and the signaling agree by construction — no AYUV shader needed.
let rgb_input = matches!( let rgb_input = matches!(
self.buffer_fmt, self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
@@ -752,21 +754,33 @@ impl NvencD3d11Encoder {
} }
} }
// HDR colour signaling: BT.2020 primaries + SMPTE ST.2084 (PQ) transfer + BT.2020-NCL // Colour signaling, written UNCONDITIONALLY (was HDR-only): the capturer hands NVENC
// matrix, limited (studio) range — NVENC's RGB→YUV default. HEVC/H.264 carry it in the VUI; // pre-converted NV12 (BT.709 limited, the IDD VideoConverter) or P010 (BT.2020 PQ
// AV1 has NO VUI, so the SAME CICP code points go in the sequence-header colour config // limited, the FP16→P010 shader), so the stream must SAY so — an SDR stream with no
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`). Without // colour description decodes correctly only on clients whose "unspecified" default
// this a non-HEVC decoder assumes BT.709 SDR → washed-out / colour-shifted HDR. // happens to be BT.709 limited (ours are, but Moonlight/third-party/Android-vendor
// decoders default 601 at sub-HD resolutions). HEVC/H.264 carry it in the VUI; AV1 has
// NO VUI, so the SAME CICP code points go in the sequence-header colour config
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`).
// //
// This is the per-stream colour *description* only. The static mastering-display (ST.2086) // This is the per-stream colour *description* only. The static mastering-display (ST.2086)
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a // and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC // separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
// sets Main10 above today). // sets Main10 above today).
if self.hdr { {
let prim = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020; let (prim, trc, mat) = if self.hdr {
let trc = (
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084; nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
let mat = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL; nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
)
} else {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
)
};
match self.codec { match self.codec {
Codec::H265 => { Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters; let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
@@ -1160,6 +1174,24 @@ impl Encoder for NvencD3d11Encoder {
} }
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB, _ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
}; };
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
// the stream really carries instead of silently claiming full chroma.
if self.chroma_444
&& !matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
)
{
tracing::warn!(
format = ?captured.format,
"4:4:4 negotiated but the capturer delivered subsampled YUV — encoding 4:2:0"
);
self.chroma_444 = false;
}
let device = frame.device.clone(); let device = frame.device.clone();
self.init_session(&device)?; self.init_session(&device)?;
self.init_device = dev_raw; self.init_device = dev_raw;
@@ -1573,3 +1605,166 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
ok ok
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
use windows::Win32::Graphics::Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
};
/// The 8 fully-saturated colour bars the matrix analysis samples (RGB). Saturated primaries
/// separate BT.601 from BT.709 by tens of code points (e.g. pure-green luma 145 vs 173).
const BARS: [(u8, u8, u8); 8] = [
(255, 255, 255), // white
(255, 255, 0), // yellow
(0, 255, 255), // cyan
(0, 255, 0), // green
(255, 0, 255), // magenta
(255, 0, 0), // red
(0, 0, 255), // blue
(0, 0, 0), // black
];
/// BGRA probe pattern: left half = the 8 colour bars (flat patches → matrix measurement),
/// right half = alternating 1-px red/blue columns (the chroma-resolution litmus: true 4:4:4
/// keeps adjacent columns' chroma distinct; an internally-subsampled encode blends them).
fn probe_pattern(w: usize, h: usize) -> Vec<u8> {
let mut px = vec![0u8; w * h * 4];
let bar_w = (w / 2) / BARS.len();
for y in 0..h {
for x in 0..w {
let (r, g, b) = if x < w / 2 {
BARS[(x / bar_w).min(BARS.len() - 1)]
} else if x % 2 == 0 {
(255, 0, 0) // red column
} else {
(0, 0, 255) // blue column
};
let o = (y * w + x) * 4;
px[o] = b;
px[o + 1] = g;
px[o + 2] = r;
px[o + 3] = 255;
}
}
px
}
/// Encode 30 static pattern frames through the real NVENC session (ARGB input, the exact
/// production configuration) at the given chroma and write the Annex-B stream to `path`.
fn encode_pattern(chroma: ChromaFormat, path: &str) {
const W: u32 = 1280;
const H: u32 = 720;
// SAFETY (test-only): straight-line D3D11/DXGI COM calls on one thread; every out-pointer
// is checked before use; the texture/device outlive the encoder (dropped at scope end).
unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
let mut adapter = None;
for i in 0.. {
let Ok(a) = factory.EnumAdapters1(i) else {
break;
};
let desc = a.GetDesc1().expect("adapter desc");
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
adapter = Some(a);
break;
}
}
let adapter = adapter.expect("no hardware DXGI adapter");
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
let bytes = probe_pattern(W as usize, H as usize);
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: bytes.as_ptr() as *const _,
SysMemPitch: W * 4,
SysMemSlicePitch: 0,
};
let desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
// NVENC registration requires RENDER_TARGET on D3D11 input textures.
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
let mut tex = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.expect("pattern texture");
let tex = tex.expect("null pattern texture");
let mut enc = NvencD3d11Encoder::open(
Codec::H265,
PixelFormat::Bgra,
W,
H,
60,
100_000_000, // high rate: the 1-px stripes must survive quantization
8,
chroma,
)
.expect("NVENC open");
let mut out = Vec::new();
for i in 0..30u64 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i * 16_666_667,
format: PixelFormat::Bgra,
payload: FramePayload::D3d11(D3d11Frame {
texture: tex.clone(),
device: device.clone(),
}),
};
enc.submit(&frame).expect("submit");
while let Some(au) = enc.poll().expect("poll") {
out.extend_from_slice(&au.data);
}
}
enc.flush().ok();
while let Ok(Some(au)) = enc.poll() {
out.extend_from_slice(&au.data);
}
assert!(!out.is_empty(), "no AUs produced");
let caps444 = enc.caps().chroma_444;
std::fs::write(path, &out).expect("write bitstream");
println!(
"wrote {path}: {} bytes, requested {chroma:?}, caps.chroma_444={caps444}",
out.len()
);
}
}
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
/// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT
/// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
/// the FREXT stream is truly full-chroma and (2) which matrix NVENC's internal RGB→YUV CSC
/// used (BT.601 vs BT.709 — saturated bars differ by tens of code points). Run with:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_444_on_glass --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box"]
fn nvenc_444_on_glass_probe() {
encode_pattern(
ChromaFormat::Yuv444,
"C:\\Users\\Public\\nvenc444_probe.h265",
);
encode_pattern(
ChromaFormat::Yuv420,
"C:\\Users\\Public\\nvenc420_probe.h265",
);
}
}
@@ -257,9 +257,9 @@ fn run(
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>, audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
) -> Result<()> { ) -> Result<()> {
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?; let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
// Grow SO_SNDBUF/RCVBUF + opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1). // Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
// qWAVE derives the flow from the connected 5-tuple).
punktfunk_core::transport::grow_socket_buffers(&sock); punktfunk_core::transport::grow_socket_buffers(&sock);
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Audio);
// The client pings the audio port (~every 500ms) so we learn where to send. // The client pings the audio port (~every 500ms) so we learn where to send.
sock.set_read_timeout(Some(Duration::from_secs(10)))?; sock.set_read_timeout(Some(Duration::from_secs(10)))?;
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping"); tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
@@ -269,6 +269,12 @@ fn run(
.context("audio: no client ping within 10s")?; .context("audio: no client ping within 10s")?;
sock.connect(client) sock.connect(client)
.context("connect client audio endpoint")?; .context("connect client audio endpoint")?;
// Opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1); the guard keeps the
// Windows qWAVE flow alive for the whole stream (this function's scope IS the stream).
let _qos_flow = punktfunk_core::transport::set_media_qos(
&sock,
punktfunk_core::transport::MediaClass::Audio,
);
tracing::info!(%client, "audio: client endpoint learned"); tracing::info!(%client, "audio: client endpoint learned");
// Reuse the persistent capturer when its channel count still matches (drain stale // Reuse the persistent capturer when its channel count still matches (drain stale
+2 -2
View File
@@ -53,8 +53,8 @@ pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR /// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into /// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + /// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push /// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely on GameStream: stock Moonlight is 4:2:0
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`). /// full-chroma is a punktfunk/1-native negotiation only (`crate::capture::capturer_supports_444`).
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8; pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate /// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
@@ -380,6 +380,47 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR" "client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
); );
} }
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
// host that encodes something else shifts the client's colours. INSTRUMENTATION ONLY for
// now: we always encode BT.709 limited for SDR (the IDD VideoConverter / VUI-driven NVENC)
// and BT.2020 PQ for HDR — log what clients actually ask for so honoring `encoderCscMode`
// can be scoped from field data rather than guessed. (Absent on very old clients.)
if let Some(csc) = parse_u("x-nv-video[0].encoderCscMode") {
let (space, range) = (
match csc >> 1 {
0 => "Rec601",
1 => "Rec709",
2 => "Rec2020",
_ => "unknown",
},
if csc & 1 != 0 { "full" } else { "limited" },
);
let ours = if hdr {
"Rec2020 limited (PQ)"
} else {
"Rec709 limited"
};
let matches_ours = (hdr && csc >> 1 == 2 || !hdr && csc >> 1 == 1) && csc & 1 == 0;
if matches_ours {
tracing::info!(
csc,
space,
range,
"GameStream client requested CSC — matches ours"
);
} else {
tracing::warn!(
csc,
requested = format!("{space} {range}"),
encoding = ours,
"GameStream client requested a CSC we don't encode — Moonlight renders by its \
REQUEST, so its colours will be shifted (honoring encoderCscMode is a known \
follow-up; report this log line)"
);
}
}
// Parity floor the client asks for (protects small frames); clamp to a sane max. // Parity floor the client asks for (protects small frames); clamp to a sane max.
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets") let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
.unwrap_or(2) .unwrap_or(2)
+37 -102
View File
@@ -11,7 +11,6 @@ use super::VIDEO_PORT;
use crate::capture::{self, Capturer, FastSyntheticCapturer}; use crate::capture::{self, Capturer, FastSyntheticCapturer};
use crate::encode::{self, Codec}; use crate::encode::{self, Codec};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rand::Rng;
use std::net::UdpSocket; use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -95,10 +94,10 @@ fn run(
encode::validate_dimensions(cfg.codec, cfg.width, cfg.height) encode::validate_dimensions(cfg.codec, cfg.width, cfg.height)
.context("client-requested video mode")?; .context("client-requested video mode")?;
let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?; let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?;
// Grow SO_SNDBUF/RCVBUF (avoid host-side ENOBUFS at high bitrate) like the native plane, and // Grow SO_SNDBUF/RCVBUF (avoid host-side ENOBUFS at high bitrate) like the native plane.
// opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1). // The opt-in DSCP/QoS tag happens after connect below (Windows qWAVE derives the flow from
// the connected 5-tuple).
punktfunk_core::transport::grow_socket_buffers(&sock); punktfunk_core::transport::grow_socket_buffers(&sock);
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Video);
// The client pings the video port so we learn where to send; it re-pings until video // The client pings the video port so we learn where to send; it re-pings until video
// flows, so a missed early ping is fine. // flows, so a missed early ping is fine.
sock.set_read_timeout(Some(Duration::from_secs(10)))?; sock.set_read_timeout(Some(Duration::from_secs(10)))?;
@@ -112,6 +111,12 @@ fn run(
.context("video: no client ping within 10s")?; .context("video: no client ping within 10s")?;
sock.connect(client) sock.connect(client)
.context("connect client video endpoint")?; .context("connect client video endpoint")?;
// Opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1); the guard keeps the
// Windows qWAVE flow alive for the whole stream (this function's scope IS the stream).
let _qos_flow = punktfunk_core::transport::set_media_qos(
&sock,
punktfunk_core::transport::MediaClass::Video,
);
tracing::info!(%client, "video: client endpoint learned"); tracing::info!(%client, "video: client endpoint learned");
// Short label for web-console stats captures: the client's peer IP. // Short label for web-console stats captures: the client's peer IP.
let client_label = client.ip().to_string(); let client_label = client.ip().to_string();
@@ -422,20 +427,6 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
Ok(()) Ok(())
} }
/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows
/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds
/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a
/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send
/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps
/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent.
fn pace_layout(n: usize) -> (usize, usize) {
const MIN_PACE_CHUNK: usize = 16;
const MAX_PACE_STEPS: usize = 12;
let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS));
let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS
(chunk_sz, steps)
}
/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access /// One encoded frame handed from the encode loop to the packetizer thread: the frame's access
/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC /// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC
/// packetization runs on the packetizer thread — off the encode loop — so it never serializes /// packetization runs on the packetizer thread — off the encode loop — so it never serializes
@@ -485,15 +476,16 @@ fn spawn_packetizer(
} }
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in /// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval /// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval — the
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode /// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
/// thread is never blocked by this). On send failure (client gone) it clears `running`. /// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
/// of the fixed budget. On send failure (client gone) it clears `running`.
fn spawn_sender( fn spawn_sender(
sock: UdpSocket, sock: UdpSocket,
rx: std::sync::mpsc::Receiver<PacketBatch>, rx: std::sync::mpsc::Receiver<PacketBatch>,
frame_interval: Duration, frame_interval: Duration,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
drop_pct: u32,
) -> Result<()> { ) -> Result<()> {
std::thread::Builder::new() std::thread::Builder::new()
.name("punktfunk-send".into()) .name("punktfunk-send".into())
@@ -501,53 +493,38 @@ fn spawn_sender(
// Transmit thread: above-normal, matching the native path's send thread (includes the // Transmit thread: above-normal, matching the native path's send thread (includes the
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5). // Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false); crate::punktfunk1::boost_thread_priority(false);
// Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
// of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a
// 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this
// non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms
// budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and
// dropping ~half the frames ("send queue full"). Capping the step count keeps the
// microburst shaping (a real link drops line-rate bursts) while making per-frame sleep
// overshoot negligible and independent of bitrate.
let budget = frame_interval.mul_f32(0.75); let budget = frame_interval.mul_f32(0.75);
let mut rng = rand::thread_rng(); let cfg = crate::send_pacing::PaceCfg {
burst_bytes: None, // no microburst stage — the whole frame spreads
chunk: crate::send_pacing::ChunkPolicy::Bounded {
min_chunk: 16,
max_steps: 12,
},
sleep_floor: Duration::from_micros(500),
};
let mut sent: u64 = 0; let mut sent: u64 = 0;
let mut dropped: u64 = 0; let mut dropped: u64 = 0;
while let Ok(mut batch) = rx.recv() { while let Ok(mut batch) = rx.recv() {
if drop_pct > 0 { // FEC test knob (PUNKTFUNK_VIDEO_DROP) — same knob the native plane honors.
batch.retain(|_| { dropped += crate::send_pacing::inject_video_drop(&mut batch);
let keep = rng.gen_range(0..100) >= drop_pct; if batch.is_empty() {
if !keep {
dropped += 1;
}
keep
});
}
let n = batch.len();
if n == 0 {
continue; continue;
} }
// Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into let r = crate::send_pacing::pace_frame(
// dozens of sleeps. Each step gets an equal slice of the budget (total pacing time &batch,
// == budget regardless of n). crate::send_pacing::PaceBudget::Fixed(budget),
let (chunk_sz, steps) = pace_layout(n); &cfg,
let per_step = budget.mul_f64(1.0 / steps as f64); |chunk| {
let start = Instant::now(); sendmmsg_all(&sock, chunk)?;
for (i, chunk) in batch.chunks(chunk_sz).enumerate() { sent += chunk.len() as u64;
if let Err(e) = sendmmsg_all(&sock, chunk) { Ok::<(), std::io::Error>(())
},
);
if let Err(e) = r {
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream"); tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
running.store(false, Ordering::SeqCst); running.store(false, Ordering::SeqCst);
return; return;
} }
sent += chunk.len() as u64;
// Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_step.mul_f64((i + 1) as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= Duration::from_micros(500) {
std::thread::sleep(ahead);
}
}
}
} }
tracing::debug!(sent, dropped, "video sender exiting"); tracing::debug!(sent, dropped, "video sender exiting");
}) })
@@ -555,16 +532,7 @@ fn spawn_sender(
Ok(()) Ok(())
} }
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the web-console use crate::send_pacing::percentile;
/// stats sample's per-stage p50/p99.
fn percentile(v: &mut [u32], q: f64) -> u32 {
if v.is_empty() {
return 0;
}
v.sort_unstable();
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
v[i]
}
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread /// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode. /// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
@@ -627,11 +595,6 @@ fn stream_body(
let mut fps_count: u32 = 0; let mut fps_count: u32 = 0;
let mut fps_t = Instant::now(); let mut fps_t = Instant::now();
let stream_start = Instant::now(); let stream_start = Instant::now();
// Test knob: drop this % of outbound packets to exercise FEC recovery (0 = off).
let drop_pct: u32 = std::env::var("PUNKTFUNK_VIDEO_DROP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut sent_batches: u64 = 0; let mut sent_batches: u64 = 0;
let mut dropped_batches: u64 = 0; let mut dropped_batches: u64 = 0;
@@ -650,7 +613,6 @@ fn stream_body(
batch_rx, batch_rx,
Duration::from_secs_f64(1.0 / target_fps as f64), Duration::from_secs_f64(1.0 / target_fps as f64),
running.clone(), running.clone(),
drop_pct,
)?; )?;
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2); let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?; spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
@@ -989,7 +951,6 @@ mod tests {
rx, rx,
Duration::from_millis(8), // ~120fps frame interval Duration::from_millis(8), // ~120fps frame interval
running.clone(), running.clone(),
0,
) )
.unwrap(); .unwrap();
@@ -1026,30 +987,4 @@ mod tests {
assert_eq!(got, 3 * PER_FRAME); assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
} }
/// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size,
/// while always covering every packet and keeping small frames on the 16-packet floor. Guards
/// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600
/// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget).
#[test]
fn pace_layout_bounds_step_count() {
for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] {
let (chunk, steps) = pace_layout(n);
assert!(steps >= 1, "n={n}: at least one step");
assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap");
assert!(
chunk >= 16,
"n={n}: chunk {chunk} below the 16-packet floor"
);
assert!(
chunk * steps >= n,
"n={n}: {chunk}×{steps} must cover all packets"
);
}
// Small frames stay on the floor: one 16-packet burst.
assert_eq!(pace_layout(1), (16, 1));
assert_eq!(pace_layout(16), (16, 1));
// A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded.
assert!(pace_layout(610).1 <= 12);
}
} }
@@ -149,7 +149,8 @@ impl VideoPacketizer {
}; };
let wire_pct = if m > 0 { (100 * m) / k } else { 0 }; let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
let parity = if m > 0 { let parity = if m > 0 {
Gf8Coder.encode(&shards, m).unwrap_or_default() let refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect();
Gf8Coder.encode(&refs, m).unwrap_or_default()
} else { } else {
Vec::new() Vec::new()
}; };
@@ -13,12 +13,14 @@ use super::egl::DmabufPlane;
use super::proto::{self, BufferDesc, ImportKind, Reply, Request}; use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io; use std::io;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::path::Path; use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command}; use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds. /// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
@@ -69,6 +71,57 @@ fn sweep_reaper() {
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_)))); list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
} }
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
/// `/proc/self/exe` magic link. The link names the running image's *inode*, not its path, so it
/// resolves even after the installed binary was replaced or deleted — and exec'ing the fd (via
/// [`fd_exec_path`]) then still runs byte-for-byte the build this process is. `current_exe()`
/// instead readlinks to a path: after a package upgrade under a running host that path is
/// "<path> (deleted)" and spawning it fails ENOENT — every capture then silently fell back to
/// the CPU copy — and even while the path exists it may hold a newer build whose worker
/// protocol mismatches this process.
static SELF_EXE: OnceLock<Option<File>> = OnceLock::new();
fn self_exe() -> Option<BorrowedFd<'static>> {
SELF_EXE
.get_or_init(|| {
let f = match File::open("/proc/self/exe") {
Ok(f) => f,
Err(e) => {
tracing::warn!(
error = %e,
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
which breaks if this binary is replaced on disk"
);
return None;
}
};
if f.as_raw_fd() != 3 {
return Some(f);
}
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
// `spawn_exe`) — pinned there, the child would clobber it before exec resolves
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
// duplicate cannot land on it.
match f.try_clone() {
Ok(clone) => Some(clone),
Err(e) => {
tracing::warn!(error = %e, "re-numbering the pinned exe fd off fd 3 failed");
None
}
}
})
.as_ref()
.map(|f| f.as_fd())
}
/// `/proc/self/fd/<n>` — an exec'able path to `fd`'s inode. The kernel resolves it at exec time
/// inside the forked child, whose fd table is a copy of ours (close-on-exec applies only once
/// the exec succeeds), so it names the pinned inode no matter what sits at the file's original
/// path by then.
fn fd_exec_path(fd: BorrowedFd<'_>) -> PathBuf {
PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()))
}
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process /// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
/// [`super::egl::EglImporter`] surface the capture thread uses. /// [`super::egl::EglImporter`] surface the capture thread uses.
pub struct RemoteImporter { pub struct RemoteImporter {
@@ -81,12 +134,18 @@ pub struct RemoteImporter {
} }
impl RemoteImporter { impl RemoteImporter {
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err` /// Spawn the worker from this host binary and complete the readiness handshake. The worker
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly /// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this
/// like an in-process `EglImporter::new()` failure. /// process runs — even after the installed binary was replaced mid-flight. An `Err` here
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
/// an in-process `EglImporter::new()` failure.
pub fn spawn() -> Result<RemoteImporter> { pub fn spawn() -> Result<RemoteImporter> {
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?; match self_exe() {
Self::spawn_exe(&exe) Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
None => Self::spawn_exe(
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
),
}
} }
/// [`Self::spawn`] with an explicit executable (separated for tests). /// [`Self::spawn`] with an explicit executable (separated for tests).
@@ -94,6 +153,8 @@ impl RemoteImporter {
sweep_reaper(); sweep_reaper();
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?; let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
let mut cmd = Command::new(exe); let mut cmd = Command::new(exe);
// `exe` is normally an opaque `/proc/self/fd/<n>` — keep `ps` output meaningful.
cmd.arg0("punktfunk-host");
cmd.arg("zerocopy-worker").arg("--fd").arg("3"); cmd.arg("zerocopy-worker").arg("--fd").arg("3");
let raw = worker_end.as_raw_fd(); let raw = worker_end.as_raw_fd();
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are // SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
@@ -102,7 +163,6 @@ impl RemoteImporter {
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3, // the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead. // `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
unsafe { unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(move || { cmd.pre_exec(move || {
if raw == 3 { if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD); let flags = libc::fcntl(3, libc::F_GETFD);
@@ -483,6 +543,48 @@ mod tests {
assert!(format!("{err:#}").contains("handshake"), "{err:#}"); assert!(format!("{err:#}").contains("handshake"), "{err:#}");
} }
#[test]
fn spawn_execs_the_pinned_self_exe() {
// `spawn()` execs this very process's image via the pinned `/proc/self/fd/…` path. Here
// that image is the libtest harness, which rejects `--fd` and exits without a handshake
// — so a "handshake" error proves the exec itself succeeded (an exec failure would read
// "spawn zerocopy-worker" instead).
let Err(err) = RemoteImporter::spawn() else {
panic!("the test harness is not a worker; spawn must fail at the handshake")
};
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
}
#[test]
fn pinned_fd_exec_survives_on_disk_replacement() {
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
// a copy of /bin/sh, delete it, then run it through the fd path.
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
std::fs::copy("/bin/sh", &copy).unwrap();
let pinned = File::open(&copy).unwrap();
std::fs::remove_file(&copy).unwrap();
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
// not the mechanism under test — production pins a read-only fd on a binary nobody
// write-opens.
let status = loop {
match Command::new(fd_exec_path(pinned.as_fd()))
.arg("-c")
.arg("exit 42")
.status()
{
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
std::thread::sleep(Duration::from_millis(10))
}
other => break other.expect("exec via /proc/self/fd of a deleted file"),
}
};
assert_eq!(status.code(), Some(42));
}
/// A scripted peer: answers the handshake, then serves canned replies per request. /// A scripted peer: answers the handshake, then serves canned replies per request.
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) { fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
let (host, worker) = proto::socketpair_seqpacket().unwrap(); let (host, worker) = proto::socketpair_seqpacket().unwrap();
@@ -221,11 +221,20 @@ pub fn drm_fourcc(format: crate::capture::PixelFormat) -> Option<u32> {
} }
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA /// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
/// context and report. De-risks the FFI/linking/GPU-access without needing a capture session. /// context and report, then exercise the production path — spawn the isolated worker (exec'd
/// from this binary's pinned exe fd), handshake, and query modifiers. De-risks the
/// FFI/linking/GPU-access AND the worker spawn (e.g. the installed binary replaced under a
/// running host) without needing a capture session.
pub fn probe() -> anyhow::Result<()> { pub fn probe() -> anyhow::Result<()> {
let _importer = EglImporter::new()?; let _importer = EglImporter::new()?;
let ctx = cuda::context()?; let ctx = cuda::context()?;
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized"); tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
let mut worker = client::RemoteImporter::spawn()?;
let modifiers = worker.supported_modifiers(fourcc(b"XR24")).len();
tracing::info!(
modifiers,
"zero-copy probe OK — worker spawned, handshake + modifier query"
);
Ok(()) Ok(())
} }
@@ -27,6 +27,14 @@ const FD_CACHE_CAP: usize = 64;
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own /// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in). /// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
pub fn run_from_args(args: &[String]) -> Result<()> { pub fn run_from_args(args: &[String]) -> Result<()> {
// The host execs this worker through its pinned exe fd (`client::self_exe`), so the kernel
// derives our comm from the exec path's basename — a meaningless fd number. Rename so
// `top`/`pkill` see the worker.
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal
// is valid, NUL-terminated, and short enough. No pointer is retained past the call.
unsafe {
libc::prctl(libc::PR_SET_NAME, c"pf-zerocopy".as_ptr());
}
let fd: i32 = args let fd: i32 = args
.iter() .iter()
.skip_while(|a| *a != "--fd") .skip_while(|a| *a != "--fd")
+16 -3
View File
@@ -28,6 +28,9 @@ mod wol;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/crash.rs"] #[path = "windows/crash.rs"]
mod crash; mod crash;
#[cfg(target_os = "windows")]
#[path = "windows/ddc.rs"]
mod ddc;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "linux/dmabuf_fence.rs"] #[path = "linux/dmabuf_fence.rs"]
mod dmabuf_fence; mod dmabuf_fence;
@@ -50,12 +53,17 @@ mod install;
mod interactive; mod interactive;
mod library; mod library;
mod log_capture; mod log_capture;
mod metronome;
mod mgmt; mod mgmt;
mod mgmt_token; mod mgmt_token;
#[cfg(target_os = "windows")]
#[path = "windows/monitor_devnode.rs"]
mod monitor_devnode;
mod native_pairing; mod native_pairing;
mod pipeline; mod pipeline;
mod punktfunk1; mod punktfunk1;
mod pwinit; mod pwinit;
mod send_pacing;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/service.rs"] #[path = "windows/service.rs"]
mod service; mod service;
@@ -194,6 +202,11 @@ fn real_main() -> Result<()> {
// driver to a stray second host started while the service sat idle. // driver to a stray second host started while the service sat idle.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
vdisplay::manager::claim_instance_eagerly(); vdisplay::manager::claim_instance_eagerly();
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
// monitor devnodes a previous host disabled for an Exclusive session and never
// restored (crash/kill/power loss) — before any new session touches the topology.
#[cfg(target_os = "windows")]
monitor_devnode::startup_recover();
gamestream::serve(mgmt_opts, native, gamestream) gamestream::serve(mgmt_opts, native, gamestream)
} }
// Print the management API's OpenAPI document (for client codegen). // Print the management API's OpenAPI document (for client codegen).
@@ -213,9 +226,9 @@ fn real_main() -> Result<()> {
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed). // Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("zerocopy-probe") => zerocopy::probe(), Some("zerocopy-probe") => zerocopy::probe(),
// Hidden: the isolated GPU-import worker the capture path spawns from /proc/self/exe // Hidden: the isolated GPU-import worker the capture path spawns from a pinned fd to its
// (design/zerocopy-worker-isolation.md) — never run by hand; --fd names the inherited // own executable image (design/zerocopy-worker-isolation.md) — never run by hand; --fd
// socketpair end. // names the inherited socketpair end.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]), Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12 // NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
+151
View File
@@ -0,0 +1,151 @@
//! Detector for METRONOMIC event cycles — evenly-spaced disturbances repeating every few seconds.
//!
//! The "periodic double-jolt" symptom class field reports keep describing is a host/display-side
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
//! stalls (`capture::windows::idd_push`).
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// Pure evenly-spaced-events detector (unit-tested below).
///
/// Events within [`Self::COALESCE`] count as ONE (a double-jolt's paired disturbances — e.g. the
/// cooldown re-issue of a lost keyframe ~0.7 s after the first — are one user-visible cycle). When
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
/// [`Self::REWARN`] while the cycle persists.
pub(crate) struct Metronome {
events: VecDeque<Instant>,
last_warn: Option<Instant>,
}
impl Metronome {
/// Events closer together than this are the same user-visible disturbance.
const COALESCE: Duration = Duration::from_millis(1500);
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
const STREAK: usize = 4;
/// "Evenly spaced" = every gap within this fraction of the mean gap.
const TOLERANCE: f64 = 0.2;
/// Once warned, re-warn at most this often while the cycle persists.
const REWARN: Duration = Duration::from_secs(30);
pub(crate) fn new() -> Self {
Self {
events: VecDeque::new(),
last_warn: None,
}
}
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
/// warning should fire.
pub(crate) fn note(&mut self, now: Instant) -> Option<Duration> {
if self
.events
.back()
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
{
return None;
}
self.events.push_back(now);
if self.events.len() > Self::STREAK {
self.events.pop_front();
}
if self.events.len() < Self::STREAK {
return None;
}
let gaps: Vec<f64> = self
.events
.iter()
.zip(self.events.iter().skip(1))
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
.collect();
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
if mean <= 0.0
|| gaps
.iter()
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
{
return None;
}
if self
.last_warn
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
{
return None;
}
self.last_warn = Some(now);
Some(Duration::from_secs_f64(mean))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Feed a [`Metronome`] a schedule of event offsets (ms from a common origin) and return
/// what each `note` produced.
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<Duration>> {
let base = Instant::now();
let mut c = Metronome::new();
offsets_ms
.iter()
.map(|ms| c.note(base + Duration::from_millis(*ms)))
.collect()
}
#[test]
fn cadence_detects_metronomic_events() {
// Four events ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
assert_eq!(out[..3], [None, None, None]);
let period = out[3].expect("metronomic series must be detected");
assert!(
(period.as_secs_f64() - 3.98).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_coalesces_double_jolt_pairs() {
// The field signature: a jolt pair (second event ~0.7 s after the first, e.g. the IDR
// cooldown re-issue) every ~4 s. Each pair is ONE event; detection still lands on the
// ~4 s cycle.
let out = cadence_run(&[
0, 700, // pair 1
4_000, 4_700, // pair 2
8_000, 8_650, // pair 3
12_000, // pair 4 (first event trips it)
]);
assert!(out[..6].iter().all(Option::is_none));
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_ignores_irregular_bursts() {
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
.iter()
.all(Option::is_none));
}
#[test]
fn cadence_rewarns_at_most_every_30s() {
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
let out = cadence_run(&offsets);
let warned: Vec<usize> = out
.iter()
.enumerate()
.filter_map(|(i, o)| o.map(|_| i))
.collect();
assert_eq!(warned, vec![3, 11], "warn indices");
}
}
+13 -4
View File
@@ -1051,6 +1051,10 @@ fn display_settings_state() -> DisplaySettingsState {
"identity".into(), "identity".into(),
"layout".into(), "layout".into(),
"game_session".into(), "game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
], ],
} }
} }
@@ -1256,10 +1260,12 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure // Lock the current effective behavior into explicit fields + set the manual arrangement (pure
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy // transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.) // axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
let policy = store let policy = store.get().effective().with_manual_layout(
.get() req.positions,
.effective() store.game_session(),
.with_manual_layout(req.positions, store.game_session()); store.ddc_power_off(),
store.pnp_disable_monitors(),
);
if let Err(e) = store.set(policy) { if let Err(e) = store.set(policy) {
return api_error( return api_error(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -2944,6 +2950,9 @@ mod tests {
assert!(enforced.contains(&"mode_conflict")); assert!(enforced.contains(&"mode_conflict"));
assert!(enforced.contains(&"identity")); assert!(enforced.contains(&"identity"));
assert!(enforced.contains(&"layout")); assert!(enforced.contains(&"layout"));
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
assert!(enforced.contains(&"ddc_power_off"));
assert!(enforced.contains(&"pnp_disable_monitors"));
} }
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has /// The display state/release endpoints are wired + auth-gated. On the test host no backend has
+51 -242
View File
@@ -120,6 +120,7 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. /// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
use crate::native_pairing::{NativePairing, PairingDecision}; use crate::native_pairing::{NativePairing, PairingDecision};
use crate::send_pacing::{percentile, PaceStat};
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API /// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
/// and the GameStream loop; threaded into each session's `SessionContext`. /// and the GameStream loop; threaded into each session's `SessionContext`.
use crate::stats_recorder::StatsRecorder; use crate::stats_recorder::StatsRecorder;
@@ -978,6 +979,19 @@ async fn serve_session(
"encode chroma" "encode chroma"
); );
// Linux 4:4:4 rides the CPU swscale → 8-bit `YUV444P` path (see `encode/linux`) — there
// is no 10-bit 4:4:4 input there, so a 10-bit-negotiated session would silently encode
// 8-bit. Resolve the depth DOWN before the Welcome so the wire never overstates what the
// stream carries. (Windows NVENC composes Main 4:4:4 10 from an RGB input, so it keeps
// the resolved depth — this clamp is Linux-only.)
#[cfg(target_os = "linux")]
let bit_depth: u8 = if chroma.is_444() && bit_depth == 10 {
tracing::info!("4:4:4 on the Linux path encodes 8-bit YUV444P — resolving bit depth 8");
8
} else {
bit_depth
};
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no // Reserve the data-plane UDP socket up front and HOLD it through streaming (no
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address, // `--data-port` yields `direct = true` (stream straight to the client's reported address,
@@ -987,6 +1001,13 @@ async fn serve_session(
let mut key = [0u8; 16]; let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key); rand::thread_rng().fill_bytes(&mut key);
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
// of the two to be unique per session (the nonce is salt || sequence under the session
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
// just follow.
let mut salt = [0u8; 4];
rand::thread_rng().fill_bytes(&mut salt);
let welcome = Welcome { let welcome = Welcome {
abi_version: punktfunk_core::WIRE_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
udp_port, udp_port,
@@ -1012,7 +1033,7 @@ async fn serve_session(
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16, shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
encrypt: true, encrypt: true,
key, key,
salt: *b"pkf1", salt,
frames: match source { frames: match source {
Punktfunk1Source::Synthetic => frames, Punktfunk1Source::Synthetic => frames,
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
@@ -2617,34 +2638,16 @@ fn service_probes(
} }
} }
/// Seal one access unit and send its packets PACED over the budget until `deadline` (the next /// Seal one access unit and send it with MICROBURST pacing (the shared
/// frame's due time), in 16-packet `sendmmsg` chunks — so a high-bitrate frame spreads across the /// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
/// frame interval instead of bursting all at once into the NIC. A real link drops a line-rate burst /// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
/// (the host send buffer EAGAINs), and under infinite GOP a single dropped frame freezes the decode /// only the OVERFLOW beyond that is spread in 16-packet chunks across ~90% of the time to
/// until the next keyframe — the cause of the "freezes over ~150 Mbps, no image at 400 Mbps" /// `deadline`. So a normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added
/// symptom. When there's little/no slack (encode ≈ interval at very high fps) the budget collapses /// latency, while a genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping
/// to ~0 and every chunk goes out immediately, so this is never slower than the unpaced path. /// the freeze fix exactly where it's needed (an unpaced line-rate burst overruns the kernel tx
/// One paced send's outcome: how long the frame's packets took to leave (`spread_us`) and whether /// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
/// any were paced (vs the whole frame fitting the microburst and going out immediately). Fed to the /// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
/// PUNKTFUNK_PERF histogram so the pacing tail is visible per-frame. /// this is never slower than unpaced.
struct PaceStat {
spread_us: u32,
paced: bool,
}
const PACE_CHUNK: usize = 16;
/// Seal one access unit and send it with MICROBURST pacing: the first `burst_cap` bytes go out
/// immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and only the OVERFLOW
/// beyond that is spread in [`PACE_CHUNK`]-packet chunks across ~90% of the time to `deadline`. So a
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a genuine
/// IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix exactly where
/// it's needed (an unpaced line-rate burst overruns the kernel tx buffer → EAGAIN drop → under
/// infinite GOP, a freeze until the next keyframe). With no slack (encode ≈ interval) the budget
/// collapses to 0 and even the overflow goes out immediately, so this is never slower than unpaced.
/// Parsed-once `PUNKTFUNK_VIDEO_DROP` percentage for the native data plane (see `paced_submit`).
static NATIVE_VIDEO_DROP: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
fn paced_submit( fn paced_submit(
session: &mut Session, session: &mut Session,
data: &[u8], data: &[u8],
@@ -2657,80 +2660,25 @@ fn paced_submit(
.seal_frame(data, pts_ns, flags) .seal_frame(data, pts_ns, flags)
.map_err(|e| anyhow!("seal_frame: {e:?}"))?; .map_err(|e| anyhow!("seal_frame: {e:?}"))?;
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect(); let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
// FEC/recovery test knob: PUNKTFUNK_VIDEO_DROP=N discards N% of the sealed wire packets // FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
// before send — controlled loss injection with no netem/root, same knob the GameStream video crate::send_pacing::inject_video_drop(&mut refs);
// path honors. Parsed once; 0/unset = off (the normal path is untouched). let cfg = crate::send_pacing::PaceCfg {
let drop_pct = *NATIVE_VIDEO_DROP.get_or_init(|| { burst_bytes: Some(burst_cap),
let pct = std::env::var("PUNKTFUNK_VIDEO_DROP") chunk: crate::send_pacing::ChunkPolicy::Fixed(16),
.ok() sleep_floor: std::time::Duration::from_micros(500),
.and_then(|s| s.parse::<u32>().ok()) };
.filter(|p| (1..=90).contains(p)) let result = crate::send_pacing::pace_frame(
.unwrap_or(0); &refs,
if pct > 0 { crate::send_pacing::PaceBudget::UntilDeadline {
tracing::warn!( deadline,
pct, fraction: 0.9,
"PUNKTFUNK_VIDEO_DROP: injecting wire-packet loss (FEC test)" },
&cfg,
|chunk| session.send_sealed(chunk).map(|_| ()),
); );
}
pct
});
if drop_pct > 0 {
use rand::Rng;
let mut rng = rand::thread_rng();
refs.retain(|_| rng.gen_range(0..100) >= drop_pct);
}
let start = std::time::Instant::now();
// Split at the microburst cap: packets [0..split] burst out immediately, [split..] are paced.
let mut cum = 0usize;
let mut split = refs.len();
for (k, r) in refs.iter().enumerate() {
cum += r.len();
if cum >= burst_cap {
split = k + 1;
break;
}
}
for chunk in refs[..split].chunks(PACE_CHUNK) {
session
.send_sealed(chunk)
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
}
let paced = split < refs.len();
if paced {
let pace_start = std::time::Instant::now();
let budget = deadline
.checked_duration_since(pace_start)
.unwrap_or_default()
.mul_f32(0.9);
let m = refs[split..].len().div_ceil(PACE_CHUNK).max(1);
for (j, chunk) in refs[split..].chunks(PACE_CHUNK).enumerate() {
session
.send_sealed(chunk)
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
// Sleep toward this chunk's slice of the budget; skip sub-500µs waits (scheduler jitter).
let target = pace_start + budget.mul_f64((j + 1) as f64 / m as f64);
if let Some(ahead) = target.checked_duration_since(std::time::Instant::now()) {
if ahead > std::time::Duration::from_micros(500) {
std::thread::sleep(ahead);
}
}
}
}
let spread_us = start.elapsed().as_micros() as u32;
drop(refs); // release the borrow of `wires` so it can return to the seal pool drop(refs); // release the borrow of `wires` so it can return to the seal pool
session.reclaim_wires(wires); session.reclaim_wires(wires);
Ok(PaceStat { spread_us, paced }) result.map_err(|e| anyhow!("send_sealed: {e:?}"))
}
/// Percentile of a slice (sorts it in place first). `q` in 0.0..=1.0.
fn percentile(sorted_or_not: &mut [u32], q: f64) -> u32 {
if sorted_or_not.is_empty() {
return 0;
}
sorted_or_not.sort_unstable();
let i = ((sorted_or_not.len() as f64 * q) as usize).min(sorted_or_not.len() - 1);
sorted_or_not[i]
} }
/// One encoded frame handed from the capture/encode thread to the send thread (the encode|send /// One encoded frame handed from the capture/encode thread to the send thread (the encode|send
@@ -3230,82 +3178,6 @@ struct SessionContext {
launch: Option<String>, launch: Option<String>,
} }
/// Detector for METRONOMIC client keyframe-recovery cycles — the "periodic double-jolt" symptom
/// class field reports keep describing: a host/display-side disturbance repeating every few
/// seconds (display-topology churn, display-poller software, virtual-display timing), where each
/// cycle ends in a client keyframe request the host serves. Random network loss is bursty and
/// irregular; a stable period is a machine, and saying so in the host log turns a "nothing in the
/// logs :/" report into a self-diagnosis.
///
/// Served forced IDRs within [`Self::COALESCE`] count as ONE event (a double-jolt's paired IDRs —
/// the cooldown re-issue of a lost keyframe — are one user-visible disturbance). When the gaps
/// between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their mean,
/// [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
/// [`Self::REWARN`] while the cycle persists. Pure logic — unit-tested below.
struct RecoveryCadence {
events: std::collections::VecDeque<std::time::Instant>,
last_warn: Option<std::time::Instant>,
}
impl RecoveryCadence {
/// Serves closer together than this are the same user-visible disturbance.
const COALESCE: std::time::Duration = std::time::Duration::from_millis(1500);
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
const STREAK: usize = 4;
/// "Evenly spaced" = every gap within this fraction of the mean gap.
const TOLERANCE: f64 = 0.2;
/// Once warned, re-warn at most this often while the cycle persists.
const REWARN: std::time::Duration = std::time::Duration::from_secs(30);
fn new() -> Self {
Self {
events: std::collections::VecDeque::new(),
last_warn: None,
}
}
/// Record a served client-recovery IDR at `now`; `Some(mean period)` exactly when the
/// metronomic-cycle warning should fire.
fn note(&mut self, now: std::time::Instant) -> Option<std::time::Duration> {
if self
.events
.back()
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
{
return None;
}
self.events.push_back(now);
if self.events.len() > Self::STREAK {
self.events.pop_front();
}
if self.events.len() < Self::STREAK {
return None;
}
let gaps: Vec<f64> = self
.events
.iter()
.zip(self.events.iter().skip(1))
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
.collect();
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
if mean <= 0.0
|| gaps
.iter()
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
{
return None;
}
if self
.last_warn
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
{
return None;
}
self.last_warn = Some(now);
Some(std::time::Duration::from_secs_f64(mean))
}
}
fn virtual_stream(ctx: SessionContext) -> Result<()> { fn virtual_stream(ctx: SessionContext) -> Result<()> {
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal / // This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU // synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
@@ -3521,8 +3393,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// opening GOP, instead of answering it with a redundant second IDR. // opening GOP, instead of answering it with a redundant second IDR.
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now()); let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle // Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
// into a stable multi-second rhythm (see [`RecoveryCadence`]). // into a stable multi-second rhythm (see [`crate::metronome::Metronome`]).
let mut recovery_cadence = RecoveryCadence::new(); let mut recovery_cadence = crate::metronome::Metronome::new();
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see // Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert), // exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one // submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
@@ -3733,7 +3605,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
disturbance (display-topology churn, display-poller software, \ disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network loss; \ virtual-display timing) is the likely cause, not random network loss; \
correlate with 'slow display-descriptor poll' / 'display descriptor \ correlate with 'slow display-descriptor poll' / 'display descriptor \
changed' lines" changed' / 'IDD-push capture stall' lines"
); );
} }
} }
@@ -4425,69 +4297,6 @@ mod tests {
assert_eq!(dec, snap); assert_eq!(dec, snap);
} }
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
/// what each `note` produced.
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
let base = std::time::Instant::now();
let mut c = RecoveryCadence::new();
offsets_ms
.iter()
.map(|ms| c.note(base + std::time::Duration::from_millis(*ms)))
.collect()
}
#[test]
fn cadence_detects_metronomic_recoveries() {
// Four IDR serves ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
assert_eq!(out[..3], [None, None, None]);
let period = out[3].expect("metronomic series must be detected");
assert!(
(period.as_secs_f64() - 3.98).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_coalesces_double_jolt_pairs() {
// The field signature: a jolt pair (second IDR ~0.7 s after the first, the cooldown
// re-issue) every ~4 s. Each pair is ONE event; detection still lands on the ~4 s cycle.
let out = cadence_run(&[
0, 700, // pair 1
4_000, 4_700, // pair 2
8_000, 8_650, // pair 3
12_000, // pair 4 (first serve trips it)
]);
assert!(out[..6].iter().all(Option::is_none));
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
assert!(
(period.as_secs_f64() - 4.0).abs() < 0.2,
"period={period:?}"
);
}
#[test]
fn cadence_ignores_irregular_bursts() {
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
.iter()
.all(Option::is_none));
}
#[test]
fn cadence_rewarns_at_most_every_30s() {
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
let out = cadence_run(&offsets);
let warned: Vec<usize> = out
.iter()
.enumerate()
.filter_map(|(i, o)| o.map(|_| i))
.collect();
assert_eq!(warned, vec![3, 11], "warn indices");
}
#[test] #[test]
fn adapt_fec_maps_loss_to_recovery_band() { fn adapt_fec_maps_loss_to_recovery_band() {
// A perfectly clean window (0 loss) lands on the floor. // A perfectly clean window (0 loss) lands on the floor.
+408
View File
@@ -0,0 +1,408 @@
//! Shared microburst pacing POLICY for the two video send planes (networking-audit deferred
//! plan §5): the native plane (`punktfunk1::paced_submit`, GSO via the core `Session`) and the
//! GameStream compat plane (`gamestream::stream::spawn_sender`, `sendmmsg` over its own RTP
//! socket). Both spread a frame's packets across a time budget in chunked bursts so a real link
//! doesn't drop the frame as one line-rate burst; the syscall layers stay deliberately separate
//! (different sockets, framing, and error contracts) — this module shares the schedule, not the
//! plumbing.
//!
//! The two planes keep their historical parameterizations exactly (pinned by the
//! deterministic-schedule tests below):
//!
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
//! overflow is paced in fixed 16-packet chunks across 90 % of the time left to the frame
//! deadline (no slack ⇒ budget 0 ⇒ never slower than unpaced);
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
//! (Moonlight clients are tested against this timing).
//!
//! `PUNKTFUNK_VIDEO_DROP` (the FEC-recovery test knob both planes honor) and the stats
//! `percentile` helper live here too — they were duplicated alongside the pacing.
use std::time::{Duration, Instant};
/// One paced send's outcome: how long the frame's packets took to leave (`spread_us`) and
/// whether any were paced (vs the whole frame fitting the microburst and going out
/// immediately). The native plane feeds it to the PUNKTFUNK_PERF histogram so the pacing tail
/// is visible per-frame.
pub(crate) struct PaceStat {
pub(crate) spread_us: u32,
pub(crate) paced: bool,
}
/// How a frame's packets split into send chunks.
#[derive(Clone, Copy, Debug)]
pub(crate) enum ChunkPolicy {
/// Fixed chunk size; the step count scales with the frame (native: 16).
Fixed(usize),
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
Bounded { min_chunk: usize, max_steps: usize },
}
/// The time the paced (post-burst) packets spread across.
#[derive(Clone, Copy, Debug)]
pub(crate) enum PaceBudget {
/// `(deadline now-after-burst) × fraction`, collapsing to 0 with no slack (native: 0.9).
UntilDeadline { deadline: Instant, fraction: f32 },
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
Fixed(Duration),
}
/// Per-plane pacing parameters. See the module doc for the two canonical values.
#[derive(Clone, Copy, Debug)]
pub(crate) struct PaceCfg {
/// Bytes that leave immediately as one absorbed microburst before pacing starts; `None` =
/// no burst stage at all (GameStream). `Some(0)` still bursts the first packet — the split
/// is "the packet that crosses the cap goes with the burst", exactly the native semantics.
pub(crate) burst_bytes: Option<usize>,
pub(crate) chunk: ChunkPolicy,
/// Sleeps shorter than this are skipped (scheduler-jitter floor; both planes: 500 µs).
pub(crate) sleep_floor: Duration,
}
/// A frame's send schedule, computed up front as pure data (what the deterministic tests pin):
/// packets `[0..burst_len)` go immediately in `chunk`-sized bursts; the rest go in `steps`
/// chunks of `chunk`, chunk `j` (0-based) sleeping toward `budget × (j+1)/steps`.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct PaceSchedule {
pub(crate) burst_len: usize,
pub(crate) chunk: usize,
pub(crate) steps: usize,
}
/// Compute the schedule for one frame's wire packets under `cfg`.
pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSchedule {
let burst_len = match cfg.burst_bytes {
None => 0,
Some(cap) => {
// The packet that crosses the cap still bursts (`split = k + 1`) — the whole frame
// bursts when it never crosses it.
let mut cum = 0usize;
let mut split = packets.len();
for (k, p) in packets.iter().enumerate() {
cum += p.as_ref().len();
if cum >= cap {
split = k + 1;
break;
}
}
split
}
};
let overflow = packets.len() - burst_len;
let (chunk, steps) = match cfg.chunk {
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
ChunkPolicy::Bounded {
min_chunk,
max_steps,
} => {
let c = min_chunk.max(overflow.div_ceil(max_steps));
(c, overflow.div_ceil(c).max(1))
}
};
PaceSchedule {
burst_len,
chunk,
steps,
}
}
/// Send one frame's packets under the plane's pacing policy: the burst stage leaves
/// immediately, then each paced chunk is sent and slept toward its slice of the budget
/// (sub-`sleep_floor` waits are skipped). A `send` error aborts the frame and propagates —
/// the native plane bails the session, GameStream stops the stream.
pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
packets: &[T],
budget: PaceBudget,
cfg: &PaceCfg,
mut send: impl FnMut(&[T]) -> Result<(), E>,
) -> Result<PaceStat, E> {
let start = Instant::now();
let sched = schedule(packets, cfg);
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
send(chunk)?;
}
let paced = sched.burst_len < packets.len();
if paced {
let pace_start = Instant::now();
let budget = match budget {
PaceBudget::UntilDeadline { deadline, fraction } => deadline
.checked_duration_since(pace_start)
.unwrap_or_default()
.mul_f32(fraction),
PaceBudget::Fixed(d) => d,
};
for (j, chunk) in packets[sched.burst_len..].chunks(sched.chunk).enumerate() {
send(chunk)?;
// Sleep toward this chunk's slice of the budget; skip sub-floor waits (jitter).
let target = pace_start + budget.mul_f64((j + 1) as f64 / sched.steps as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= cfg.sleep_floor {
std::thread::sleep(ahead);
}
}
}
}
Ok(PaceStat {
spread_us: start.elapsed().as_micros() as u32,
paced,
})
}
/// Parsed-once `PUNKTFUNK_VIDEO_DROP` percentage (1..=90, anything else = off): discard N % of
/// the sealed wire packets before send — controlled loss injection with no netem/root, honored
/// by BOTH video planes. Warned once on activation.
pub(crate) fn video_drop_pct() -> u32 {
static PCT: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*PCT.get_or_init(|| {
let pct = std::env::var("PUNKTFUNK_VIDEO_DROP")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|p| (1..=90).contains(p))
.unwrap_or(0);
if pct > 0 {
tracing::warn!(
pct,
"PUNKTFUNK_VIDEO_DROP: injecting wire-packet loss (FEC test)"
);
}
pct
})
}
/// Apply the [`video_drop_pct`] loss injection to one frame's wire packets, returning how many
/// were discarded (0 when the knob is off — the normal path is untouched).
pub(crate) fn inject_video_drop<T>(packets: &mut Vec<T>) -> u64 {
let pct = video_drop_pct();
if pct == 0 {
return 0;
}
use rand::Rng;
let mut rng = rand::thread_rng();
let before = packets.len();
packets.retain(|_| rng.gen_range(0..100) >= pct);
(before - packets.len()) as u64
}
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the
/// PUNKTFUNK_PERF histograms and the web-console stats sample's per-stage p50/p99.
pub(crate) fn percentile(v: &mut [u32], q: f64) -> u32 {
if v.is_empty() {
return 0;
}
v.sort_unstable();
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
v[i]
}
#[cfg(test)]
mod tests {
use super::*;
/// The native plane's canonical parameters (mirrors `punktfunk1::paced_submit`).
fn native_cfg(burst_cap: usize) -> PaceCfg {
PaceCfg {
burst_bytes: Some(burst_cap),
chunk: ChunkPolicy::Fixed(16),
sleep_floor: Duration::from_micros(500),
}
}
/// The GameStream plane's canonical parameters (mirrors `gamestream::stream::spawn_sender`).
fn gs_cfg() -> PaceCfg {
PaceCfg {
burst_bytes: None,
chunk: ChunkPolicy::Bounded {
min_chunk: 16,
max_steps: 12,
},
sleep_floor: Duration::from_micros(500),
}
}
fn packets(n: usize, len: usize) -> Vec<Vec<u8>> {
(0..n).map(|_| vec![0u8; len]).collect()
}
/// Deterministic-schedule pin, native plane: burst split + chunking + step count must
/// reproduce the legacy `paced_submit` math exactly — `split = first k with cum ≥ cap + 1`
/// (whole frame if never crossed), fixed 16-packet chunks, `m = ceil(overflow/16).max(1)`.
#[test]
fn native_schedule_matches_legacy_paced_submit() {
let legacy = |sizes: &[usize], burst_cap: usize| -> (usize, usize) {
// Verbatim transcription of the pre-dedup split + step-count computation.
let mut cum = 0usize;
let mut split = sizes.len();
for (k, len) in sizes.iter().enumerate() {
cum += len;
if cum >= burst_cap {
split = k + 1;
break;
}
}
let m = (sizes.len() - split).div_ceil(16).max(1);
(split, m)
};
for (n, len, cap) in [
(1usize, 1200usize, 128 * 1024usize), // tiny frame ≪ cap → all burst
(109, 1200, 128 * 1024), // exactly at the cap boundary region
(110, 1200, 128 * 1024), // one past
(600, 1200, 128 * 1024), // 4K P-frame: burst + paced overflow
(3300, 1200, 128 * 1024), // multi-MB IDR
(600, 1200, 0), // cap 0: first packet still bursts
(0, 1200, 128 * 1024), // empty (post-drop-injection) frame
] {
let pkts = packets(n, len);
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
let (split, m) = legacy(&sizes, cap);
let s = schedule(&pkts, &native_cfg(cap));
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
}
}
/// Deterministic-schedule pin, GameStream plane: no burst stage, and the chunk/step layout
/// must reproduce the legacy `pace_layout` exactly (chunk = max(16, ceil(n/12)), ≤ 12
/// steps) — including the historical bounds its old unit test asserted.
#[test]
fn gamestream_schedule_matches_legacy_pace_layout() {
let legacy_pace_layout = |n: usize| -> (usize, usize) {
let chunk_sz = 16usize.max(n.div_ceil(12));
(chunk_sz, n.div_ceil(chunk_sz))
};
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
let pkts = packets(n, 1024);
let (chunk, steps) = legacy_pace_layout(n);
let s = schedule(&pkts, &gs_cfg());
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
assert_eq!(s.steps, steps, "n={n}: step count");
assert!(s.steps <= 12, "n={n}: step count bounded");
assert!(s.chunk >= 16, "n={n}: chunk floor");
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
}
// The legacy test's exact anchors.
let s = schedule(&packets(1, 1024), &gs_cfg());
assert_eq!((s.chunk, s.steps), (16, 1));
let s = schedule(&packets(16, 1024), &gs_cfg());
assert_eq!((s.chunk, s.steps), (16, 1));
assert!(schedule(&packets(610, 1024), &gs_cfg()).steps <= 12);
}
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
/// zero budget, so the test never sleeps.
#[test]
fn pace_frame_sends_the_scheduled_chunk_sequence() {
// Native, 40 × 1 KB with a 10 KB cap: packets 0..=9 burst (cum hits 10 KB at #10),
// then 30 overflow → chunks of 16: [10..26), [26..40).
let pkts = packets(40, 1024);
let mut seen: Vec<usize> = Vec::new();
let stat = pace_frame(
&pkts,
PaceBudget::Fixed(Duration::ZERO),
&native_cfg(10 * 1024),
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(seen, vec![10, 16, 14]);
assert!(stat.paced);
// Native, frame under the cap: one immediate burst (chunked at 16), nothing paced.
let pkts = packets(20, 100);
let mut seen: Vec<usize> = Vec::new();
let stat = pace_frame(
&pkts,
PaceBudget::Fixed(Duration::ZERO),
&native_cfg(128 * 1024),
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(seen, vec![16, 4]);
assert!(!stat.paced);
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
let pkts = packets(146, 1024);
let mut seen: Vec<usize> = Vec::new();
pace_frame(
&pkts,
PaceBudget::Fixed(Duration::ZERO),
&gs_cfg(),
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(seen.len(), 10);
assert_eq!(seen.iter().sum::<usize>(), 146);
assert!(seen[..9].iter().all(|&c| c == 16));
assert_eq!(*seen.last().unwrap(), 2);
// A send error aborts the frame and propagates.
let pkts = packets(64, 1024);
let mut calls = 0;
let r = pace_frame(
&pkts,
PaceBudget::Fixed(Duration::ZERO),
&gs_cfg(),
|_chunk| {
calls += 1;
if calls == 2 {
Err(std::io::Error::other("client gone"))
} else {
Ok(())
}
},
);
assert!(r.is_err());
assert_eq!(calls, 2, "no sends after the failing chunk");
}
/// The sleep targets are each paced chunk's fraction of the budget — pinned against the
/// legacy formulas of both planes (native: `budget×(j+1)/m` directly; GameStream:
/// `(budget×1/steps)×(i+1)`, which agrees to sub-step-count nanoseconds).
#[test]
fn sleep_targets_match_legacy_formulas() {
let budget = Duration::from_micros(12_500); // GS: ¾ of a 60 Hz frame interval
for steps in [1usize, 2, 10, 12] {
for j in 0..steps {
let unified = budget.mul_f64((j + 1) as f64 / steps as f64);
// Native legacy: one fused fraction — identical expression.
assert_eq!(unified, budget.mul_f64((j + 1) as f64 / steps as f64));
// GameStream legacy: per_step rounds to ns first; ≤ steps/2 ns apart.
let gs_legacy = budget.mul_f64(1.0 / steps as f64).mul_f64((j + 1) as f64);
let diff = unified.abs_diff(gs_legacy);
assert!(
diff <= Duration::from_nanos(steps as u64),
"steps={steps} j={j}: {diff:?} off legacy"
);
}
}
}
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
#[test]
fn drop_injection_off_by_default() {
let mut pkts = packets(100, 64);
assert_eq!(inject_video_drop(&mut pkts), 0);
assert_eq!(pkts.len(), 100);
}
#[test]
fn percentile_picks_expected_ranks() {
let mut v = vec![90, 10, 50, 70, 30];
assert_eq!(percentile(&mut v, 0.0), 10);
assert_eq!(percentile(&mut v, 0.5), 50);
assert_eq!(percentile(&mut v, 0.99), 90);
assert_eq!(percentile(&mut [], 0.5), 0);
}
}
+10
View File
@@ -132,6 +132,16 @@ impl SessionPlan {
let gpu = { let gpu = {
let force_cpu_for_nvenc_444 = let force_cpu_for_nvenc_444 =
self.chroma.is_444() && !crate::encode::linux_zero_copy_is_vaapi(); self.chroma.is_444() && !crate::encode::linux_zero_copy_is_vaapi();
if gpu && force_cpu_for_nvenc_444 {
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
// it looks like an unexplained fps ceiling if you don't know it happened.
tracing::warn!(
"4:4:4 session on the NVENC path: zero-copy GPU capture DISABLED — every \
frame is CPU RGB + swscale RGBYUV444P; expect a lower fps ceiling than \
4:2:0 at this mode"
);
}
gpu && !force_cpu_for_nvenc_444 gpu && !force_cpu_for_nvenc_444
}; };
crate::capture::OutputFormat { crate::capture::OutputFormat {
+48 -3
View File
@@ -224,6 +224,25 @@ pub struct DisplayPolicy {
/// so existing `display-settings.json` files are untouched. /// so existing `display-settings.json` files are untouched.
#[serde(default)] #[serde(default)]
pub game_session: GameSession, pub game_session: GameSession,
/// EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →
/// DPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.
/// Targets the "connected-but-dark head" periodic-stutter class (monitor standby
/// auto-input-scan / DP link churn while the virtual display is the sole active display) at
/// the monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in
/// the OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across
/// preset changes; `#[serde(default)]` = off so existing `display-settings.json` files are
/// untouched.
#[serde(default)]
pub ddc_power_off: bool,
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
} }
fn one() -> u32 { fn one() -> u32 {
@@ -247,6 +266,8 @@ impl Default for DisplayPolicy {
layout: Layout::default(), layout: Layout::default(),
max_displays: 4, max_displays: 4,
game_session: GameSession::default(), game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
} }
} }
} }
@@ -306,6 +327,8 @@ impl EffectivePolicy {
&self, &self,
positions: BTreeMap<String, Position>, positions: BTreeMap<String, Position>,
game_session: GameSession, game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
) -> DisplayPolicy { ) -> DisplayPolicy {
DisplayPolicy { DisplayPolicy {
version: 1, version: 1,
@@ -319,8 +342,10 @@ impl EffectivePolicy {
positions, positions,
}, },
max_displays: self.max_displays, max_displays: self.max_displays,
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it). // Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
game_session, game_session,
ddc_power_off,
pnp_disable_monitors,
} }
} }
} }
@@ -434,6 +459,20 @@ impl DisplayPolicyStore {
self.get().game_session self.get().game_session
} }
/// The experimental DDC/CI panel-off axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn ddc_power_off(&self) -> bool {
self.get().ddc_power_off
}
/// The experimental PnP monitor-devnode-disable axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn pnp_disable_monitors(&self) -> bool {
self.get().pnp_disable_monitors
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk /// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing. /// write succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> { pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
@@ -749,9 +788,12 @@ mod tests {
let mut positions = BTreeMap::new(); let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated); let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
// The orthogonal game-session axis is preserved through the layout transform. // The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
// the transform.
assert_eq!(p.game_session, GameSession::Dedicated); assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
// Preset drops to Custom so the explicit fields (incl. the layout) rule… // Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom); assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim… // …every other behavior axis is preserved verbatim…
@@ -776,6 +818,9 @@ mod tests {
assert_eq!(p.keep_alive, KeepAlive::default()); assert_eq!(p.keep_alive, KeepAlive::default());
assert_eq!(p.topology, Topology::Auto); assert_eq!(p.topology, Topology::Auto);
assert_eq!(p.version, 1); assert_eq!(p.version, 1);
// A file written before the experimental DDC/PnP axes existed defaults them OFF.
assert!(!p.ddc_power_off);
assert!(!p.pnp_disable_monitors);
} }
#[test] #[test]
@@ -118,6 +118,12 @@ struct Monitor {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
pinger: Option<JoinHandle<()>>, pinger: Option<JoinHandle<()>>,
ccd_saved: Option<SavedConfig>, ccd_saved: Option<SavedConfig>,
/// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's
/// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0.
ddc_panels_off: u32,
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled
/// at this monitor's isolate — teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op). /// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
gen: u64, gen: u64,
} }
@@ -668,6 +674,8 @@ impl VirtualDisplayManager {
} }
} }
let mut ccd_saved: Option<SavedConfig> = None; let mut ccd_saved: Option<SavedConfig> = None;
let mut ddc_panels_off = 0u32;
let mut pnp_disabled: Vec<String> = Vec::new();
match &gdi_name { match &gdi_name {
Some(n) => { Some(n) => {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
@@ -682,10 +690,32 @@ impl VirtualDisplayManager {
use crate::vdisplay::policy::Topology; use crate::vdisplay::policy::Topology;
match topology_action() { match topology_action() {
Topology::Exclusive => { Topology::Exclusive => {
// EXPERIMENTAL `ddc_power_off` policy axis: command the physical panels
// dark over DDC/CI BEFORE the isolate — an HMONITOR (and with it the DDC
// channel) only exists while the display is still active. A panel that
// believes it has an owner skips its no-signal standby probing — the
// suspected source of the periodic sole-virtual-display stutter (the
// rationale + evidence live in `windows/ddc.rs`).
if crate::vdisplay::policy::prefs().ddc_power_off() {
ddc_panels_off = crate::ddc::panel_off_except(n);
}
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the // SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory // `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
// crosses), under the `state` lock — the sole topology mutator. // crosses), under the `state` lock — the sole topology mutator.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
// across hot-plug re-arrival) so a standby monitor/TV's periodic wake
// events no longer trigger the Windows reaction cascade — the suspected
// hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`).
if crate::vdisplay::policy::prefs().pnp_disable_monitors() {
if let Some(saved) = &ccd_saved {
pnp_disabled = crate::monitor_devnode::disable_for_deactivated(
saved,
added.target_id,
);
}
}
} }
Topology::Primary => { Topology::Primary => {
// On a headless box the IDD auto-activates as the SOLE display, so a physical // On a headless box the IDD auto-activates as the SOLE display, so a physical
@@ -743,6 +773,8 @@ impl VirtualDisplayManager {
stop, stop,
pinger: Some(pinger), pinger: Some(pinger),
ccd_saved, ccd_saved,
ddc_panels_off,
pnp_disabled,
gen: self.gen.fetch_add(1, Ordering::Relaxed), gen: self.gen.fetch_add(1, Ordering::Relaxed),
}) })
} }
@@ -804,7 +836,28 @@ impl VirtualDisplayManager {
} }
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays. // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
if let Some(saved) = &mon.ccd_saved { if let Some(saved) = &mon.ccd_saved {
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so the CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
if !mon.pnp_disabled.is_empty() {
crate::monitor_devnode::enable_instances(&mon.pnp_disabled);
thread::sleep(Duration::from_millis(300));
}
restore_displays_ccd(saved); restore_displays_ccd(saved);
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
// belt-and-braces for the rest. The brief settle wait lets the re-activated paths show
// up in EnumDisplayMonitors (no HMONITOR, no DDC channel); teardown is already
// seconds-scale and watched by the 10 s wedge logger above.
if mon.ddc_panels_off > 0 {
thread::sleep(Duration::from_millis(300));
let woken = crate::ddc::panel_on_all();
tracing::info!(
commanded_off = mon.ddc_panels_off,
woken,
"DDC/CI: panel wake commands sent after topology restore"
);
}
} }
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and // SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the // `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the

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