83 Commits

Author SHA1 Message Date
enricobuehler a9dc6efe55 fix(windows): drop the orphaned touch_last_used re-export
ci / web (push) Successful in 44s
ci / docs-site (push) Successful in 1m0s
ci / bench (push) Successful in 6m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m29s
ci / rust (push) Successful in 22m28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
decky / build-publish (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
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 10s
android-screenshots / screenshots (push) Successful in 3m6s
flatpak / build-publish (push) Successful in 7m12s
web-screenshots / screenshots (push) Successful in 2m53s
linux-client-screenshots / screenshots (push) Successful in 10m8s
docker / deploy-docs (push) Successful in 23s
arch / build-publish (push) Successful in 11m54s
deb / build-publish (push) Successful in 12m53s
windows-host / package (push) Successful in 15m26s
android / android (push) Successful in 15m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m16s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m59s
apple / swift (push) Successful in 4m56s
release / apple (push) Successful in 24m44s
apple / screenshots (push) Successful in 20m44s
More builtin-removal fallout: trust.rs re-exported pf_client_core::trust::
touch_last_used, whose only consumer was the deleted in-process session pump. In
a binary crate an unused pub-use is a hard -D warnings error (it surfaced only
after the gamepad dead-code errors were cleared, which had suppressed the
unused_imports pass). Drop it; every other re-export still has a user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:37:24 +02:00
enricobuehler 9822fc3b1c fix(windows): drop the orphaned in-process gamepad forwarding hooks
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m0s
ci / bench (push) Successful in 5m49s
android-screenshots / screenshots (push) Successful in 3m17s
ci / rust (push) Successful in 17m50s
decky / build-publish (push) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 59s
apple / swift (push) Successful in 4m25s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
android / android (push) Successful in 14m55s
arch / build-publish (push) Successful in 13m12s
deb / build-publish (push) Successful in 11m29s
web-screenshots / screenshots (push) Successful in 2m42s
docker / deploy-docs (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
linux-client-screenshots / screenshots (push) Successful in 7m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m36s
windows-host / package (push) Successful in 15m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m10s
release / apple (push) Successful in 22m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m59s
flatpak / build-publish (push) Successful in 5m41s
apple / screenshots (push) Successful in 19m29s
Removing the builtin stream path (ef580825) left GamepadService's attach/detach/
active/auto_pref + the Ctl::Attach/Detach variants with no callers — the spawned
punktfunk-session binary owns pad forwarding now. The client still compiled, but
clippy -D warnings tripped on the dead code. Drop the forwarding hooks + the
active-pad mirror; the service keeps pads() (Settings list) and set_pinned()
(persist the forwarded-pad selection the session child reads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:49:38 +02:00
enricobuehler cdb43f00fe style: rustfmt the freeze-until-reanchor client wiring
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m7s
ci / bench (push) Successful in 5m11s
decky / build-publish (push) Successful in 28s
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 40s
deb / build-publish (push) Successful in 11m37s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 15m30s
apple / swift (push) Successful in 4m19s
android / android (push) Successful in 16m52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m34s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10m14s
windows-host / package (push) Successful in 13m57s
flatpak / build-publish (push) Successful in 5m48s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m46s
ci / rust (push) Successful in 22m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m7s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m1s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m30s
release / apple (push) Successful in 21m2s
docker / deploy-docs (push) Successful in 21s
apple / screenshots (push) Successful in 19m51s
cargo fmt --all --check flagged the reanchor gate wiring (decode.rs / session.rs /
abi.rs / reanchor.rs): wrapped signatures + comparisons, and two multi-line comments
that followed a trailing-comment line were restructured to their own lines so
rustfmt keeps them at normal indentation instead of deep-aligning them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:36:43 +02:00
enricobuehler 644c035a34 feat(encode/amf): accept AMF runtime >=1.4.34 (graceful degrade) + log loaded amfrt64.dll identity
ci / rust (push) Failing after 47s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 56s
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 8s
docker / deploy-docs (push) Successful in 11s
ci / bench (push) Successful in 5m8s
android / android (push) Successful in 14m4s
arch / build-publish (push) Successful in 12m44s
deb / build-publish (push) Successful in 13m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m19s
windows-host / package (push) Successful in 9m36s
apple / swift (push) Successful in 4m40s
apple / screenshots (push) Successful in 19m47s
The Windows AMF encoder hard-rejected any runtime <1.4.36 — a Jan-2025
(Adrenalin 25.1.1) driver floor. Every AMD host on an older driver failed the
session with "update the AMD driver" after 8 retries, notably Boot Camp Macs
whose bundled amfrt64.dll lags far behind.

Split the single pin:
- AMF_MIN_VERSION (1.4.34): the ABI floor accepted at load. Every vtable slot
  the FFI mirrors is a base-interface slot stable since well before 1.4.34; the
  1.4.35/1.4.36-only features are string-keyed encoder properties already applied
  via set_prop(required=false), which log-and-continue — so an older driver
  degrades those features individually instead of failing.
- AMF_HEADER_VERSION (1.4.36): the header the mirror targets, now passed to
  AMFInit capped at min(header, runtime) so claiming a version newer than the
  runtime can't make AMFInit reject an otherwise-usable older driver.

No functionality removed: a >=1.4.36 runtime behaves exactly as before.

Also logs, once per process, the AMF runtime version AND the loaded amfrt64.dll's
full path + file-version resource (via GetModuleFileNameW + VerQueryValueW). This
surfaces the Boot Camp failure mode where the display driver reads 25.x but the
System32 amfrt64.dll is a stale build reporting an old AMF version; the too-old
decline now names the DLL path/build and points at reboot + DDU reinstall.

Not compile-verified: amf.rs is Windows-only and this Linux box can't cross-build
it (a dependency's C build fails for the msvc target). Needs cargo check/clippy on
the Windows build box / CI. rustfmt-clean; the windows-crate FFI signatures were
verified against the on-disk 0.62.2 bindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:32:51 +02:00
enricobuehler 3c16c1dd30 chore(release): bump workspace version to 0.10.0
audit / bun-audit (push) Successful in 12s
ci / rust (push) Failing after 42s
ci / docs-site (push) Successful in 48s
ci / web (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 2m35s
ci / bench (push) Successful in 5m56s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m9s
apple / swift (push) Successful in 4m44s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m31s
apple / screenshots (push) Successful in 19m30s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
android-screenshots / screenshots (push) Successful in 3m19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m58s
windows-host / package (push) Successful in 9m32s
linux-client-screenshots / screenshots (push) Successful in 8m5s
docker / deploy-docs (push) Successful in 22s
web-screenshots / screenshots (push) Successful in 2m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m36s
arch / build-publish (push) Successful in 12m38s
deb / build-publish (push) Successful in 12m44s
android / android (push) Successful in 13m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m59s
release / apple (push) Successful in 24m1s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m20s
flatpak / build-publish (push) Failing after 8m2s
Bumps [workspace.package] version 0.9.2 -> 0.10.0 (14 workspace crates). Apple
MARKETING_VERSION / Android versionName are set from the release tag by CI, so no
client manifest changes; the nested Windows-driver workspace keeps its independent
0.0.1 version.

Also syncs Cargo.lock: the version bump for the 14 members, plus dropping the
now-orphaned crossbeam-channel entry the Windows builtin removal (ef580825) left
behind (it dropped the dep from the manifest but not the lock), so
`cargo build --locked` (ci.yml / deb.yml) stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:32:26 +02:00
enricobuehler c0fc2d8ee8 feat(apple): iPad ⌃⌥⇧Q release chord + click-to-recapture, pixel-grid snap, match-window opt-in
ci / docs-site (push) Successful in 49s
ci / rust (push) Failing after 51s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Failing after 4m20s
apple / swift (push) Successful in 4m43s
arch / build-publish (push) Failing after 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 4m8s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m21s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m33s
windows-host / package (push) Successful in 9m2s
flatpak / build-publish (push) Failing after 8m11s
android / android (push) Successful in 12m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m3s
release / apple (push) Successful in 21m56s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m31s
apple / screenshots (push) Successful in 19m11s
- InputCapture / StreamViewIOS: iPad ⌃⌥⇧Q un-capture chord, recognized from the
  GCKeyboard HID stream (no NSEvent monitor on iOS) for cross-client parity with
  the macOS/Windows/Linux combo; and a click into the video re-engages capture —
  the iPad analogue of macOS mouseDown → engageCapture(fromClick:), with the
  engaging click suppressed toward the host.
- SessionPresenter: snap the aspect-fit sublayer frame to the backing pixel grid.
  AVMakeRect centers the fit rect at fractional points, so the compositor
  resampled the layer — a uniform "everything soft" blur even when the drawable
  was pixel-exact 1:1. Rounding origin + size to device pixels makes the composite
  a true 1:1 blit; idempotent when already aligned.
- MetalVideoPresenter: PUNKTFUNK_BILINEAR_LUMA=1 A/B lever — compiles the shader
  with Catmull-Rom luma off (plain bilinear) to isolate bicubic overshoot from
  upstream fringing.
- SettingsView / StreamView / StreamViewIOS: match-window reverted to opt-in
  (default OFF) — the explicit mode is used and never auto-resized unless enabled.
2026-07-13 01:22:09 +02:00
enricobuehler ef5808254a refactor(windows): remove the legacy in-process builtin stream path
The real Windows client is the spawned punktfunk-session Vulkan binary
(pf-client-core); the in-process builtin GUI stream — reachable only via
PUNKTFUNK_BUILTIN_STREAM=1 — was dead weight kept alive by nothing and a
recurring source of wasted effort. Remove it: delete present/render/input/
audio.rs and the builtin remainder of session/video.rs, rip all the builtin
wiring (app/mod, connect, stream), and make connect always spawn.

Preserve the two shipped keepers that happened to live in those files by
relocating them to a new probe.rs: run_speed_probe (the per-host network speed
test used by the Settings speed page and --headless --speed-test) and
decodable_codecs (the codec-capability advert on the probe connect). Trim gpu.rs
to just the Settings adapter picker (adapter_names + helpers). --headless now
supports only --speed-test — the in-process decode/frame-counter went with the
pump.

Drops the now-orphaned deps opus, wasapi, crossbeam-channel, anyhow; keeps
ffmpeg-next (probe::decodable_codecs still needs it). Net 4432 deletions.
Statically verified (module wiring, imports, orphaned symbols/deps all clean);
the type-level compile runs on the windows-amd64 CI runner, which has the
toolchain this non-Windows host lacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:22:09 +02:00
enricobuehler 8a18e130a2 feat(client): freeze-until-reanchor loss recovery on Android + Apple via shared core gate
After unrecoverable loss the host keeps sending delta frames that reference a
picture the client never received; hardware decoders conceal these as gray/
garbage with a success status. Linux already withheld them and held the last
good frame until a proven clean re-anchor — this brings that behavior to the
Android and Apple clients.

Extract the Linux pump's freeze state machine into a shared `ReanchorGate` in
punktfunk-core (reanchor.rs, 18 tests) exposed over the C ABI (ABI v6, additive —
no wire change) for the Swift clients. Migrate the Linux/Deck pump
(pf-client-core) onto it as the parity proof (no-op refactor). Then wire:

- Android (decode.rs, both sync + async loops): arm on the frame-index gap, a
  pts-keyed flag map carries the wire flags to the output-buffer release, fold
  the gate per drained output, gate.poll replaces the dropped-climb block.
- Apple Stage2Pipeline (default): arm on a gap (new noteFrameIndexGap), withhold
  at the ring-submit seam (CAMetalLayer holds its last drawable), poll
  framesDropped, fold VT decode errors through the no-output streak.
- Apple StreamPump (stage-1): fold at enqueue, withhold via
  kCMSampleAttachmentKey_DoNotDisplay so the layer keeps decoding (reference
  chain intact) but holds the last displayed frame.
- Apple VideoDecoder: thread the AU's wire flags to the async decode callback via
  a retained FrameContext refcon (replaces the receivedNs bit-pattern scalar).

Lifts only on a proven re-anchor (IDR / RFI anchor / 2nd recovery mark) with a
500 ms backstop so a lost re-anchor can never freeze forever. Apple: swift build
clean, 123/123 tests pass (incl. VideoToolboxRoundTripTests). On-glass
loss-injection validation still owed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:22:09 +02:00
enricobuehler cd701a9594 fix(vdisplay): preserve FramePublisher across swap-chain reassign (sibling-join freeze)
windows-drivers / probe-and-proto (push) Successful in 28s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
windows-drivers / driver-build (push) Successful in 1m35s
apple / swift (push) Successful in 4m34s
ci / bench (push) Successful in 5m39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m50s
docker / deploy-docs (push) Successful in 33s
windows-host / package (push) Successful in 10m8s
android / android (push) Successful in 12m24s
arch / build-publish (push) Successful in 16m41s
deb / build-publish (push) Successful in 17m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m21s
ci / rust (push) Successful in 23m52s
apple / screenshots (push) Successful in 20m9s
When a second client got its own virtual display mid-stream, the FIRST
client's IDD-push stream froze (video only; `new_fps=0 repeat_fps=240`
forever). Adding/removing/resizing a sibling display re-commits the CCD
topology, which makes the OS unassign+reassign the first monitor's IddCx
swap chain. `unassign_swap_chain` dropped the SwapChainProcessor, dropping
`run_core`'s local FramePublisher and closing the sealed-channel handles.
The fresh worker then polled the frame-channel stash — but that stash is
consumed once at session open, and the host only re-delivers on a ring
recreate (a descriptor change). The first monitor's descriptor didn't
change and WUDFHost stayed alive, so no watchdog fired: the driver drained
the swap chain without publishing and the host repeated its last frame
indefinitely. Confirmed twice on the .173 box (host.log 21:12 & 21:15).

Preserve the live FramePublisher across the flap instead of dropping it:
the host-owned ring (header/event/textures) it holds stays valid — only
the swap chain died.

- frame_transport.rs: FramePublisher records its render-adapter LUID +
  exposes render_adapter().
- monitor.rs: MonitorObject.preserved_publisher + preserve_publisher()
  (mirrors set_frame_channel) + take_preserved_publisher() (mirrors
  take_frame_channel). Monitor teardown drops the stashed publisher and
  closes its ring handles, so nothing leaks.
- swap_chain_processor.rs run_core: after SetDevice OK, re-adopt a
  preserved publisher ONLY when the new swap chain renders on the same
  LUID (same pooled Direct3DDevice → its context + opened textures are
  valid); on loop exit, stash the publisher back on the monitor.

Safe: the old worker is fully joined (drop-outside-lock discipline)
before the new one runs, so no concurrent context use; a stale re-adopted
publisher is superseded by the existing is_stale() + has_frame_channel()
newest-wins checks at the loop top.

Verified clippy -D warnings clean on rustc 1.96.0 via a faithful mock
crate (the real crate needs the WDK to compile). Needs a driver rebuild +
reinstall on the host to take effect; not yet hardware-validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:19:20 +02:00
enricobuehler 05868ef634 fix(encode): Vulkan-HEVC full-RPS reference retention + AV1 feature gate (RFI review)
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
decky / build-publish (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
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 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 52s
apple / swift (push) Successful in 4m47s
ci / bench (push) Successful in 6m2s
docker / deploy-docs (push) Successful in 24s
windows-host / package (push) Successful in 8m6s
arch / build-publish (push) Successful in 11m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m19s
android / android (push) Successful in 16m58s
deb / build-publish (push) Successful in 17m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m38s
ci / rust (push) Successful in 24m8s
apple / screenshots (push) Successful in 20m38s
2026-07-12 review of the host encoders / client decoders / RFI plane.
NVENC (both), AMF-LTR, the session glue, and the client RfiTracker came
out clean; every fix lands in the Vulkan Video backend + dispatch:

1. HEVC: author each P-frame's short-term RPS to retain ALL resident DPB
   pictures (minus the setup slot), not just its one reference. HEVC
   8.3.2 evicts unlisted pictures, and clients keep FEEDING the decoder
   while frozen — so with the old single-pic RPS, a conforming parser
   (FFmpeg = the Linux VAAPI/Vulkan and Windows D3D11VA clients) had
   already discarded the picture an RFI recovery anchor references
   whenever a fed post-loss frame preceded it: generate_missing_ref, and
   the "clean" anchor plus everything chained after it decodes as
   garbage. Pure builder (`build_h265_rps_s0`) + unit tests; AV1 needs
   nothing (slot-based retention). The smoke test now encodes a fed
   post-loss frame between loss@4 and anchor@6 so an ffmpeg decode of
   the dropped dump exercises exactly this (expect ONE POC-4 complaint,
   never POC 3) — revalidate on the AMD box; this NVIDIA dev box fails
   the backend earlier at HEVC header retrieval (pre-existing).

2. AV1: chain PhysicalDeviceVideoEncodeAV1FeaturesKHR (videoEncodeAV1 =
   TRUE, stype 1000513004) into device creation — spec-required for the
   ENCODE_AV1 codec op; RADV tolerated the omission, validation layers
   and stricter drivers do not.

3. RFI decline no longer self-arms force_kf — that bypassed the session
   glue's 750 ms IDR cooldown, turning a storm of hopeless RFI requests
   into one full IDR each. Decline like NVENC/AMF and let the caller's
   coalesced keyframe path own the fallback; add the missing
   first>last guard for parity.

4. open_video_backend now returns the label of the branch that ACTUALLY
   opened, so the mgmt API / web console reports "vulkan" instead of
   "vaapi" for the default-on Vulkan sessions (the old dispatch-mirror
   resolved_backend_label went stale when the backend gained its VAAPI
   fallback; deleted).

Structure: the ~230-line inline HEVC coding block moves to
record_coding_h265 (symmetric with record_coding_av1) and the duplicated
pre-encode barriers dedupe into begin_encode_cmd.

Follow-up plan (separate, punktfunk-planning): bring the post-loss
freeze + RECOVERY_ANCHOR/POINT lift to the Android/Windows/Apple clients
via a shared ReanchorGate (design/client-reanchor-freeze-parity.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:12:12 +02:00
enricobuehler 2d37835545 feat(encode): AV1 on the Linux Vulkan Video encoder (real RFI)
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
ci / bench (push) Successful in 5m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 12m32s
docker / deploy-docs (push) Successful in 21s
android / android (push) Successful in 13m27s
ci / rust (push) Successful in 17m43s
deb / build-publish (push) Successful in 13m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m36s
windows-host / package (push) Successful in 14m43s
apple / swift (push) Successful in 4m42s
apple / screenshots (push) Successful in 19m20s
Extend the raw Vulkan Video backend to AV1 (`VK_KHR_video_encode_av1`)
alongside HEVC, so AMD/Intel Linux hosts get the same clean-P-frame loss
recovery for AV1 that HEVC already has — no full IDR on packet loss.

ash 0.38.0+1.3.281 predates the AV1-encode extension (finalized in Vulkan
1.3.290) and bumping ash breaks the SDL/Vulkan client (it drops the
lifetime on AllocationCallbacks, which sdl3-sys still generates). So the
AV1-encode structs/flags/enums are vendored host-only in
`encode/linux/vk_av1_encode.rs`, copied verbatim from ash-master's
generated code and chained into ash's generic video-encode calls via raw
p_next — the common StdVideoAV1* types (from AV1 decode) are reused from
ash 1.3.281.

`vulkan_video.rs` gains a parallel AV1 path: AV1 Main profile/caps/session
(+ max-level session-create), a bit-packed sequence-header OBU + per-frame
temporal-delimiter framing (Vulkan AV1 encode, unlike H26x, emits only the
frame OBU), and per-frame StdVideoEncodeAV1PictureInfo with the RFI
reference model — a normal P inherits CDF context from its reference for
compression, while an IDR or recovery anchor sets primary_ref_frame=NONE +
error_resilient_mode so it decodes independent of the (possibly lost)
frames since its reference. HEVC recording is unchanged; the shared CSC /
ring / DPB-barrier pipeline is reused as-is. Codec routing in
`open_video_backend` extends the HEVC arm to HEVC|AV1.

The seq header enables only order-hint (+128px superblocks per caps),
matching FFmpeg's proven Vulkan AV1 config — enabling CDEF/restoration made
VCN emit frame-header sections our seq header didn't match, desyncing every
inter frame.

Headless-validated on real RADV (780M): open + 6-frame encode (I P P P P P)
decodes 0-error on both dav1d and ffmpeg/cbs; the RFI recovery anchor at
frame 4 is a clean P (not IDR), and dropping the "lost" frame 3 still
decodes clean (re-anchored to frame 2). HEVC smoke unchanged (no
regression). `cargo clippy --features vulkan-encode -- -D warnings` and the
no-feature build both green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:06:24 +02:00
enricobuehler 9514a8c0e2 fix(client): correct Linux/Windows "Forwarded controller" copy for multi-controller
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 56s
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 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
decky / build-publish (push) Successful in 52s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 4m39s
ci / bench (push) Successful in 6m27s
windows-host / package (push) Successful in 8m34s
flatpak / build-publish (push) Successful in 7m15s
docker / deploy-docs (push) Successful in 24s
android / android (push) Successful in 12m49s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m16s
deb / build-publish (push) Successful in 12m50s
arch / build-publish (push) Successful in 16m32s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m44s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m53s
ci / rust (push) Successful in 25m18s
release / apple (push) Successful in 22m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m57s
apple / screenshots (push) Successful in 18m56s
The relm4 (Linux) and native (Windows) shells spawn punktfunk-session, which
since the native-plane rework forwards ALL controllers by default — but the
"Forwarded controller" settings dropdowns still described the pre-rework world
("Automatic (most recent)", "Exactly one controller is forwarded to the host").

The dropdown already lists every detected pad and wires set_pinned(None)=all /
set_pinned(key)=single-player; this fixes only the misleading labels, subtitle,
tooltip, and stale leading comments to match: Automatic forwards every real
controller (each its own player); pick one to force single-player.

cargo check -p punktfunk-client-linux green; Windows is windows-gated (pure
string edits, CI windows.yml).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:07 +02:00
enricobuehler 97c67b2692 feat(apple): multi-controller support
Roll the pf-client-core slot pattern to the Apple client (Swift):

- GamepadManager tracks all connected GCControllers, assigning each a stable
  lowest-free wire pad index + concrete type, emitting GamepadArrival on
  connect and GamepadRemove on disconnect (index freed for reuse on re-plug).
- GamepadCapture binds every controller with per-controller Slot state
  (buttons/axes/fingers/motion), threading the pad index into flags on every
  event; GamepadWire/InputEvents carry the pad + the two new events.
- GamepadFeedback + RumbleRenderer go per-pad (rumbleByPad, slots[pad]),
  routing rumble/HID back to the correct controller by wire index.
- ContentView/Settings surface every forwarded controller.

pad 0 => flags 0, so single-controller wire is byte-identical. Cannot build
on the Linux dev box (no Swift toolchain / Apple frameworks); wire bytes
hand-checked against input.rs and GamepadWireTests extended for multi-pad.
CI apple.yml (swift build/test on macOS) is the compile gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:07 +02:00
enricobuehler 0ad4e6eff7 feat(android): multi-controller support
Roll the pf-client-core slot pattern to the Android client (Kotlin + JNI):

- New kit/GamepadRouter.kt: the Android analogue of the client-core Slot
  model — a deviceId→Slot map assigning each InputDevice a stable lowest-free
  wire pad index held for its lifetime, GamepadArrival(pref) before a pad's
  first input, GamepadRemove on onInputDeviceRemoved, per-slot AxisMapper +
  held-bitmask so two pads never clobber each other. The isForwardable gate
  (excludes DualSense/DS4 all-zero sensor sibling nodes) is centralized in
  slotFor so no entry point can open a phantom slot.
- native/src/session/input.rs: JNI shims take a pad arg -> flags=pad
  (nativeSendGamepadButton/Axis, plus nativeSendGamepadArrival/Remove).
- native/src/feedback.rs: pad carried in rumble bits 49..52 + a leading
  hidout pad byte; GamepadFeedback.kt routes rumble/lightbar/LED back to the
  originating device by pad via deviceForPad.
- MainActivity.kt routes key/motion events by device; ControllersScreen.kt
  badges every forwarded pad (was hardcoded i==0), reading getControllerNumber.

A lone controller lands on wire index 0, so its per-transition datagrams stay
byte-identical to the old single-pad path. gradle :app:assembleDebug green
(Rust cross-compiled via cargo-ndk); JNI signatures hand-verified 1:1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:07 +02:00
enricobuehler 76be4c3e12 feat(gamepad): multi-controller support on the native plane
Host was already built for 16 pads; the blocker was every client
hard-coding pad 0. This lands the host-side + reference-client contract:

- input.rs: new wire kinds GamepadArrival=14 (declares a pad's type:
  code=GamepadPref byte, flags=pad) and GamepadRemove=13 (flags=seq<<24|pad,
  shares the snapshot seq space via encode/decode_gamepad_remove).
- pf-client-core/gamepad.rs: reworked from a single `open` pad to a
  slots: Vec<Slot> model — every forwarded controller gets a stable
  lowest-free wire index held for its lifetime, per-slot held/axis/touch/
  rumble state, GamepadArrival on open + GamepadRemove on close, and
  feedback routed back per wire index. Automatic forwards all real pads;
  a pin forces single-player.
- punktfunk1.rs: replaced the single-session PadBackend enum with a Pads
  router — per-pad kinds[]/owner[] arrays, lazily-created per-kind managers,
  pure route_decision keeping a live device in its manager across a kind
  change (no ghost/dup). Input thread seq-gates GamepadRemove (clears the
  pad_mask bit, resets rumble) and applies GamepadArrival kinds.
- inject linux/windows backends: add the two new no-op InputKind arms.

Native/session + default-Windows clients (both spawn punktfunk-session)
inherit this. 57 core + 33 client-core + 272 host tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:53:07 +02:00
enricobuehler 84329205eb feat(encode): default-on the Linux Vulkan Video HEVC backend
ci / docs-site (push) Successful in 59s
ci / web (push) Successful in 1m7s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
apple / swift (push) Successful in 4m13s
ci / bench (push) Successful in 7m13s
windows-host / package (push) Successful in 9m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 28s
android / android (push) Successful in 12m35s
deb / build-publish (push) Successful in 12m46s
arch / build-publish (push) Successful in 16m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m32s
apple / screenshots (push) Successful in 18m49s
ci / rust (push) Successful in 25m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m0s
On-glass validated 2026-07-12 on an AMD RADV 780M with a real Deck-class
client: the pipelined raw-Vulkan HEVC encoder ran a rock-solid 1080p@240
session and healed loss with clean P-frame recovery anchors (real RFI the
libav VAAPI path can't express). Ship it on by default, mirroring the NVENC
default-on.

- vulkan_encode_enabled() defaults ON; PUNKTFUNK_VULKAN_ENCODE=0 is the libav
  VAAPI escape hatch. A failed open still falls back to VAAPI, so a device
  without h265 Vulkan encode (or an untested Intel/ANV that misbehaves at open)
  degrades gracefully instead of breaking the stream.
- Ring depth defaults to 2 (one frame of overlap, lowest added latency — the
  on-glass-validated real-time setting); PUNKTFUNK_VULKAN_INFLIGHT still tunes it.
- Compile --features punktfunk-host/vulkan-encode into the arch/deb/rpm host
  builds (pure-Rust ash, no new lib / no link-time dep), alongside nvenc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:20:31 +02:00
enricobuehler e9d8f2bc04 perf(encode): pipeline the Vulkan Video encoder (frames in flight)
The Linux raw-Vulkan HEVC backend blocked on two GPU fences per frame, so
CPU readback and the next capture could not overlap the GPU encode. Refactor
into a small ring of per-in-flight-frame resources (own command buffers,
CSC descriptor set + Y/UV/NV12 scratch, bitstream buffer, feedback query and
sync objects) so submit() records into a free slot and returns without
waiting, and poll() reads back the oldest slot once its fence signals. The
pump's non-blocking poll then overlaps a frame's CSC+encode with the next
capture — the throughput win — with no capturer/pump change (VAAPI untouched).

- New `Frame` struct + `make_frame`; encoder holds `frames`/`ring`/`in_flight`.
- `record_submit` (non-blocking) + `read_slot` (fence-gated readback) replace
  the synchronous `encode_frame`; `enqueue` applies backpressure by draining
  the oldest slot when the ring is full.
- DPB self-barrier between consecutive encodes: orders frame N's reconstruct
  write before N+1's reference read now that they can be in flight together.
- flush() drains all in-flight slots in order; reset() waits idle + discards.
- Ring depth defaults to 3, overridable via PUNKTFUNK_VULKAN_INFLIGHT (2..=6).
- Smoke test drains via poll-loop + flush (async breaks one-AU-per-submit).

Headless-validated on real RADV 780M: cargo check (feature on/off) + clippy
-D warnings + rustfmt clean; smoke test at ring depth 2/3/6 all ffmpeg-decode
clean (I P P P P P) and drop-heal (I P P P P) with 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:51:21 +02:00
enricobuehler bbbb7f5723 fix+perf(encode): clamp Vulkan CSC to source edge + cache dmabuf imports
Two refinements after the initial on-glass validation on RADV (780M):

- Green padding bar at non-16-aligned heights (e.g. 1080 → coded 1088): the CSC
  compute shader read past the edge of the shorter source dmabuf for the 8
  alignment-padding rows, producing undefined/green garbage that showed on a
  client rendering the coded frame. Clamp every source fetch to `textureSize-1`
  so padding rows duplicate the last real row (invisible, and the SPS
  conformance window still crops it for a compliant decoder). BT.709 conversion
  is byte-identical for in-bounds pixels. 5120x1440 (exactly aligned) was never
  affected.

- Per-frame dmabuf import churn: the backend created + imported + destroyed a
  VkImage every frame (allocation jitter → stutter). PipeWire cycles a small
  fixed pool, so import each underlying buffer ONCE (keyed by st_dev/st_ino —
  each frame's fd is a fresh dup of the same buffer) and reuse it, matching the
  CUDA-path VkBridge. First import acquires from the foreign producer; cached
  re-reads keep queue ownership and use a plain visibility barrier. On-glass:
  ~3-6 imports per session then silent (was ~one per frame at 240 Hz), stutter
  gone at resolutions with headroom.

Also adds a PF_SMOKE_W/H override to the headless smoke test to exercise the
conformance-window crop path (ffprobe confirms coded 1088 → displayed 1080).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:24:12 +02:00
enricobuehler 5ab6daa694 feat(encode): raw Vulkan Video HEVC backend on Linux (AMD/Intel) with real RFI
Add `VulkanVideoEncoder` (`VK_KHR_video_encode_h265` via ash) — the open-stack
twin of the direct-NVENC RFI path, giving AMD/Intel Linux hosts real
reference-frame invalidation loss recovery: a clean P-frame recovery anchor
that re-references a known-good older frame instead of a full IDR. The app owns
the DPB, so recovery = pointing the P-frame's single L0 reference at a resident
slot strictly older than the loss (never a concealed frame).

The backend owns its own ash instance/device with encode + compute queues,
authors VPS/SPS/PPS (Main, conformance-window crop for non-16-aligned heights
like 1080->1088), runs a DPB-ring reference-slot state machine with monotonic
POC and CBR rate control, and does an on-GPU RGB->NV12 BT.709 compute CSC
(embedded rgb2yuv.spv) since capture delivers packed-RGB dmabufs — importing
each frame's dmabuf (explicit DRM modifier) or uploading a CPU-RGB fallback,
CSC on the compute queue, then encode on the encode queue, ordered by a
semaphore.

Wired into `open_video_backend`: an AMD/Intel HEVC session opens this instead
of libav VAAPI when `PUNKTFUNK_VULKAN_ENCODE=1` (VAAPI fallback on any open
error, so it can only improve recovery, never break a stream); `PUNKTFUNK_
ENCODER=vulkan` forces it. Gated behind the new `vulkan-encode` Cargo feature,
which pulls no new dependency (reuses the `ash` bindings already carried for
the dmabuf zero-copy bridge). Opt-in until on-glass validated, mirroring how
the direct-NVENC path shipped.

Headless-validated on real RADV (RDNA3 780M, Mesa 26): open + multi-frame
encode + `invalidate_ref_frames` all run through the real struct and ffmpeg
decodes the output `I P P P P P` with 0 errors; the recovery frame is a clean
P-frame (not an IDR); and dropping the "lost" AU still decodes cleanly because
the recovery re-anchored to an older frame — the RFI heal, proven on real
hardware. `cargo check`/`clippy -D warnings` green with the feature on and off.

Design: design/linux-vulkan-video-encode.md. Harness: design/vkenc-probe-harness/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:08:15 +02:00
enricobuehler 76594f27c1 feat(nvenc): default-on the Linux direct-SDK NVENC path
ci / web (push) Successful in 45s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
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 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 4m49s
ci / bench (push) Successful in 6m13s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 14m19s
arch / build-publish (push) Successful in 20m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m28s
android / android (push) Successful in 21m8s
deb / build-publish (push) Successful in 20m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m25s
ci / rust (push) Successful in 24m14s
apple / screenshots (push) Successful in 21m0s
On-glass validated 2026-07-12 on an RTX 5070 Ti host with a real Steam Deck
client (NV12 4:2:0, 1280x800@90): real nvEncInvalidateRefFrames landed 73/73
as clean P-frame recovery anchors (never IDR), losses consistently ~2 frames
deep — well inside the 5-frame DPB. That is the loss recovery the libav
hevc_nvenc path cannot express, so make the direct path the default on NVIDIA.

PUNKTFUNK_NVENC_DIRECT=0 (also false/no/off) is now the libav escape hatch.
Still gated on a CUDA capture payload — the `cuda` check in open_nvenc_probed
keeps AMD/Intel on VAAPI regardless, and the NVENC/CUDA entry points stay
dlopen'd at runtime (no new DT_NEEDED), so non-NVIDIA hosts are unaffected.
Packaging comments updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:34:53 +02:00
enricobuehler e89b2f60eb build(linux): enable --features nvenc in the arch/deb/rpm host builds
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m3s
decky / build-publish (push) Successful in 20s
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 (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
ci / bench (push) Successful in 6m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m7s
docker / deploy-docs (push) Successful in 20s
deb / build-publish (push) Successful in 12m12s
apple / swift (push) Successful in 5m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m55s
arch / build-publish (push) Successful in 15m6s
android / android (push) Successful in 20m8s
ci / rust (push) Successful in 23m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m36s
apple / screenshots (push) Successful in 20m26s
The Phase 5.2 direct-SDK NVENC Linux backend (encode/linux/nvenc_cuda.rs) is
gated `#[cfg(all(target_os = "linux", feature = "nvenc"))]`, but no Linux
packager passed `--features nvenc` (it was historically a Windows-only feature
for the D3D11 NVENC path). So the module was compiled OUT of every arch/deb/rpm
canary regardless of commit — PUNKTFUNK_NVENC_DIRECT was a silent no-op on the
shipped Linux host. Add `--features punktfunk-host/nvenc` to all three package
builds so the code actually ships.

AMD/Intel-SAFE (verified): this is NOT the old Windows link-import crash. The
NVENC/CUDA entry points are dlopen'd at RUNTIME (libloading) — `objdump -p` shows
the feature build's DT_NEEDED is byte-identical to a plain build (no libcuda /
libnvidia-encode), so the binary starts fine driver-less. We use only the crate's
`sys::nvEncodeAPI` types (no code-running safe wrapper / lazy statics), cudarc
stays on `ci-check`/dynamic-loading (no CUDA toolkit at build), and the encoder is
only constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT — never on a
VAAPI (AMD/Intel) host. The sysext images repackage these outputs, so they inherit
it; no other Linux host build compiles the binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:17:19 +02:00
enricobuehler 63bc2bb10f docs(web-console): fix stale http:// URLs — the console serves HTTPS on :47992
ci / docs-site (push) Successful in 48s
ci / web (push) Successful in 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
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 52s
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
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (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
windows-host / package (push) Has been cancelled
The punktfunk-web unit serves HTTP/1.1 over TLS with the host's own
self-signed identity cert, but several guides still told users to open
http://<host>:47992, which fails. Correct the scheme everywhere and note
the one-time browser cert warning in the canonical + SteamOS docs.

The RPM %post web hint was doubly wrong (http://<host-ip>:3000): wrong
scheme and wrong port — the service listens on :47992, not the :3000 dev
default. Also fixes scripts/web-init.sh, so the URL the SteamOS/Linux
installer prints at the end of setup is correct too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:14:52 +02:00
enricobuehler ad532b08a0 style(encode): rustfmt the direct-SDK NVENC Linux backend
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 52s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 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 6s
ci / bench (push) Successful in 6m1s
docker / deploy-docs (push) Successful in 20s
arch / build-publish (push) Successful in 12m23s
android / android (push) Successful in 13m26s
deb / build-publish (push) Successful in 14m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m22s
ci / rust (push) Successful in 18m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m14s
windows-host / package (push) Successful in 14m15s
apple / swift (push) Successful in 4m49s
apple / screenshots (push) Successful in 20m11s
`cargo fmt` was not run on the Phase 5.2 additions (nvenc_cuda.rs + the encode.rs
dispatcher fork), failing the ci.yml rust fmt job. Whitespace/wrapping only — no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:35:07 +02:00
enricobuehler 93093f3cf9 feat(encode): direct-SDK NVENC on Linux (CUDA input) with real RFI
ci / rust (push) Failing after 1m1s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
apple / swift (push) Successful in 4m42s
ci / bench (push) Successful in 7m37s
docker / deploy-docs (push) Successful in 22s
flatpak / build-publish (push) Successful in 6m51s
windows-host / package (push) Successful in 14m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m59s
arch / build-publish (push) Successful in 16m48s
deb / build-publish (push) Successful in 17m14s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m49s
android / android (push) Successful in 18m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m1s
release / apple (push) Successful in 22m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m40s
apple / screenshots (push) Successful in 18m53s
Phase 5.2 of design/encoder-recovery-hardening.md (design/linux-direct-nvenc.md).
The Linux NVIDIA host encodes through libavcodec `hevc_nvenc`, which structurally
cannot express `nvEncInvalidateRefFrames` — so every FEC-unrecoverable loss is a
full IDR and, since the client freezes-until-reanchor, a per-loss freeze for
RTT+IDR-encode. This ports the Windows raw-NVENC backend to
NV_ENC_DEVICE_TYPE_CUDA over the shared CUcontext so Linux NVIDIA gets the same
real RFI + F2 recovery-anchor + reset() stall lever + HDR-SEI/Main10 plumbing.

New `encode/linux/nvenc_cuda.rs` (`NvencCudaEncoder`):
- runtime-loaded entry table via `dlopen libnvidia-encode.so.1` (never link-time,
  mirroring the zerocopy::cuda libcuda loader) — one binary still starts on
  AMD/Intel Linux boxes and falls through to VAAPI/software;
- session on the shared CUcontext (zerocopy::cuda::context());
- an encoder-owned ring of registered CUDADEVICEPTR input surfaces
  (zerocopy::cuda::InputSurface + a contiguous-NV12 allocator), each captured
  DeviceBuffer device→device copied in via the existing copy_* helpers — mirrors
  the libav recycled-hwframe-pool copy, so zero regression vs today;
- config/RFI/anchor/reset ported from the Windows backend; sync-only (NVENC async
  is Windows-only, so that whole subsystem is dropped);
- Main10/HDR-SEI wired but inert until a Linux P010 capture path (Phase 5.1).

Wired behind PUNKTFUNK_NVENC_DIRECT (default OFF) in open_nvenc_probed; the Windows
path is untouched (no shared extraction in v1). Two on-hardware `#[ignore]` smokes
added.

Validated on .21 (RTX 5070 Ti, driver 610.43.03): builds on Linux under ci-check,
clippy-clean, full host suite 272/0, NV12 smoke (8 AUs, real invalidate_ref_frames
+ recovery_anchor on a P-frame) and YUV444 FREXT smoke (6 AUs, chroma_444) green;
Windows compile unaffected. Owed: the client-in-the-loop matrix (RFI-survives-ABR,
reset() heal, A/B vs libav) and the default flip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:18:21 +02:00
enricobuehler fdda7144ed fix(encode): harden loss-recovery correctness across host encoders (F1–F7)
Phases 1–4 of design/encoder-recovery-hardening.md — make the shipped RFI/
freeze-until-reanchor recovery honest and rebuild-safe across every backend.

F1 — frame-index domain desync: the encode loop now owns a session-lifetime
`au_seq`; `Encoder::submit_indexed(au_seq + inflight)` pins NVENC inputTimeStamp
and AMF LTR slots to the WIRE frame index, so `invalidate_ref_frames` compares
client frame numbers in the same domain and survives adaptive-bitrate rebuilds
(an internal counter desynced on the first rebuild → RFI silently dead / an AMF
force-ref onto a never-decoded frame). `FrameMsg.frame_index` →
`Session::seal_frame_at`; GameStream gets the same via `VideoPacketizer::
packetize(.., Some(idx))`.

F2 — Windows NVENC left the client frozen ~1s per loss: NVENC RFI was
transparent (no anchor tag) while the session glue armed the 750ms IDR cooldown,
so the freeze only lifted on the ~1s keyframe re-ask. NVENC now mirrors AMF —
`pending_anchor` tags the first post-invalidate AU (the clean re-anchor
P-frame) `recovery_anchor`, incl. the covering-range dedupe re-arm; the client
lifts at ~RTT.

F3 — speed-test probe filler burned video frame indexes: moved to its own index
space (`Packetizer::alloc_probe_index` + `Session::submit_probe_frame`) with a
second client reassembly window routed on FLAG_PROBE, gated on the new
VIDEO_CAP_PROBE_SEQ Hello bit (mid-session probes declined for older clients).

F4 — RFI range sanity cap: forward gaps wider than `packet::RFI_MAX_RANGE` (256)
resync via keyframe instead of an out-of-range RFI, host- and client-side
(client huge-gap → keyframe in `RfiRecovery::observe` + the pf-client-core pump).

F5 — reset() parity: Windows NVENC (teardown + lazy re-init), Linux VAAPI
(drop-inner), Linux NVENC (reopen from stored OpenArgs) now give the stall
watchdog a heal lever instead of ending the session.

F6 — sw.rs `pending: VecDeque` (was `Option`), killing the silent AU drop at
capturer pipeline depth > 1. F7 — doc sweep on the RFI/anchor comments.

Verified: punktfunk-core lib tests (macOS + Linux), full punktfunk-host suite on
Linux (RTX 5070 Ti), Windows compile. Owed: the on-glass client matrix (F2
freeze A/B, AMF LTR spike across a bitrate rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:17:19 +02:00
enricobuehler 0dc414f197 docs: guide for embedding the C ABI (webOS, Xbox, Tizen examples)
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m19s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
apple / swift (push) Successful in 3m38s
ci / bench (push) Successful in 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8m25s
docker / deploy-docs (push) Successful in 35s
android / android (push) Successful in 12m34s
arch / build-publish (push) Successful in 15m3s
deb / build-publish (push) Successful in 16m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m7s
apple / screenshots (push) Successful in 19m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m17s
ci / rust (push) Successful in 23m12s
Developer integration guide for building a punktfunk client on any platform
by linking punktfunk-core through its stable C ABI: what the core does vs.
what the embedder supplies, build/link/cross-compile, the full client
lifecycle (identity/pairing, connect ladder, video+recovery loop, audio,
input, feedback planes, teardown), plus worked blueprints for webOS, Xbox
(GDK), and Tizen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:49:13 +02:00
enricobuehler a95b518ef3 fix(windows): show app version on About screen + capitalize Punktfunk on licenses
ci / docs-site (push) Successful in 51s
ci / web (push) Successful in 57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 19s
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 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m1s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m3s
apple / swift (push) Successful in 5m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m25s
ci / bench (push) Successful in 8m10s
docker / deploy-docs (push) Successful in 20s
arch / build-publish (push) Successful in 11m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m47s
android / android (push) Successful in 16m10s
deb / build-publish (push) Successful in 16m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m47s
ci / rust (push) Successful in 23m26s
apple / screenshots (push) Successful in 19m57s
The About settings card had no version at all — add an identity block (app name +
"Version <CARGO_PKG_VERSION>", the workspace version) at the top, the WinUI
Settings convention and matching the Apple client's "Version X" wording. Also
capitalize the brand name on the licenses screen (was lowercase "punktfunk").

Verified against the pinned windows-reactor source + cargo fmt --check; full
Windows link left to CI (Windows-only crate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:46:45 +02:00
enricobuehler f77fdee3e9 style(host): fix clippy doc_lazy_continuation in reconfig_allowed
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 55s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 17s
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 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 7s
ci / bench (push) Successful in 5m57s
docker / deploy-docs (push) Successful in 21s
windows-host / package (push) Successful in 8m52s
android / android (push) Successful in 12m58s
arch / build-publish (push) Successful in 13m33s
deb / build-publish (push) Successful in 16m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m26s
apple / swift (push) Successful in 5m21s
ci / rust (push) Successful in 19m47s
apple / screenshots (push) Successful in 19m47s
New clippy (rust-1.96) flags the summary line after the 'Gated OFF for' bullet
list as a lazy list-continuation under -D warnings. Add a blank /// line so it
reads as its own paragraph (clippy's suggested fix), matching intent. Comment
only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:25 +02:00
enricobuehler a85be8e467 feat(displays): clearer virtual-display preset names + descriptions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m15s
decky / build-publish (push) Successful in 18s
apple / swift (push) Successful in 4m55s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
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 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
ci / rust (push) Failing after 7m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m21s
docker / deploy-docs (push) Successful in 24s
windows-host / package (push) Failing after 8m23s
ci / bench (push) Successful in 8m44s
arch / build-publish (push) Successful in 12m26s
android / android (push) Successful in 13m37s
deb / build-publish (push) Successful in 14m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m17s
release / apple (push) Successful in 22m53s
apple / screenshots (push) Successful in 20m47s
"Gaming rig" actually meant a dedicated/headless box you only ever stream from,
which confused users — rename it to "Headless box" and rewrite all five preset
summaries to be scenario-first and shorter (the console cards already show the
mechanics as badges). Updated across the host API summaries (mgmt.rs), the web
console labels (en/de), and the docs table + prose. The internal preset id
`gaming-rig` is unchanged (stable API / stored-policy / test contract).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:29:18 +02:00
enricobuehler 1dfcb0b2f6 feat(android): default-UI connect/wake modal (Material dialog)
Mirror the Apple client: the connect/wake overlay was showing the full-screen
aurora takeover in the default touch UI too. Make ConnectOverlay mode-aware —
gamepad/console keeps the aurora ConnectTakeover, the default UI now renders a
Material 3 AlertDialog over the host grid (inert scrim; Back/buttons cancel),
matching the app's other touch dialogs. Extract a shared connectCopy() so both
presentations read identically; ConnectTakeover is now console-only.

Screenshot scenes updated (touch phases -> modal over the host grid via
shootScreen; console stays a root capture); record-mode tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:29:18 +02:00
enricobuehler e87fd42cee feat(apple): default-UI connect/wake modal, auto-wake toggle, pixel-exact windowed streaming
Three client-UX changes that share the connect/present path (and settings files):

- Connect/wake overlay is now mode-aware: the console/gamepad UI keeps the
  full-screen aurora takeover, while the default (touch/desktop) UI shows a
  Liquid Glass modal over the host grid — the takeover looked out of place there.
- Add an auto-wake toggle (DefaultsKey.autoWake, default on) across macOS/iOS/tvOS
  Settings + the gamepad settings view; gate startSession/prepareWake and the
  gamepad "Wake & Connect" label on it. MAC-address learning stays always-on.
- Windowed sessions now stream at the window's native pixels (Match-window
  default-on) so the picture is 1:1 pixel-exact instead of the presenter
  resampling a fixed-mode frame; fullscreen reports full-display px, also 1:1.
  Also lands the mid-resize aspect-fit tracking (decoded contentSize) that keeps
  the picture undistorted after a resize.

swift build + swift test (121 tests) green; screenshot scenes verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:29:12 +02:00
enricobuehler dd02e1f402 feat(clients): unified full-screen connect/wake takeover + iOS/tvOS Wake-on-LAN
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m8s
decky / build-publish (push) Successful in 20s
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 11s
android / android (push) Successful in 12m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m3s
arch / build-publish (push) Successful in 12m57s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m40s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m4s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 5m57s
deb / build-publish (push) Successful in 13m26s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m50s
apple / swift (push) Successful in 4m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m44s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 21m27s
ci / rust (push) Failing after 6m47s
apple / screenshots (push) Successful in 20m18s
Give instant feedback the moment a host is picked, and make the wake wait a
full-screen takeover instead of a modal card — unified into one ConnectOverlay
across every client:

- android: new ConnectOverlay (aurora backdrop; Connecting / Waking / timed-out
  phases) replaces the tiny inline "Connecting…" row and the WakeOverlay card.
  The dial phase is now cancelable and hands off to the wake wait in one frame.
- console (pf-console-ui): the connect/wake overlays become a full-screen aurora
  takeover (draw_takeover) instead of a centered card over a dim scrim; the
  Waking → Connecting handoff no longer blinks.
- apple: new ConnectOverlay mirrors it (macOS / iOS / tvOS), replacing the
  per-tile connecting spinner + the WakeOverlay card; instant "Connecting…" from
  model.phase, and the carousel is gated inactive during the dial.

Also enable Wake-on-LAN on iOS/tvOS now that the multicast entitlement is
approved: enable com.apple.developer.networking.multicast and flip
wakeOnLANAvailable to true on every platform (MACs were already learned from
mDNS, so wake works immediately).

Verified: Android compileDebugKotlin + screenshot renders; console clippy +
36 tests + rendered phases on Linux; Apple swift build + 121 tests + rendered
phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:29:35 +02:00
enricobuehler 2271f67202 style: rustfmt the recovery + resize changes (Windows CI fmt check)
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 4m26s
ci / bench (push) Successful in 5m53s
ci / rust (push) Failing after 7m35s
windows-host / package (push) Failing after 8m56s
flatpak / build-publish (push) Failing after 8m12s
arch / build-publish (push) Successful in 11m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
android / android (push) Successful in 13m28s
deb / build-publish (push) Successful in 13m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
docker / deploy-docs (push) Successful in 20s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
release / apple (push) Successful in 25m4s
apple / screenshots (push) Successful in 19m32s
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was
failing: the mid-stream loss-recovery and resize-overlay commits landed
with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core,
pf-console-ui, and a few host files.

Applied `cargo fmt`, and hand-relocated two trailing comments in
session.rs (a decoded-frame note and the wrap-counter note) to their own
lines so rustfmt no longer column-aligns the following comment block to
a deep indent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:25:06 +02:00
enricobuehler 89aa6767f9 feat(resize): scrim+spinner resize overlay in the shared presenter (Windows + GTK4)
ci / docs-site (push) Failing after 29s
ci / web (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
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 9s
decky / build-publish (push) Successful in 33s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / deploy-docs (push) Successful in 20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / swift (push) Successful in 4m55s
ci / bench (push) Successful in 6m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
arch / build-publish (push) Successful in 12m42s
android / android (push) Successful in 12m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m7s
deb / build-publish (push) Successful in 13m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
apple / screenshots (push) Successful in 19m31s
ci / rust (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m4s
flatpak / build-publish (push) Failing after 8m4s
The mid-stream Match-window trigger + Resolution tri-state already shipped on BOTH
desktop clients via the session-always punktfunk-session binary (pf-presenter D1/D2,
C1). This ports the last Apple-parity piece — the resize-in-progress indicator
(clients/apple ResizeIndicator/ResizeIndicatorView) — into the SHARED pf-presenter
overlay, so one implementation covers both the Windows and GTK4 session windows.

- ResizeIndicator (run.rs): the Apple state machine in Rust — `steering` (a switch was
  requested) shows it, `decoded` (a frame reached the target size) clears it, `tick`
  times it out after 2.5 s for a switch the host rejected/capped. The live drag stays
  sharp; only the host's 0.3-2 s virtual-display + encoder rebuild gap is covered. A
  present-while-resizing path keeps the spinner animating through that frame-less gap.
- DecodedImage::dimensions() (pf-client-core): the END signal — a decoded frame at the
  target size means the sharp new-mode picture is on glass (the accept ack alone lands
  ahead of the host's rebuild). Mirrors is_keyframe()'s cfg arms.
- FrameCtx.resizing (pf-presenter/overlay.rs) + Skia draw (pf-console-ui): a full-screen
  55% scrim + the shared rotating theme::spinner + "Resizing…" label. The overlay
  composites its own RGBA quad and can't sample the video to blur it as SwiftUI does, so
  a scrim stands in for the blur — same intent, one draw. resizing_since clocks the
  spinner; Drawn.resize_step defeats the damage gate so it redraws each frame.

Verified on Linux: pf-presenter/pf-console-ui/session/linux-client build + clippy
-D warnings clean; 8 pf-presenter tests green incl. 2 new ResizeIndicator tests.
Windows session-binary compile (cfg-symmetric) + live on-glass both pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:40:03 +02:00
enricobuehler 7cea893db5 feat(recovery): wire LTR-RFI loss recovery into every client
ci / web (push) Successful in 48s
ci / rust (push) Failing after 48s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
ci / docs-site (push) Successful in 1m0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
decky / build-publish (push) Successful in 17s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
apple / swift (push) Successful in 4m51s
ci / bench (push) Successful in 6m13s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Failing after 8m22s
flatpak / build-publish (push) Failing after 8m4s
arch / build-publish (push) Successful in 11m53s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m45s
android / android (push) Successful in 12m53s
deb / build-publish (push) Successful in 13m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m2s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m29s
release / apple (push) Successful in 25m48s
apple / screenshots (push) Successful in 19m31s
Centralize the client-side loss-range detector in punktfunk-core so every
embedder shares one implementation instead of re-deriving the wrapping
frame-index arithmetic:

- NativeClient::note_frame_index(frame_index) folds each received AU (in
  receive order) through RfiRecovery::observe, firing a throttled RFI request
  for the exact lost span [first_missing, frame_index-1] on a forward gap. A
  host that can RFI (AMD LTR / NVENC) re-references a known-good frame instead
  of paying a 20-40x IDR spike; the frames_dropped-driven keyframe path stays
  the backstop for when the recovery frame itself is lost.
- Export request_rfi + note_frame_index over the C ABI (Apple client).
- Call it from the Android (hw+sw pumps), Apple (StreamPump + Stage2Pipeline
  via PunktfunkConnection.noteFrameIndex), and Windows in-process pumps.
  Linux/Deck inherit it through pf-client-core's session pump.
- Split the decision into a pure RfiRecovery::observe(frame_index, now) and add
  8 unit tests: arming, contiguous runs, exact lost-range, single-frame drop,
  the 100ms throttle (burst-suppress then re-open), reorder stragglers, and
  u32 wraparound (contiguous + gap-range).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:11:01 +02:00
enricobuehler e55ff1bb28 feat(recovery): clean mid-stream loss recovery — freeze-until-reanchor + AMD LTR-RFI
ci / rust (push) Failing after 53s
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
apple / swift (push) Successful in 4m57s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m4s
docker / deploy-docs (push) Successful in 21s
windows-host / package (push) Failing after 8m42s
flatpak / build-publish (push) Failing after 8m6s
android / android (push) Successful in 11m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
arch / build-publish (push) Successful in 13m20s
deb / build-publish (push) Successful in 14m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m1s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m28s
release / apple (push) Successful in 25m54s
apple / screenshots (push) Successful in 19m35s
Removes the "gray frames with motion" artifact on Vulkan-Video clients and lets
AMD/NVENC hosts re-anchor after loss WITHOUT a 20-40x IDR spike.

Client (pf-client-core): after a reference loss the hardware decoder conceals the
missing-reference deltas (on RADV, a gray plate with new motion painted over) and
returns Ok. The pump now freezes on the last good picture until a clean re-anchor
instead of showing the concealment — lifting on a real IDR, an intra-refresh
recovery mark (2nd wave boundary), or an LTR-RFI recovery anchor (1st). The
frame_index gap is the early, precise loss signal and drives an RFI request.

Host recovery signals (inert unless the backend supports them):
- USER_FLAG_RECOVERY_POINT — intra-refresh wave boundary (NVENC constrained GDR).
- USER_FLAG_RECOVERY_ANCHOR — AMD LTR reference-frame-invalidation recovery frame.

AMD LTR-RFI (encode/windows/amf.rs) — the AMD twin of NVENC RFI. AMF's AVC/HEVC API
has no constrained-intra property (intra-refresh cannot heal; PSNR-proven), so the
only clean-recovery lever is user LTR: mark frames as long-term references, and on
loss force the next frame to re-reference the newest known-good one — a clean
P-frame, not an IDR. Two rotating LTR slots, ~0.5s mark cadence, on by default for
AVC/HEVC (PUNKTFUNK_NO_AMF_LTR disables). invalidate_ref_frames picks the newest LTR
before the loss; a range older than the live slots falls back to a keyframe.

Protocol (punktfunk-core): RfiRequest control message + NativeClient::request_rfi().
Host: RfiRequest dispatch -> invalidate_ref_frames (IDR fallback); an RFI success
anchors the keyframe cooldown so the client's frames_dropped echo of the same loss
is coalesced away rather than emitting a redundant IDR.

Spike: synthetic NV12 GPU source for headless AMF encoder testing.

Validated: core rfi_request_roundtrip; pf-client-core 31 unit tests
(incl. an_rfi_anchor_lifts_immediately); punktfunk-host builds + 271 tests on Linux;
punktfunk-host builds clean on Windows; real AMD iGPU spike (invalidate at frame 90
forced re-reference to LTR frame 60 — 180 frames, keyframes=1, no recovery IDR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:31:17 +02:00
enricobuehler 890c7531d8 Merge branch 'midstream-resize': mid-stream resolution resize
apple / swift (push) Successful in 4m19s
ci / rust (push) Failing after 46s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m2s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m17s
arch / build-publish (push) Successful in 10m44s
docker / deploy-docs (push) Successful in 11s
android / android (push) Successful in 16m43s
windows-host / package (push) Failing after 8m29s
flatpak / build-publish (push) Failing after 8m13s
deb / build-publish (push) Successful in 11m24s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m51s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m24s
release / apple (push) Successful in 24m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m24s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m2s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m25s
apple / screenshots (push) Successful in 19m55s
Lands the mid-stream resolution resize feature (client-driven Reconfigure so
the host's virtual display + encoder follow a resized client window without a
reconnect), all paths default OFF:

- host hardening H1-H5 + session-binary Match window (C1)
- Apple macOS/iPadOS Match-window trigger + settings (C3) and the resize
  overlay (blur + spinner) client UX
- Windows on-glass fixes: corrective-ack actual resolution + pf-vdisplay
  monitor re-arrival for out-of-list mid-stream modes
- Linux backend matrix + the live-reconfigure gate unit tests

Validated on-glass: Windows IDD-push (.173), Linux Mutter + KWin. Android
(C4) deferred; Apple full build pending on a Mac.
2026-07-11 15:59:07 +02:00
enricobuehler e6fbcecdb9 fix(clients/windows): GUI text inputs read the live value, not a stale render snapshot
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m33s
apple / swift (push) Successful in 4m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m18s
ci / bench (push) Successful in 7m10s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
arch / build-publish (push) Successful in 11m38s
deb / build-publish (push) Successful in 12m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m46s
android / android (push) Successful in 17m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
docker / deploy-docs (push) Successful in 22s
ci / rust (push) Successful in 26m33s
apple / screenshots (push) Successful in 19m29s
A component() page re-renders reliably only when its props change: root wraps
every screen in a stable animated border, so once the entrance tween settles the
reconciler skips that unchanged-props subtree and a page's own use_state writes
never force a re-render. Three text fields read their value at click time from
that stranded local state:

- PIN pairing sent an empty PIN, so pairing always failed with "wrong PIN, or not
  armed?" — the reported bug. The CLI --pair path bypasses the reactor and worked.
- "Add host" Connect captured the empty mount-time address and silently did
  nothing (you open the modal precisely when the host isn't being discovered, so
  no discovery tick re-renders the page while you type).
- Rename round-tripped the draft through an always-deferred AsyncSetState into a
  controlled text box, fighting the caret on fast typing and dropping the last
  character when Save was clicked before the write landed.

Fix: hold each field's live value in a use_ref cell written on every keystroke
and read at commit time (uncontrolled input), instead of a render-time snapshot.
Rename is seeded when its target changes and no longer re-renders the whole page
per keystroke. Reviewed the rest of the app (settings, speed test, library,
stream, connect/request-access/waking, forget) — all driven by root-state props
and wired correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:58:25 +02:00
enricobuehler f01c5e210c feat(resize/apple): resize overlay — blur + spinner during mid-stream resize
Make a Match-window resize deliberate instead of a stutter: blur the live
stream and show a spinner while the host rebuilds its virtual display +
encoder and VideoToolbox re-inits on the new-mode IDR. No new protocol —
driven entirely by existing client signals.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:05:21 +02:00
238 changed files with 26897 additions and 11163 deletions
+8 -1
View File
@@ -90,7 +90,14 @@ jobs:
git config --global --add safe.directory "$PWD"
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
# both client binaries must ship (build-client-deb.sh installs both).
cargo build --release --locked \
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
- name: Build + smoke-boot web console (bun preset)
+101
View File
@@ -0,0 +1,101 @@
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
# NEITHER build: it just (re)places the compose files and pulls the already-published
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
# dispatch without triggering those rebuilds.
#
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
#
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
# DEPLOY_HOST/USER/PORT/SSH_KEY (the unom-ci-deploy key) + REGISTRY_TOKEN (docs pull).
name: deploy-services
run-name: ${{ gitea.actor }} deploy docs + flatpak server
on:
workflow_dispatch:
inputs:
deploy_host:
description: "Box IP to deploy to; deploy-all passes the freshly-provisioned target IP. Blank (push) uses the DEPLOY_HOST secret."
required: false
jobs:
docs:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync compose file
# SHA-pinned (receives DEPLOY_SSH_KEY): a moved tag would mean credential
# exfiltration. v0.1.7 = 917f8b8. Bump the SHA + trailing version together.
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "compose.production.yml"
target: "~/punktfunk-docs"
overwrite: true
- name: Pull and start docs
# SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN. v1.2.5 = 0ff4204.
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Token enters via env, never the script text (keeps it out of run logs).
envs: REGISTRY_TOKEN
script: |
set -euo pipefail
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u enricobuehler --password-stdin
cd ~/punktfunk-docs
docker compose -f compose.production.yml pull docs
docker compose -f compose.production.yml up -d --no-build docs
flatpak:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync flatpak server compose + Caddyfile
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land both files flat in ~/unom-flatpak/ (drop the packaging/flatpak/server/ prefix).
source: "packaging/flatpak/server/compose.production.yml,packaging/flatpak/server/Caddyfile"
target: "~/unom-flatpak"
strip_components: 3
overwrite: true
- name: Start flatpak static server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./site (the OSTree repo) is NOT shipped by this workflow. Ensure the
# bind-mount source exists so caddy starts; content is restored from the
# unom-1 backup or regenerated by flatpak.yml's next publish.
mkdir -p ~/unom-flatpak/site/repo
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
+42 -6
View File
@@ -59,6 +59,23 @@ jobs:
image: fedora:43
options: --privileged
steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
# module is nss-resolve (systemd-resolved), which isn't running in a CI
# container. glibc getaddrinfo (git, curl, dnf) mostly falls through to the
# `dns` module -> Docker's embedded 127.0.0.11 -> works. flatpak/ostree's
# resolver does NOT fall through: the absent-daemon socket connect trips
# `[!UNAVAIL=return]` and it reports "[6] Could not resolve hostname". This
# was masked as an intermittent "busy runner drops DNS" and papered over
# with retry.sh — but it's deterministic on a runner where the resolve
# module tips that way (surfaced when jobs began landing on home-runner-2).
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve — no systemd-resolved in CI)
run: sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
# executes via the container shell (no node needed), so install node BEFORE checkout.
- name: node for the JS actions
@@ -72,11 +89,28 @@ jobs:
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# Flathub provides the GNOME runtime/SDK + the rust-stable + ffmpeg-full extensions.
# retry.sh: the busy runner intermittently drops DNS lookups ("[6] Could not resolve
# hostname" seconds after dnf pulled 300+ packages fine) — never fail the job on a
# single-shot fetch. Same treatment for every network command below.
bash scripts/ci/retry.sh 5 flatpak remote-add --user --if-not-exists flathub \
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
# a SHARED, resource-capped fleet (3 replicas, --cpus 5 --memory 7g each, on a 16c/24G
# box) serving punktfunk AND several other orgs' repos (chatwoot, website, cms, data,
# infra, tempblade, played...). A push to punktfunk's main fans out ~8 workflows at
# once, and this runner box's Docker embedded DNS resolver (127.0.0.11) genuinely
# drops UDP lookups under that concurrent load — exactly what retry.sh's own header
# comment already documented. Reproduced: manually dispatching this job when the box
# was idle (1 container running) succeeded immediately, with or without the sed above.
# 5 attempts (~100s total) isn't always enough to outlast a synchronized multi-org
# burst, so bump the bootstrap fetch's budget — never fail the job on a single-shot
# fetch. Same treatment for every network command below.
bash scripts/ci/retry.sh 10 flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
git config --global --add safe.directory "$PWD"
@@ -171,10 +205,12 @@ jobs:
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no FUSE).
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 5 flatpak-builder --user --force-clean --disable-rofiles-fuse \
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only \
"$PWD/build-dir" "$MANIFEST"
Generated
+14 -27
View File
@@ -870,15 +870,6 @@ dependencies = [
"itertools 0.10.5",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -2154,7 +2145,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.9.1"
version = "0.10.0"
[[package]]
name = "lazy_static"
@@ -2286,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"punktfunk-core",
]
@@ -2765,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-client-core"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2787,7 +2778,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"ash",
@@ -2808,7 +2799,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"ash",
"bindgen",
@@ -2817,7 +2808,7 @@ dependencies = [
[[package]]
name = "pf-presenter"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"ash",
@@ -3001,7 +2992,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"android_logger",
"jni",
@@ -3017,7 +3008,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3033,7 +3024,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3048,14 +3039,11 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"async-channel",
"crossbeam-channel",
"ffmpeg-next",
"mdns-sd",
"opus",
"pf-client-core",
"punktfunk-core",
"sdl3",
@@ -3063,7 +3051,6 @@ dependencies = [
"serde_json",
"tracing",
"tracing-subscriber",
"wasapi",
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f)",
"windows-reactor",
"windows-reactor-setup",
@@ -3072,7 +3059,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3103,7 +3090,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3175,7 +3162,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3189,7 +3176,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -35,7 +35,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.9.1"
version = "0.10.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+8
View File
@@ -2517,6 +2517,10 @@
"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`].",
"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": {
"$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."
@@ -2539,6 +2543,10 @@
"mode_conflict": {
"$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": {
"$ref": "#/components/schemas/Preset"
},
@@ -0,0 +1,277 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
/**
* Which phase of the connect flow to draw — the pure view model [ConnectOverlay] resolves from the
* live dial/wake state, so [ConnectTakeover] / [ConnectModal] can render (and be screenshot-tested)
* statelessly.
*/
internal sealed interface ConnectPhase {
val hostName: String
/** The dial is in flight (shown the instant a host is picked). */
data class Connecting(override val hostName: String) : ConnectPhase
/** A sleeping host is being Wake-on-LAN'd and we're waiting for it to advertise again. */
data class Waking(override val hostName: String, val seconds: Int, val connectsAfter: Boolean) : ConnectPhase
/** The wake wait ran out — offer retry / cancel. */
data class WakeTimedOut(override val hostName: String) : ConnectPhase
}
/** Per-phase copy, shared by the console takeover and the touch modal so both read identically. */
private data class ConnectCopy(
val title: String,
val subtitle: String,
/** Monospace the subtitle so a ticking seconds counter doesn't jitter its width. */
val monoSubtitle: Boolean,
val cancelLabel: String,
)
private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
is ConnectPhase.Connecting -> ConnectCopy(
"Connecting to ${phase.hostName}", "Establishing a secure connection…", false, "Cancel",
)
is ConnectPhase.Waking -> ConnectCopy(
"Waking ${phase.hostName}",
"Waiting for it to come online · ${phase.seconds}s",
true,
// A wake-only wait (no dial after) says "Stop Waiting"; a wake that will connect says "Cancel".
if (!phase.connectsAfter) "Stop Waiting" else "Cancel",
)
is ConnectPhase.WakeTimedOut -> ConnectCopy(
"${phase.hostName} didn't wake",
"It may still be booting, or it's powered off / off this network.",
false,
"Cancel",
)
}
/**
* The unified "getting you connected" feedback — one flow for BOTH phases of reaching a host, so the
* user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
* out to be asleep:
*
* - **Connecting** ([connectingHostName] non-null): the dial is in flight. Shown immediately on tap,
* so a host that takes a beat to answer no longer looks like nothing happened.
* - **Waking** ([WakeController.waking] non-null): the dial failed on a sleeping host, so we're firing
* Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on timeout.
*
* Presentation is mode-aware (mirrors the Apple client): in the **console / gamepad** UI it's a
* full-screen aurora [ConnectTakeover] — the same signature backdrop the console home uses, driven by
* the pad (B cancels, A retries once timed out) with a hint bar. In the **default touch** UI it's a
* Material [ConnectModal] over the host grid, matching the app's other dialogs — the aurora takeover
* looked out of place there.
*
* The two phases hand off within a single Compose frame (see ConnectScreen's `doConnectDirect` →
* `waker.start` → redial), so nothing blinks between them.
*/
@Composable
fun ConnectOverlay(
connectingHostName: String?,
waker: WakeController,
gamepadUi: Boolean,
onCancelConnect: () -> Unit,
) {
val waking = waker.waking
// Waking takes precedence (it only exists after a dial has failed) so a stray overlap can't strand
// the "Connecting…" phase over a wake in progress.
val phase = when {
waking != null && waking.timedOut -> ConnectPhase.WakeTimedOut(waking.hostName)
waking != null -> ConnectPhase.Waking(waking.hostName, waking.seconds, waking.connectsAfter)
connectingHostName != null -> ConnectPhase.Connecting(connectingHostName)
else -> return
}
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
if (gamepadUi) {
BackHandler { cancel() }
// A retries once a wake has timed out; B falls through to the BackHandler above.
GamepadNavEffect2D(
active = true,
onDirection = {},
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
)
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
} else {
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
}
}
/**
* The default-UI presentation: a Material dialog over the host grid, matching the app's other touch
* dialogs. A spinner (or the sleep glyph once timed out) sits above the title; the scrim is inert so a
* stray tap can't drop a connect in flight — only the buttons or system Back cancel.
*/
@Composable
internal fun ConnectModal(
phase: ConnectPhase,
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
AlertDialog(
onDismissRequest = onCancel,
properties = DialogProperties(dismissOnClickOutside = false),
icon = {
if (timedOut) {
Icon(Icons.Filled.Bedtime, contentDescription = null)
} else {
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
}
},
title = { Text(copy.title, textAlign = TextAlign.Center) },
text = {
Text(
copy.subtitle,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
)
},
// No confirm action until the wake times out; then "Try Again" is the primary button.
confirmButton = {
if (timedOut) TextButton(onClick = onRetry) { Text("Try Again") }
},
dismissButton = {
TextButton(onClick = onCancel) { Text(copy.cancelLabel) }
},
)
}
/**
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
* out) — glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
* user without a working pad can still get out.
*/
@Composable
internal fun ConnectTakeover(
phase: ConnectPhase,
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
Box(
Modifier
.fillMaxSize()
// Swallow taps so the screen behind can't be touched through the takeover.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(
Modifier.padding(horizontal = 40.dp).widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (timedOut) {
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(46.dp),
)
}
} else {
PulsingSpinner()
}
Text(
copy.title,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = Color.White.copy(alpha = 0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
)
}
val hints = buildList {
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
}
GamepadHintBar(hints, Modifier.align(Alignment.BottomCenter).padding(bottom = 28.dp))
}
}
/**
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
* expand and fade on a staggered loop — a small sign of life so the takeover reads as working, not
* stalled.
*/
@Composable
private fun PulsingSpinner() {
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
label = "pulse",
)
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val maxR = size.minDimension / 2f
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = Color.White,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
}
}
@@ -88,6 +88,16 @@ private class RequestAccessState(val target: PendingTrust) {
val cancelled = AtomicBoolean(false)
}
/**
* A plain dial in flight — [hostName] labels the unified [ConnectOverlay]'s "Connecting…" phase, and
* [cancelled] lets its Cancel abort. The native connect is a blocking call with no abort, so Cancel
* returns the UI immediately and a late-arriving handle is torn down silently rather than navigating
* into a session the user already backed out of. Mirrors [RequestAccessState]'s late-result handling.
*/
private class ConnectAttempt(val hostName: String) {
val cancelled = AtomicBoolean(false)
}
@Composable
fun ConnectScreen(
settings: Settings,
@@ -107,6 +117,9 @@ fun ConnectScreen(
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
// when idle or when the request-access / wake flows own the screen instead.
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
@@ -267,11 +280,20 @@ fun ConnectScreen(
status = "Identity not ready yet — try again in a moment"
return
}
val thisAttempt = ConnectAttempt(name)
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
connecting = true
status = "Connecting to $targetHost:$targetPort"
status = null
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
// cancelConnect — drop the just-opened session silently rather than navigating into it.
if (thisAttempt.cancelled.get()) {
if (handle != 0L) withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
return@launch
}
attempt = null
connecting = false
if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
@@ -284,7 +306,9 @@ fun ConnectScreen(
} else {
discovery.start()
if (onFailure != null) {
status = ""
// Hand off to the wake-and-wait flow — clearing `attempt` above and setting
// `waker.waking` here land in one recompose, so the overlay slides
// Connecting → Waking without a blank frame.
onFailure()
} else {
status = "Connection failed — check host/port, PIN, and logcat"
@@ -293,6 +317,16 @@ fun ConnectScreen(
}
}
// Cancel a plain dial in flight (the overlay's "Connecting…" phase, B / Cancel). The native
// connect can't be aborted, so flag this attempt (a late handle is closed silently in
// doConnectDirect) and return the UI now, resuming the discovery we paused for the dial.
fun cancelConnect() {
attempt?.cancelled?.set(true)
attempt = null
connecting = false
discovery.start()
}
// Wake-aware connect. If auto-wake is on (Settings.autoWakeEnabled) and the target is a saved
// host with a learned MAC that ISN'T currently advertising, fire a wake packet and DIAL
// IMMEDIATELY — mDNS absence does NOT mean unreachable (a host reached over a routed network —
@@ -506,27 +540,9 @@ fun ConnectScreen(
Spacer(Modifier.height(24.dp))
status?.let {
// While connecting it's progress (spinner, neutral); otherwise it's a
// result/error (red). Previously every status showed in error-red, so a
// normal "Connecting…" looked like a failure.
if (connecting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
// Result/error: a filled error container reads as a real failure banner,
// not just red text lost in the layout.
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error
// container reads as a real failure banner, not just red text lost in the layout.
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.medium,
@@ -540,7 +556,6 @@ fun ConnectScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
Spacer(Modifier.height(16.dp))
}
}
@@ -837,8 +852,15 @@ fun ConnectScreen(
}
}
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
WakeOverlay(waker, gamepadUi)
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
// grid and the console home.
ConnectOverlay(
connectingHostName = attempt?.hostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = { cancelConnect() },
)
}
/**
@@ -158,8 +158,11 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
pads.forEachIndexed { i, dev ->
PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting)
// Every real controller is forwarded now (Automatic forwards them all, each on its own
// wire pad index) — not just the first. A joystick-only device Android doesn't classify as
// a gamepad still can't be forwarded (the host wants a gamepad), so gate the badge on it.
pads.forEach { dev ->
PadRow(dev, forwarded = isForwarded(dev), gamepadSetting = gamepadSetting)
}
}
@@ -222,8 +225,12 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (forwarded) {
// Android's own controller number (1-based; 0 = unassigned), shown so a multi-pad
// user can tell which physical pad is which. The stream's wire pad index is
// assigned separately (lowest-free per device) once streaming starts.
val number = dev.controllerNumber
Text(
"forwarded to host",
if (number > 0) "forwarded · player $number" else "forwarded to host",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -319,6 +326,15 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
}
}
/**
* Whether this device is actually forwarded to the host — the same rule the stream's [GamepadRouter]
* applies: a real, non-virtual controller whose source classes include GAMEPAD. A joystick-only node
* (e.g. a DualSense motion-sensor sibling, or an adapter that enumerates as bare joystick) shows in
* the list but isn't forwarded.
*/
private fun isForwarded(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) {
@@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
@@ -27,8 +28,12 @@ class MainActivity : ComponentActivity() {
*/
var streamHandle: Long = 0L
/** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */
var axisMapper: Gamepad.AxisMapper? = null
/**
* Multi-controller router for the active session (built/released by StreamScreen): assigns each
* connected pad a stable wire index, threads it onto every event, declares/removes pads on
* hot-plug, and routes rumble/HID feedback back by pad index. Null while not streaming.
*/
var gamepadRouter: GamepadRouter? = null
/**
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
@@ -44,9 +49,6 @@ class MainActivity : ComponentActivity() {
*/
var requestStreamExit: (() -> Unit)? = null
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
private var heldPadButtons = 0
/**
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
@@ -125,23 +127,12 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.buttonBit(event.keyCode)
if (bit != 0) {
when (event.action) {
// repeatCount guard: don't re-send a held button as auto-repeat.
KeyEvent.ACTION_DOWN -> {
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
heldPadButtons = heldPadButtons or bit
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
// (a couch user has no keyboard/Back). Fired once per full chord.
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
heldPadButtons = 0
// The router forwards the bit on this device's own wire pad index, tracks held
// state per pad, and reports when the emergency-exit chord (Select + Start + L1 +
// R1) completed on any one pad (a couch user has no keyboard/Back).
if (gamepadRouter?.onButton(event, bit) == true) {
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
}
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false)
heldPadButtons = heldPadButtons and bit.inv()
}
}
return true // consumed
}
}
@@ -203,7 +194,7 @@ class MainActivity : ComponentActivity() {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (streamHandle != 0L) {
if (axisMapper?.onMotion(event) == true) return true
if (gamepadRouter?.onMotion(event) == true) return true
return super.dispatchGenericMotionEvent(event)
}
// The Controllers debug screen sees pad motion before the stick→D-pad synthesis below.
@@ -248,9 +239,4 @@ class MainActivity : ComponentActivity() {
-> true
else -> KeyEvent.isGamepadButton(kc)
}
private companion object {
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
}
}
@@ -32,8 +32,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean
@@ -174,18 +174,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
// controller (Automatic). Built here, released on dispose.
val router = GamepadRouter(context, handle, initialSettings.gamepad)
activity?.gamepadRouter = router
// Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips
// the keep-alive linger), unlike a host-ended / backgrounded drop.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed.
val feedback = GamepadFeedback(handle, router).also { it.start() }
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
activity?.streamHandle = 0L
activity?.requestStreamExit = null
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
*/
class WakeController(private val scope: CoroutineScope) {
/** null = idle; non-null drives [WakeOverlay]. */
/** null = idle; non-null drives the "Waking…" phase of [ConnectOverlay]. */
data class Waking(
val hostName: String,
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
@@ -1,124 +0,0 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
* timed out) while the touch buttons work for a pointer.
*/
@Composable
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
val w = waker.waking ?: return
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
if (gamepadUi) {
// A retries once timed out; B falls through to the BackHandler above.
GamepadNavEffect2D(
active = true,
onDirection = {},
onActivate = { if (w.timedOut) waker.retry() },
)
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
// Swallow taps so the home behind can't be touched while waking.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(40.dp)
.widthIn(max = 380.dp)
.clip(RoundedCornerShape(22.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (w.timedOut) {
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f),
modifier = Modifier.size(34.dp),
)
Text(
"${w.hostName} didn't wake",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
textAlign = TextAlign.Center,
)
Text(
"It may still be booting, or it's powered off / off this network.",
color = Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(top = 6.dp),
) {
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
Button(onClick = { waker.retry() }) { Text("Try Again") }
}
} else {
CircularProgressIndicator(color = Color.White)
Text(
"Waking ${w.hostName}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
textAlign = TextAlign.Center,
)
Text(
"Waiting for it to come online · ${w.seconds}s",
color = Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
}
}
}
}
}
@@ -68,6 +68,29 @@ class ScreenshotTest {
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) }
// The touch flow is a Material dialog over the host grid (a separate window → shootScreen).
@Test
fun connecting() = shootScreen("connecting") {
HostsScene()
ConnectingScene()
}
@Test
fun waking() = shootScreen("waking") {
HostsScene()
WakingScene()
}
@Test
fun wakeTimedOut() = shootScreen("wake-timed-out") {
HostsScene()
WakeTimedOutScene()
}
// The console flow is the full-screen aurora takeover (a root capture).
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen
@@ -215,3 +218,31 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
)
}
}
/**
* The default-UI connect flow (the real [ConnectModal]) in each phase — instant "Connecting…"
* feedback, the "Waking…" wait, and the wake-timed-out prompt. These render as a Material dialog over
* the host grid, so the test composes [HostsScene] behind them and captures the whole screen.
*/
@Composable
internal fun ConnectingScene() =
ConnectModal(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
@Composable
internal fun WakingScene() =
ConnectModal(
ConnectPhase.Waking("Living Room PC", seconds = 12, connectsAfter = true),
onCancel = {}, onRetry = {},
)
@Composable
internal fun WakeTimedOutScene() =
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
/**
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) — the aurora backdrop
* with a bottom hint bar, the same signature look the console home uses.
*/
@Composable
internal fun ConnectConsoleScene() =
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
@@ -171,47 +171,26 @@ object Gamepad {
}
/**
* Maps joystick MotionEvents to axis (+ HAT→dpad) sends for one session, **on change only**.
* Holds the previous axis/hat state so an unchanged frame emits nothing. One instance per
* session; call [reset] on release-all (focus loss / disconnect / session stop) so nothing
* sticks on the host (which has no client-side held-state knowledge).
* Maps one controller's joystick MotionEvents to axis (+ HAT→dpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
* instance per forwarded controller (owned by [GamepadRouter], which routes each device's events
* to its own mapper so a second pad can't clobber the first); call [reset] on that slot closing
* (disconnect / session stop) so nothing sticks on the host (which has no client-side held-state
* knowledge).
*
* Single-source: only ONE qualifying controller feeds pad 0. Events must come from a device
* whose source classes include GAMEPAD (see [onMotion]) and the mapper pins itself to the
* first such device — a controller's joystick-classified sibling nodes (DualSense/DS4 motion
* sensors) and any second pad report every axis as 0, and folding them into the same state
* flapped a held trigger/stick between its value and 0 on every event interleave.
* The router only ever feeds this a qualifying event from the mapper's own device — a real
* gamepad (its source classes include GAMEPAD), never a controller's joystick-classified sibling
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* folds the event straight in without re-qualifying it.
*/
class AxisMapper(private val handle: Long) {
class AxisMapper(private val handle: Long, private val pad: Int) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1
private var hatY = 0
/** deviceId of the controller pad 0 is pinned to; 1 until the first qualifying event. */
private var deviceId = -1
/** Returns true if this was a joystick ACTION_MOVE we consumed. */
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
// Only a true gamepad drives pad 0. A joystick ACTION_MOVE's own source is plain
// JOYSTICK for every sender, so qualify by the DEVICE's source classes: a real pad
// carries the GAMEPAD (button) class too, its sensor/touchpad sibling nodes and
// joystick-class remotes don't — and those report every pad axis as 0 (see the
// class doc for the held-trigger flap this caused).
val dev = event.device ?: return false
if (dev.sources and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD) return false
// Single-pad model: pin to the first qualifying controller so a second pad (or its
// stick drift) can't fight pad 0; re-adopt only once the pinned device is gone.
if (deviceId != event.deviceId) {
if (deviceId != -1) {
if (InputDevice.getDevice(deviceId) != null) return false
reset() // the pinned pad is gone — lift its held state before adopting
}
deviceId = event.deviceId
}
/** Fold one joystick ACTION_MOVE from this mapper's controller onto its pad index. */
fun onMotion(event: MotionEvent) {
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
@@ -253,10 +232,9 @@ object Gamepad {
if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true)
hatY = hy
}
return true
}
/** Release-all: zero every axis and clear the held dpad. */
/** Release-all: zero every axis and clear the held dpad (all on this mapper's pad index). */
fun reset() {
for (id in 0..5) sendAxis(id, 0)
if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false)
@@ -268,10 +246,10 @@ object Gamepad {
private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return
last[id] = v
NativeBridge.nativeSendGamepadAxis(handle, id, v)
NativeBridge.nativeSendGamepadAxis(handle, id, v, pad)
}
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down)
private fun btn(bit: Int, down: Boolean) = NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)
// 1..1 float → ±32767 i16 (matches the Apple client's 32767 scale).
private fun stick(v: Float): Int = (v.coerceIn(-1f, 1f) * 32767f).toInt()
@@ -15,58 +15,82 @@ import android.view.InputDevice
import java.nio.ByteBuffer
/**
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 2830; HID-output → lightbar /
* player-LED via `LightsManager` (API 33+); adaptive
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
* Host→client gamepad feedback for one session, routed per controller by wire pad index. Two daemon
* poll threads drain the blocking native pulls and render in Kotlin: rumble → the addressed
* controller's `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 2830; HID-output
* → that controller's lightbar / player-LED via `LightsManager` (API 33+); adaptive triggers are
* parse-validated and logged (Android has no public adaptive-trigger API).
*
* Each pull carries the wire pad index it is addressed to; [GamepadRouter.deviceForPad] resolves it
* to the physical controller currently holding that index — so a rumble the host aimed at pad 1
* drives pad 1's motors, and an update for an index with no live controller (a pad that just
* unplugged) is dropped. Per-controller rumble/light bindings are built lazily and cached by device
* id (bounded — at most 16 pads).
*
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
* this MUST run before `nativeClose` frees the session handle.
* this MUST run before the router is released and `nativeClose` frees the session handle.
*
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
* `Log.i` receipt lines fire regardless of rendering hardware.
* With no controller connected (emulator) rumble/lights become logged no-ops — exactly the
* verification path; the `Log.i` receipt lines fire regardless of rendering hardware.
*/
class GamepadFeedback(private val handle: Long) {
class GamepadFeedback(private val handle: Long, private val router: GamepadRouter?) {
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
// A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L
}
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 2830). */
private class RumbleBind(
val vm: VibratorManager?,
val legacy: Vibrator?,
val ids: IntArray,
val amplitudeControlled: Boolean,
)
/** One controller's lights binding (API 33+): its open session + the RGB / player-id lights it exposes. */
private class LightBind(
val session: LightsManager.LightsSession,
val rgb: Light?,
val player: Light?,
)
@Volatile private var running = false
private var rumbleThread: Thread? = null
private var hidoutThread: Thread? = null
private var vm: VibratorManager? = null
// API 2830 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
private var legacy: Vibrator? = null
private var vibratorIds: IntArray = IntArray(0)
private var amplitudeControlled = false
private var lightsSession: LightsManager.LightsSession? = null
private var rgbLight: Light? = null
private var playerLight: Light? = null
// Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the
// rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the
// main thread AFTER joining those threads (join establishes the happens-before), so plain maps are
// race-free. A null value caches "this controller has no vibrator / no controllable lights".
private val rumbleBinds = HashMap<Int, RumbleBind?>()
private val lightBinds = HashMap<Int, LightBind?>()
fun start() {
val dev = resolvePad()
bindRumble(dev)
if (Build.VERSION.SDK_INT >= 33) {
bindLights(dev)
} else {
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
}
running = true
rumbleThread = Thread({
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
// long one-shot.
val pad = ((ev ushr 49) and 0xFL).toInt()
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
renderRumble(
pad,
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
durationMs,
)
}
}, "pf-rumble").apply { isDaemon = true; start() }
@@ -80,102 +104,109 @@ class GamepadFeedback(private val handle: Long) {
}, "pf-hidout").apply { isDaemon = true; start() }
}
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
/** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */
fun stop() {
running = false
rumbleThread?.interrupt()
hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately
runCatching { legacy?.cancel() }
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
// and rendering is a quick best-effort binder call, so each thread observes running=false and
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
// pull (nativeNextRumble/nativeNextHidout) and read the router, so they MUST be dead before
// StreamScreen's onDispose reaches router.release() / nativeClose, which free that state. A
// *bounded* join that times out would let a thread survive into the freed handle → use-after-
// free SIGSEGV (the back-while-streaming crash, on the one path the main-thread `closed` guard
// can't cover). Safe to block unbounded: the native pulls are internally time-bounded
// (PULL_TIMEOUT ~100 ms) and rendering is a quick best-effort binder call, so each thread
// observes running=false and exits within ~one timeout — the join returns promptly.
runCatching { rumbleThread?.join() }
runCatching { hidoutThread?.join() }
rumbleThread = null
hidoutThread = null
runCatching { lightsSession?.close() }
lightsSession = null
rgbLight = null
playerLight = null
vm = null
legacy = null
vibratorIds = IntArray(0)
// Threads are dead — drop any held rumble and close every lights session.
for (b in rumbleBinds.values) b?.let {
runCatching { it.vm?.cancel() }
runCatching { it.legacy?.cancel() }
}
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
rumbleBinds.clear()
lightBinds.clear()
}
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
// ---- Rumble ----
private fun bindRumble(dev: InputDevice?) {
if (dev == null) {
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
return
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
private fun rumbleBindFor(pad: Int): RumbleBind? {
val dev = router?.deviceForPad(pad) ?: return null
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
val bind = bindRumble(dev)
rumbleBinds[dev.id] = bind
return bind
}
private fun bindRumble(dev: InputDevice): RumbleBind? {
if (Build.VERSION.SDK_INT >= 31) {
val m = dev.vibratorManager
val ids = m.vibratorIds
if (ids.isEmpty()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
return
return null
}
val amp = ids.all { m.getVibrator(it).hasAmplitudeControl() }
Log.i(TAG, "rumble: bound ${ids.size} vibrators for '${dev.name}' amplitudeControl=$amp")
return RumbleBind(m, null, ids, amp)
}
vm = m
vibratorIds = ids
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
} else {
// API 2830: no VibratorManager — fall back to the controller's single legacy Vibrator.
@Suppress("DEPRECATION")
val v = dev.vibrator
if (!v.hasVibrator()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
return
}
legacy = v
amplitudeControlled = v.hasAmplitudeControl()
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
return null
}
Log.i(TAG, "rumble: bound legacy vibrator for '${dev.name}' amplitudeControl=${v.hasAmplitudeControl()}")
return RumbleBind(null, v, IntArray(0), v.hasAmplitudeControl())
}
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
private fun renderRumble(low: Int, high: Int) {
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
/**
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
*/
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val m = vm
val m = bind.vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) {
if (bind.amplitudeControlled && bind.ids.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
} else {
// Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
for (id in bind.ids) combo.addVibrator(id, oneShot(a, durationMs))
}
runCatching { m.vibrate(combo.combine()) }
return
}
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = legacy ?: return
val lv = bind.legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
lv.vibrate(
if (bind.amplitudeControlled) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
}
}
@@ -185,35 +216,38 @@ class GamepadFeedback(private val handle: Long) {
return if (v16 != 0 && a == 0) 1 else a
}
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero.
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero.
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp)
// ---- HID output ----
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
buf.rewind()
val pad = buf.get().toInt() and 0xFF // wire pad index the event is addressed to
when (buf.get()) { // kind tag
TAG_LED -> {
val r = buf.get().toInt() and 0xFF
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
}
else -> Log.d(TAG, "hidout: unknown kind, dropped")
@@ -230,37 +264,46 @@ class GamepadFeedback(private val handle: Long) {
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
}
private fun bindLights(dev: InputDevice?) {
if (dev == null) {
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
return
/** The lights binding for the controller on wire pad [pad], or null (no live pad / no lights / < API 33). Cached by device id. */
private fun lightBindFor(pad: Int): LightBind? {
if (Build.VERSION.SDK_INT < 33) return null
val dev = router?.deviceForPad(pad) ?: return null
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
val bind = bindLights(dev)
lightBinds[dev.id] = bind
return bind
}
private fun bindLights(dev: InputDevice): LightBind? {
val lm = dev.lightsManager
var rgb: Light? = null
var player: Light? = null
for (l in lm.lights) {
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
if (rgb == null && l.hasRgbControl()) rgb = l
if (player == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) player = l
}
if (rgbLight == null && playerLight == null) {
if (rgb == null && player == null) {
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
return
return null
}
lightsSession = lm.openSession()
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
val session = lm.openSession()
Log.i(TAG, "lights: bound rgb=${rgb != null} playerLed=${player != null} for '${dev.name}'")
return LightBind(session, rgb, player)
}
private fun setLightbar(argb: Int) {
val s = lightsSession ?: return
val l = rgbLight ?: return
private fun setLightbar(pad: Int, argb: Int) {
val bind = lightBindFor(pad) ?: return
val l = bind.rgb ?: return
runCatching {
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
}
}
private fun setPlayerId(player: Int) {
val s = lightsSession ?: return
val l = playerLight ?: return
private fun setPlayerId(pad: Int, player: Int) {
val bind = lightBindFor(pad) ?: return
val l = bind.player ?: return
runCatching {
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
bind.session.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
}
}
}
@@ -0,0 +1,204 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.input.InputManager
import android.os.Handler
import android.os.Looper
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Multi-controller router for one stream session — the Android analogue of the Linux client's gamepad
* `Worker`/`Slot` model (`pf-client-core/src/gamepad.rs`) over the shared native-plane wire contract
* (`punktfunk-core/src/input.rs`). Each physical controller (Android `deviceId`) gets a STABLE
* lowest-free wire pad index (0..15) held for its lifetime and freed only on disconnect, so a pad
* dropping never renumbers the others (a game must not see its players shuffle). Every forwarded event
* carries that pad index; a [NativeBridge.nativeSendGamepadArrival] declaring the pad's type is sent
* once BEFORE its first input, a [NativeBridge.nativeSendGamepadRemove] on disconnect. Per-device axis
* state lives in each slot's [Gamepad.AxisMapper] so a second controller can't clobber the first.
* Feedback (rumble / HID) is routed BACK to the originating device by pad index via [deviceForPad].
*
* Selection: forward EVERY real controller (the Linux client's single-player pin has no Android UI
* surface yet — Automatic is the only mode). Lifetime matches the session: constructed on stream
* attach (opening a slot for every already-connected pad, so its Arrival lands before any input),
* released on detach.
*
* A single controller lands on wire index 0, so its per-transition button/axis wire is byte-identical
* to the old single-pad path (plus the Arrival/Remove declarations the contract requires — which an
* older host simply ignores).
*
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
* threads, so the slot table is a [ConcurrentHashMap].
*/
class GamepadRouter(context: Context, private val handle: Long, private val setting: Int) {
/** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */
private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
}
/** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */
private val slots = ConcurrentHashMap<Int, Slot>()
private val inputManager = context.getSystemService(InputManager::class.java)
private val listener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
InputDevice.getDevice(deviceId)?.let { if (isForwardable(it)) openSlot(it) }
}
override fun onInputDeviceRemoved(deviceId: Int) = closeSlot(deviceId)
override fun onInputDeviceChanged(deviceId: Int) {}
}
init {
inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
// Open a slot for every controller already connected when the session starts — the pads that
// will never fire onInputDeviceAdded during this session; their Arrival lands before any input.
for (id in InputDevice.getDeviceIds()) {
InputDevice.getDevice(id)?.let { if (isForwardable(it)) openSlot(it) }
}
}
/**
* One gamepad button transition for the device that produced [event] (already resolved to BTN_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, tracks held state, and returns true when this press completed the emergency
* stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream
* (mirrors the Linux client's escape chord: any one controller can leave).
*/
fun onButton(event: KeyEvent, bit: Int): Boolean {
val slot = slotFor(event.device) ?: return false
when (event.action) {
KeyEvent.ACTION_DOWN -> {
// repeatCount guard: don't re-send a held button as auto-repeat.
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
slot.held = slot.held or bit
if (slot.held and EXIT_CHORD == EXIT_CHORD) {
slot.held = 0
return true
}
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
slot.held = slot.held and bit.inv()
}
}
return false
}
/**
* One joystick MotionEvent — routed to the producing device's own [Gamepad.AxisMapper] (per-device
* state). Returns true if consumed. Only a real gamepad drives a pad: a DualSense/DS4 motion-sensor
* sibling node classifies as bare joystick (no GAMEPAD source class) and reports every pad axis as
* 0, so [isForwardable] filters it out before it can open a slot or clobber axes.
*/
fun onMotion(event: MotionEvent): Boolean {
if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK)) return false
if (event.actionMasked != MotionEvent.ACTION_MOVE) return false
val dev = event.device ?: return false
if (!isForwardable(dev)) return false
val slot = slotFor(dev) ?: return false
slot.mapper.onMotion(event)
return true
}
/**
* The controller currently mapped to wire pad [pad], for feedback routing; null if that index
* holds no live slot (a pad that just unplugged — the update is then dropped). Read from the
* feedback poll threads.
*/
fun deviceForPad(pad: Int): InputDevice? {
for ((deviceId, slot) in slots) {
if (slot.index == pad) return InputDevice.getDevice(deviceId)
}
return null
}
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
*/
fun release() {
inputManager?.unregisterInputDeviceListener(listener)
// Snapshot the ids first — closeSlot mutates the map.
for (id in slots.keys.toList()) closeSlot(id)
}
// ---- slots ----
/** A real, non-virtual controller we forward — its source classes include GAMEPAD (excludes a pad's bare-joystick sensor node). */
private fun isForwardable(dev: InputDevice): Boolean =
!dev.isVirtual && dev.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
/**
* The slot for [dev], opening one (and declaring the pad) if this device is unseen; null when [dev]
* isn't a forwardable controller or every wire index is taken. The [isForwardable] gate lives here —
* the single lazy-open chokepoint both [onButton] and [onMotion] funnel through — so no entry point
* can open a phantom slot for a virtual/non-gamepad source (the hot-plug listener and init loop
* pre-filter and call [openSlot] directly).
*/
private fun slotFor(dev: InputDevice?): Slot? {
if (dev == null) return null
slots[dev.id]?.let { return it }
if (!isForwardable(dev)) return null
return openSlot(dev)
}
/**
* Open a slot for [dev] on the lowest free wire index, declaring its kind ([NativeBridge.nativeSendGamepadArrival])
* before any input so the host builds a matching virtual device (mixed types across pads).
* Idempotent; null when all 16 wire indices are already forwarded.
*/
private fun openSlot(dev: InputDevice): Slot? {
slots[dev.id]?.let { return it }
val index = lowestFreeIndex() ?: return null // 16 pads already forwarded — drop this one
// Automatic resolves the pad's type from its VID/PID; an explicit setting forces every pad
// to that type (a single global choice — matches the handshake's session-default pref).
val pref = if (setting == Gamepad.PREF_AUTO) Gamepad.prefFor(dev) else setting
NativeBridge.nativeSendGamepadArrival(handle, pref, index)
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
slots[dev.id] = slot
return slot
}
/**
* Flush a slot's held wire state (so nothing sticks host-side), signal the removal, and free its
* index. Safe against an already-gone device — the flush emits wire events only, no device access.
*/
private fun closeSlot(deviceId: Int) {
val slot = slots.remove(deviceId) ?: return
releaseHeld(slot)
NativeBridge.nativeSendGamepadRemove(handle, slot.index)
}
/** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */
private fun releaseHeld(slot: Slot) {
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
bits = bits and bit.inv()
}
slot.held = 0
slot.mapper.reset() // zero sticks/triggers + release the HAT dpad
}
/** Lowest wire index 0..[MAX_PADS) not held by a slot, or null when full — stable lowest-free keeps indices from shuffling on hot-plug. */
private fun lowestFreeIndex(): Int? {
val taken = slots.values.mapTo(HashSet()) { it.index }
for (i in 0 until MAX_PADS) if (i !in taken) return i
return null
}
private companion object {
/** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */
const val MAX_PADS = 16
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
}
}
@@ -269,26 +269,43 @@ object NativeBridge {
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
// ---- Gamepad: one pad forwarded as pad 0 (Rust hardcodes flags=0) ----
// ---- Gamepad: each controller forwarded on its own wire pad index (0..15, low byte of flags) ----
// The pad index is assigned per Android device by GamepadRouter; a single controller lands on 0,
// so its wire is byte-identical to the old single-pad path. The core folds the per-transition
// events into seq'd GamepadState snapshots keyed on this index and owns the per-pad seq.
/** One gamepad button transition. bit: a [Gamepad].BTN_* bit. down: press/release. */
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean)
/** One gamepad button transition on wire pad [pad] (0..15). bit: a [Gamepad].BTN_* bit. down: press/release. */
external fun nativeSendGamepadButton(handle: Long, bit: Int, down: Boolean, pad: Int)
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
/** One gamepad axis update on wire pad [pad] (0..15). axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int, pad: Int)
/**
* Declare the controller KIND presented on wire pad [pad] (0..15) so the host builds a matching
* virtual device (mixed types across pads). pref: a [Gamepad].PREF_* wire byte. Send ONCE when a
* pad opens, BEFORE any of its input; an older host ignores it (that pad then uses the handshake's
* session-default kind — the pre-existing single-pad behaviour on pad 0).
*/
external fun nativeSendGamepadArrival(handle: Long, pref: Int, pad: Int)
/** Signal wire pad [pad] (0..15) was unplugged so the host tears its virtual device down. The core stamps the seq + re-sends. */
external fun nativeSendGamepadRemove(handle: Long, pad: Int)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
* Block up to ~100 ms for the next rumble update. Returns a packed positive long: bits 49..52 =
* wire pad index (0..15), bit 48 = has a v2 lease, bits 32..47 = ttl_ms, bits 16..31 = low, bits
* 0..15 = high (each amplitude 0..0xFFFF; 0/0 = stop), or -1 on timeout / session closed. Kotlin
* routes the update to the controller holding that pad index. Call from a dedicated poll thread.
*/
external fun nativeNextRumble(handle: Long): Long
/**
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
* ByteBuffer, capacity >= 64) as `[pad][kind][fields…]` (leading pad = the wire pad index to
* route to): Led=pad 01 r g b, PlayerLeds=pad 02 bits, Trigger=pad 03 which effect…. Returns the
* byte count, or -1 on timeout / session closed.
*/
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
}
+28 -3
View File
@@ -355,10 +355,34 @@ fn decode_loop(
};
let mut pcm = vec![0f32; pcm_scratch];
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)) {
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) => {
frame_samples = samples;
let n = samples * channels;
for &s in &pcm[..n] {
window_peak = window_peak.max(s.abs());
@@ -393,7 +417,8 @@ fn decode_loop(
}
}
Err(e) => log::debug!("audio: opus decode: {e}"),
},
}
}
Err(PunktfunkError::NoFrame) => {} // timeout
Err(_) => break, // session closed
}
+218 -75
View File
@@ -15,10 +15,11 @@ use ndk::media::media_format::MediaFormat;
use ndk::native_window::NativeWindow;
use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::reanchor::{GateVerdict, ReanchorGate};
use punktfunk_core::session::Frame;
use std::collections::VecDeque;
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::time::{Duration, Instant};
@@ -202,23 +203,31 @@ fn run_sync(
let mut fed: u64 = 0;
let mut rendered: 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
// 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.
let mut pending: Option<Frame> = None;
// Loss recovery: watch the host→client unrecoverable-drop count and ask for an IDR when it
// climbs.
let mut last_dropped = client.frames_dropped();
// Freeze-until-reanchor: the shared post-loss gate ([`punktfunk_core::reanchor::ReanchorGate`]).
// Armed on a frame-index gap or a dropped-count climb, it withholds the decoder's concealed output
// (released WITHOUT rendering — the SurfaceView keeps the last rendered frame on glass) until a
// proven clean re-anchor lifts it: an IDR (wire FLAG_SOF), an RFI anchor, or the 2nd recovery mark.
// `last_kf_req` throttles the keyframe intents it emits; `recovery_flags` carries each AU's
// user_flags from feed to present (keyed by the codec-echoed pts) so `on_decoded` reads the
// re-anchor signalling the platform decoder doesn't expose.
let mut gate = ReanchorGate::new(client.frames_dropped());
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
let mut last_kf_req: Option<Instant> = None;
// 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
// 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
// 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,
// 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);
// 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
@@ -241,6 +250,20 @@ fn run_sync(
if pending.is_none() {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
// Loss recovery (RFI): feed the frame index so a forward gap fires a throttled
// reference-frame-invalidation request — an RFI-capable host (AMD LTR / NVENC)
// recovers with a cheap clean P-frame instead of a full IDR. The same forward gap
// arms the freeze gate so the decoder's concealment is held off the screen until the
// recovery re-anchors. The frames_dropped keyframe path below stays the backstop.
if client.note_frame_index(frame.frame_index) {
gate.arm(Instant::now());
}
// Park this AU's re-anchor flags for the present side (keyed by the pts the codec
// echoes on the output buffer) — unconditional, unlike the HUD's `in_flight` map.
recovery_flags.push_back((frame.pts_ns / 1000, frame.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
}
if fed == 0 {
let p = &frame.data;
log::info!(
@@ -256,6 +279,7 @@ fn run_sync(
// the output buffer) for the decoded-point pairing in `drain`.
if stats.enabled() {
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_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
.then_some((lat_ns / 1000) as u64);
@@ -295,7 +319,13 @@ fn run_sync(
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
let work_t0 = Instant::now();
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;
if fed % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
@@ -320,8 +350,10 @@ fn run_sync(
wait,
&stats,
&mut in_flight,
clock_offset,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut gate,
&mut recovery_flags,
);
rendered += r;
discarded += d;
@@ -361,21 +393,19 @@ fn run_sync(
work_accum_ns = 0;
}
// Loss recovery: under infinite GOP the only recovery keyframe is one we request. The
// reassembler drops unrecoverable AUs (frames_dropped); the decoder then conceals the
// reference-missing delta frames that follow and renders them without error, so keying off
// a decode error rarely fires. Request an IDR when the drop count climbs, throttled — the
// decode stays wedged for several frames until the IDR lands, so requesting every frame
// would flood the control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped {
last_dropped = dropped;
// Loss recovery + overdue backstop, folded through the gate. Under infinite GOP the only
// recovery keyframe is one we request; the reassembler drops unrecoverable AUs (frames_dropped)
// and the decoder then conceals the reference-missing deltas and renders them without error, so
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
if gate.poll(client.frames_dropped(), now)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);
let _ = client.request_keyframe();
log::debug!("decode: requested keyframe (loss recovery, dropped={dropped})");
}
log::debug!("decode: requested keyframe (loss recovery / overdue re-anchor)");
}
}
@@ -418,8 +448,10 @@ fn now_monotonic_ns() -> i128 {
/// endpoint whenever the platform delivers render callbacks).
struct DisplayTracker {
stats: Arc<crate::stats::VideoStats>,
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
clock_offset: i64,
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample
/// 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,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden.
@@ -427,7 +459,10 @@ struct 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 {
stats,
clock_offset,
@@ -554,7 +589,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 display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us);
@@ -687,8 +723,10 @@ struct OutputReady {
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder.
Au(Frame),
/// A received access unit from the feeder, ready to queue into the decoder. The `bool` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — `true` when this AU revealed a forward
/// frame-index gap, so the loop arms the freeze gate (the feeder already fired the RFI request).
Au(Frame, bool),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts + the callback-time `decoded` stamp).
@@ -827,13 +865,13 @@ fn run_async(
// 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
// 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()));
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// 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,
// 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);
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
@@ -842,19 +880,13 @@ fn run_async(
let client = client.clone();
let stats = stats.clone();
let in_flight = in_flight.clone();
let clock_offset = clock_offset.clone();
let shutdown = shutdown.clone();
let ev_tx = ev_tx.clone();
std::thread::Builder::new()
.name("pf-decode-feed".into())
.spawn(move || {
feeder_loop(
client,
stats,
in_flight,
clock_offset as i128,
shutdown,
ev_tx,
);
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
})
.ok()
};
@@ -878,7 +910,14 @@ fn run_async(
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
let mut discarded: u64 = 0;
let mut last_dropped = client.frames_dropped();
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
// `present_ready` (present), keyed by the codec-echoed pts.
let mut gate = ReanchorGate::new(client.frames_dropped());
let mut recovery_flags: VecDeque<(u64, u32)> = VecDeque::new();
let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
@@ -904,6 +943,8 @@ fn run_async(
&mut ready,
&mut fmt_dirty,
&mut fatal,
&mut gate,
&mut recovery_flags,
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
@@ -916,23 +957,34 @@ fn run_async(
&mut ready,
&mut fmt_dirty,
&mut fatal,
&mut gate,
&mut recovery_flags,
));
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty {
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();
present_ready(
&codec,
&mut ready,
&stats,
&in_flight,
clock_offset,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
@@ -964,19 +1016,21 @@ fn run_async(
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
}
}
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped || aus_dropped > 0 {
last_dropped = dropped;
// Loss recovery + overdue backstop, folded through the gate. A parked-AU overflow drop is itself
// a loss, so it arms the freeze directly; the gate's `poll` then arms on a dropped-count climb
// and re-asks on an overdue freeze. All keyframe intents route through the shared 100 ms
// throttle so a multi-frame recovery gap can't flood the control stream.
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
if aus_dropped > 0 {
gate.arm(now);
}
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);
let _ = client.request_keyframe();
}
}
}
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
@@ -999,7 +1053,7 @@ fn feeder_loop(
client: Arc<NativeClient>,
stats: Arc<crate::stats::VideoStats>,
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
clock_offset: i128,
clock_offset: Arc<AtomicI64>,
shutdown: Arc<AtomicBool>,
ev_tx: mpsc::Sender<DecodeEvent>,
) {
@@ -1008,8 +1062,14 @@ fn feeder_loop(
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-frame-
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
let gap = client.note_frame_index(frame.frame_index);
if stats.enabled() {
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_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
@@ -1039,7 +1099,7 @@ fn feeder_loop(
}
}
}
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
break; // the decode loop is gone
}
}
@@ -1051,6 +1111,7 @@ fn feeder_loop(
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
/// dropped on overflow (the caller then requests a keyframe).
#[allow(clippy::too_many_arguments)] // two call sites; the freeze gate + flag map are threaded in
fn dispatch_event(
ev: DecodeEvent,
pending_aus: &mut VecDeque<Frame>,
@@ -1058,9 +1119,20 @@ fn dispatch_event(
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
fatal: &mut bool,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
) -> bool {
match ev {
DecodeEvent::Au(f) => {
DecodeEvent::Au(f, gap) => {
// A forward frame-index gap arms the freeze; park this AU's flags for the present side to
// fold `on_decoded` (keyed by the pts the codec will echo).
if gap {
gate.arm(Instant::now());
}
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
}
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
@@ -1081,6 +1153,10 @@ fn dispatch_event(
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
} else {
// A recoverable/transient codec error is a decode hiccup on a broken reference chain —
// arm the freeze so the concealed output it recovers into is held off the screen.
gate.arm(Instant::now());
}
}
}
@@ -1089,12 +1165,15 @@ fn dispatch_event(
/// 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
/// 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(
codec: &MediaCodec,
client: &NativeClient,
pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>,
fed: &mut u64,
oversized_dropped: &mut u64,
) {
while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap();
@@ -1105,14 +1184,20 @@ fn feed_ready(
continue;
};
let au = &frame.data;
let n = au.len().min(dst.len());
if n < au.len() {
if au.len() > dst.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!(
"decode: AU {} > input buffer {}, truncated",
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
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
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
unsafe {
@@ -1143,6 +1228,8 @@ fn present_ready(
tracker: &DisplayTracker,
rendered: &mut u64,
discarded: &mut u64,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
) {
if ready.is_empty() {
return;
@@ -1155,10 +1242,16 @@ fn present_ready(
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us, o.decoded_ns);
}
}
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
let now = Instant::now();
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let render = i == last;
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
@@ -1178,7 +1271,7 @@ fn present_ready(
}
}
}
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins drops); no-op while hidden
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
@@ -1298,27 +1391,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
/// `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
/// parking it forever would wedge the loop on a broken codec).
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
/// 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) {
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
let n = {
let dst = buf.buffer_mut();
let n = au.len().min(dst.len());
if n < au.len() {
if au.len() > dst.len() {
*oversized_dropped += 1;
log::warn!(
"decode: AU {} > input buffer {}, truncated",
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
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
}
};
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer: {e}");
@@ -1357,19 +1467,30 @@ fn drain(
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
tracker: &DisplayTracker,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
) -> (u64, u64) {
// Newest ready buffer so far (presented after the loop) with its HUD metadata —
// `Some((pts_us, decoded_ns))` only while the HUD is visible (the stamp read is gated).
// `Some((pts_us, decoded_ns))` only while the HUD is visible. `held_present` is the freeze gate's
// verdict for that newest buffer (`false` = a post-loss concealment to withhold).
let mut held: Option<(OutputBuffer<'_>, Option<(u64, i128)>)> = None;
let mut held_present = true;
let mut discarded: u64 = 0;
let mut wait = first_wait;
loop {
match codec.dequeue_output_buffer(wait) {
Ok(DequeuedOutputBufferInfoResult::Buffer(buf)) => {
wait = Duration::ZERO; // only the first dequeue may block
// Only the first dequeue may block; later ones poll (wait == ZERO).
wait = Duration::ZERO;
// Fold every dequeued frame through the gate in pts (== decode) order — even the ones
// the newest-wins policy discards — so the two-mark re-anchor count stays correct; the
// verdict of the newest (last folded) buffer decides whether it reaches glass.
let pts_us = buf.info().presentation_time_us().max(0) as u64;
let flags = take_flags(recovery_flags, pts_us);
held_present =
gate.on_decoded(flags, false, Instant::now()) == GateVerdict::Present;
let meta = if stats.enabled() {
// The dequeue IS the sync loop's decoded-availability instant.
let pts_us = buf.info().presentation_time_us().max(0) as u64;
let decoded_ns = now_realtime_ns();
note_decoded_pts(stats, in_flight, clock_offset, pts_us, decoded_ns);
Some((pts_us, decoded_ns))
@@ -1415,16 +1536,19 @@ fn drain(
}
}
}
// Present the newest ready frame, if any, and park its metadata for the render callback.
// Present the newest ready frame — UNLESS the gate is withholding it as a post-loss concealment,
// in which case release it without rendering (the SurfaceView keeps the last rendered frame frozen
// on glass) and count it as a discard rather than a display.
let mut rendered = 0;
if let Some((buf, meta)) = held {
match codec.release_output_buffer(buf, true) {
Ok(()) => {
match codec.release_output_buffer(buf, held_present) {
Ok(()) if held_present => {
rendered = 1;
if let Some((pts_us, decoded_ns)) = meta {
tracker.note_rendered(pts_us, decoded_ns);
}
}
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
Err(e) => log::warn!("decode: release_output_buffer: {e}"),
}
}
@@ -1466,6 +1590,25 @@ fn note_decoded_pts(
stats.note_decoded(e2e_us, decode_us);
}
/// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery
/// signalling (FLAG_SOF IDR marker / RECOVERY_ANCHOR / RECOVERY_POINT) rides the AU's flags, which are
/// only in scope at feed time — so the feed side parks `(pts_us, flags)` here and the present side
/// looks them up to fold [`ReanchorGate::on_decoded`]. Decode order == input order (low-latency, no
/// B-frames), so this evicts entries older than `pts_us` as it goes; a miss (probe filler, or an entry
/// aged past the cap) reads `0` — no recovery flags, decoded normally.
fn take_flags(map: &mut VecDeque<(u64, u32)>, pts_us: u64) -> u32 {
while let Some(&(p, f)) = map.front() {
if p > pts_us {
break; // future frame — leave it for its own output buffer
}
map.pop_front();
if p == pts_us {
return f;
}
}
0
}
/// Map the decoder's reported output colour to a BT.2020 HDR dataspace, or `None` for SDR. The
/// integer values are the Android MediaFormat colour constants the NDK shares: COLOR_TRANSFER
/// ST2084 = 6 (PQ/HDR10), HLG = 7; COLOR_RANGE FULL = 1, LIMITED = 2 (the host encodes limited).
+51 -27
View File
@@ -24,8 +24,13 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
@@ -37,22 +42,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
if handle == 0 {
return -1;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
Ok((pad, low, high, ttl)) => {
// The reorder gate already ran in the core, so this update is fresh. Encode the
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
let (lease_flag, ttl_bits) = match ttl {
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
None => (0, 0),
};
(jlong::from(pad & 0xF) << 49)
| lease_flag
| ttl_bits
| (jlong::from(low) << 16)
| jlong::from(high)
}
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
}
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
/// Led → `[0x01][r][g][b]` (len 4)
/// PlayerLeds → `[0x02][bits]` (len 2)
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
/// HID-output event, written into the caller's direct ByteBuffer as `[pad][kind][fields…]` (the
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
/// controller — multi-pad HID feedback):
/// Led → `[pad][0x01][r][g][b]` (len 5)
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + effect.len())
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
@@ -85,33 +105,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { r, g, b, .. } => {
if cap < 4 {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return -1;
}
out[0] = TAG_LED;
out[1] = r;
out[2] = g;
out[3] = b;
4
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
HidOutput::PlayerLeds { bits, .. } => {
if cap < 2 {
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return -1;
}
out[0] = TAG_PLAYER_LEDS;
out[1] = bits;
2
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
HidOutput::Trigger { which, effect, .. } => {
let n = 2 + effect.len();
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = TAG_TRIGGER;
out[1] = which;
out[2..n].copy_from_slice(&effect);
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..n].copy_from_slice(&effect);
n
}
HidOutput::TrackpadHaptic { .. } => {
@@ -161,6 +161,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
}
},
preferred_codec.clamp(0, u8::MAX as jint) as u8,
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
None,
launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous)
+64 -13
View File
@@ -145,13 +145,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendKey(
}
// ---- Gamepad: Kotlin captures (KeyEvent/MotionEvent) → NativeClient::send_input ---------------
// Single-pad model: exactly one controller, forwarded as pad 0 (flags = 0). Buttons carry the
// gamepad::BTN_* bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id
// in `code` and the value in `x` (sticks i16 32768..32767, +y = up; triggers 0..255). The host
// accumulates the incremental events into its virtual xpad. Wire contract: input.rs::gamepad.
// Multi-pad model: each physical controller is forwarded on its own wire pad index (0..15), carried
// in the low byte of `flags` on every per-pad event — the Kotlin side (`GamepadRouter`) assigns a
// stable lowest-free index per Android device and threads it here. Buttons carry the gamepad::BTN_*
// bit in `code` and pressed/released in `x` (1/0); axes carry the gamepad::AXIS_* id in `code` and
// the value in `x` (sticks i16 32768..32767, +y = up; triggers 0..255). The host accumulates the
// incremental events per pad into a matching virtual device. The core input task folds these into
// the seq'd GamepadState snapshots (keyed on this same `flags` index) and owns the per-pad seq — so
// the only thing this layer must get right is the index. Wire contract: input.rs::gamepad. A single
// controller lands on index 0, so its wire is byte-identical to the old single-pad path.
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down)` — one gamepad button transition.
/// `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press, 0=release.
/// `NativeBridge.nativeSendGamepadButton(handle, bit, down, pad)` — one gamepad button transition on
/// wire pad index `pad`. `bit`: a `gamepad::BTN_*` bit (e.g. BTN_A = 0x1000). `down`: 1=press,
/// 0=release. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadButton(
_env: JNIEnv,
@@ -159,21 +165,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle: jlong,
bit: jint,
down: jboolean,
pad: jint,
) {
// flags = 0: pad index 0 — single-pad model.
send_event(
handle,
InputKind::GamepadButton,
bit as u32,
i32::from(down != 0),
0,
0,
pad as u32,
);
}
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value)` — one gamepad axis update.
/// `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16 (32768..32767, +y=up) or
/// trigger 0..255.
/// `NativeBridge.nativeSendGamepadAxis(handle, axisId, value, pad)` — one gamepad axis update on wire
/// pad index `pad`. `axisId`: a `gamepad::AXIS_*` id (LS_X=0..RT=5). `value`: stick i16
/// (32768..32767, +y=up) or trigger 0..255. `pad`: wire pad index 0..15 (rides `flags`).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadAxis(
_env: JNIEnv,
@@ -181,7 +187,52 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepad
handle: jlong,
axis_id: jint,
value: jint,
pad: jint,
) {
// flags = 0: pad index 0 — single-pad model.
send_event(handle, InputKind::GamepadAxis, axis_id as u32, value, 0, 0);
send_event(
handle,
InputKind::GamepadAxis,
axis_id as u32,
value,
0,
pad as u32,
);
}
/// `NativeBridge.nativeSendGamepadArrival(handle, pref, pad)` — declare the controller KIND presented
/// on wire pad index `pad` so the host builds a matching virtual device (mixed types — pad 0 a
/// DualSense, pad 1 an Xbox pad). `pref`: the `GamepadPref` wire byte (rides `code`). `pad`: wire pad
/// index 0..15 (rides `flags`). Sent ONCE when a pad opens, BEFORE any of its input; the core re-sends
/// it a few times against datagram loss, and an older host ignores the unknown tag (that pad then uses
/// the session-default kind from the handshake — the pre-existing single-pad behaviour on pad 0).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadArrival(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pref: jint,
pad: jint,
) {
send_event(
handle,
InputKind::GamepadArrival,
pref as u32,
0,
0,
pad as u32,
);
}
/// `NativeBridge.nativeSendGamepadRemove(handle, pad)` — signal that wire pad index `pad` was
/// unplugged so the host tears its virtual device down. `pad` (rides `flags`) is the only field; the
/// core stamps the per-pad seq (in the snapshot seq space, so a reordered snapshot can't resurrect the
/// pad) and arms a re-send burst against datagram loss. An older host ignores the unknown tag.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendGamepadRemove(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
) {
send_event(handle, InputKind::GamepadRemove, 0, 0, 0, pad as u32);
}
+13 -15
View File
@@ -2,24 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Custom keys merged into the auto-generated Info.plist (GENERATE_INFOPLIST_FILE=YES
supplies the rest). NSBonjourServices is required for NWBrowser to browse this
service type on iOS/tvOS — without it the system blocks the browse and discovery
returns nothing. Kept OUT of the synchronized App/ + Sources/ groups so it isn't
auto-added as a bundle resource (which collides with Info.plist processing). -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>GCSupportedGameControllers</key>
<array>
<dict>
<key>ProfileName</key>
<string>ExtendedGamepad</string>
</dict>
<dict>
<key>ProfileName</key>
<string>MicroGamepad</string>
</dict>
</array>
<key>NSBonjourServices</key>
<array>
<string>_punktfunk._udp</string>
</array>
<!-- Standard-algorithm crypto only (AES-GCM via the Rust core) — exempt from export
compliance, but the key must be declared or every TestFlight build stalls on the
compliance question. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Allow CADisplayLink above 60 Hz on ProMotion iPhones: without this key the system
silently caps the link at 60 even when SessionPresenter asks for the stream's rate
via preferredFrameRateRange, so a 120 fps stream would present at half rate. -->
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
+4 -12
View File
@@ -14,19 +14,11 @@
<!-- Wake-on-LAN needs to send a UDP broadcast magic packet (a sleeping host has no ARP
entry, so unicast can't wake it). Since iOS 14 / tvOS 14 the OS blocks sending to
broadcast/multicast addresses unless the app carries this managed entitlement — it must
be requested from and approved by Apple for the App ID, then enabled in the provisioning
profile. macOS is not gated by this (its App Sandbox network.client/server cover it).
GATED pending Apple's approval of the request (form filed) — an unauthorized managed
entitlement breaks iOS/tvOS signing, so it's commented out to keep those apps releasable.
ON APPROVAL: (1) uncomment the two lines below, and (2) flip
PunktfunkConnection.wakeOnLANAvailable (PunktfunkConnection.swift) to enable the iOS/tvOS
wake path + UI. Until then iOS/tvOS Wake-on-LAN is a clean no-op — MACs are still learned
from mDNS so it works immediately once ungated. macOS is unaffected (separate entitlements
file, no multicast entitlement needed). -->
<!--
be approved by Apple for the App ID and enabled in the provisioning profile. macOS is not
gated by this (its App Sandbox network.client/server cover it), hence its separate file.
Approved and provisioned, so it's enabled here and PunktfunkConnection.wakeOnLANAvailable
is true on iOS/tvOS too. -->
<key>com.apple.developer.networking.multicast</key>
<true/>
-->
</dict>
</plist>
+3 -1
View File
@@ -40,6 +40,8 @@ let package = Package(
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]),
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
.testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]),
]
)
@@ -376,7 +376,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx;
@@ -412,7 +412,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = macosx;
@@ -436,6 +436,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -449,7 +450,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -477,6 +478,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Punktfunk;
INFOPLIST_KEY_GCSupportsControllerUserInteraction = YES;
INFOPLIST_KEY_GCSupportsGameMode = YES;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Your microphone is streamed to the connected punktfunk host, where it appears as a virtual microphone.";
@@ -490,7 +492,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -522,7 +524,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
@@ -552,7 +554,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
MARKETING_VERSION = 0.9.1;
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
@@ -49,6 +49,13 @@
ReferencedContainer = "container:Punktfunk.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "PUNKTFUNK_BILINEAR_LUMA"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -60,7 +60,8 @@ struct ContentView: View {
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" overlay). macOS-only in practice WoL is gated off on iOS/tvOS.
/// "Waking" phase of the connect overlay). Available on every platform now that the iOS/tvOS
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
@StateObject private var waker = HostWaker()
#if os(macOS)
/// Whether the hosting window is native-fullscreen right now (reported by
@@ -68,25 +69,32 @@ struct ContentView: View {
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
/// never covers the video.
@State private var isFullscreen = false
#endif
#if os(macOS) || os(tvOS)
/// 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
/// 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
#endif
#if !os(macOS)
@State private var showSettings = false
#endif
#if os(iOS) || os(macOS)
// A connected controller (+ the Settings toggle) swaps the whole home screen for
// 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
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
/// Auto-wake on connect (Settings General). On (default): a dial to an offline saved host
/// fires Wake-on-LAN up front and falls into the "Waking" wait if the dial fails. Off: connects
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
}
#endif
var body: some View {
Group {
@@ -108,7 +116,7 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
#if os(macOS)
#if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
// A session actually started remember it on the card ("Connected ago"
@@ -256,9 +264,26 @@ struct ContentView: View {
}
private var home: some View {
// The "Waking" overlay rides over BOTH home UIs (and the pre-connect window is still
// `home`, so it covers the whole wakeonlineconnect sequence).
homeBase.overlay { WakeOverlay(waker: waker) }
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
// still `home`, so it covers the whole dial wake online connect sequence): instant
// "Connecting" feedback on any dial, flowing seamlessly into the "Waking" wait if the host
// turns out to be asleep.
homeBase.overlay {
ConnectOverlay(
connectingHostName: connectingOverlayName,
waker: waker,
gamepadUI: gamepadUIActive,
onCancelConnect: { model.disconnect() })
}
}
/// The host label for the connect takeover's "Connecting" phase a plain dial in flight. Nil
/// during the delegated-approval wait (that has its own "Waiting for approval" prompt, so the
/// takeover must not stack over it) and, of course, when idle or streaming.
private var connectingOverlayName: String? {
guard awaitingApproval == nil, model.phase == .connecting, let host = model.activeHost
else { return nil }
return host.displayName
}
@ViewBuilder private var homeBase: some View {
@@ -278,13 +303,30 @@ struct ContentView: View {
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
}
}
#elseif os(iOS)
#else
Group {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
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 {
HomeView(
store: store, model: model, discovery: discovery,
@@ -295,14 +337,6 @@ struct ContentView: View {
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
}
@@ -315,12 +349,25 @@ struct ContentView: View {
}()
return ZStack {
stream(captureEnabled: pendingFingerprint == nil)
.blur(radius: pendingFingerprint != nil ? 32 : 0)
// Blur the live stream during the trust prompt (heavy) and during a resize (lighter
// the deliberate "hold on" while the host rebuilds its pipeline and the decoder
// re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps
// as before (its own overlay handles the transition).
.blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0))
.animation(.easeInOut(duration: 0.22), value: model.resizing)
.overlay {
if pendingFingerprint != nil {
Color.black.opacity(0.45)
}
}
// The resize spinner rides over the (blurred) stream; suppressed under the trust
// prompt, which owns the screen. It never hit-tests, so window-drag resizes keep
// steering and the next click still reaches the stream.
.overlay {
if pendingFingerprint == nil {
ResizeIndicatorView(active: model.resizing)
}
}
if let fp = pendingFingerprint {
TrustCardView(
fingerprint: fp,
@@ -362,11 +409,14 @@ struct ContentView: View {
#else
.background(Color.black)
.ignoresSafeArea()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
// disconnect control during play, the controller's buttons flow to the host instead of
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
// host as Start the Siri Remote is the intended disconnect path.
.onExitCommand { model.disconnect() }
// SWALLOW Menu/B during a session a game controller's B button ALSO surfaces as this
// UIKit menu press, so the old instant-disconnect here ended the session on every B
// press in gameplay. The button still reaches the host via GamepadCapture; the
// DELIBERATE exits are holding the remote's Back 1 s (SiriRemotePointer) and holding
// 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
}
@@ -395,6 +445,16 @@ struct ContentView: View {
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
},
// Resize overlay START the follower is main-actor, so this drives the blur
// + spinner synchronously the instant the window differs from the live mode.
onResizeTarget: { [weak model] w, h in
model?.resizeTargeted(width: w, height: h)
},
// Resize overlay END the coded dims of each new-mode IDR, reported from the
// decode pump thread; hop to the main actor to clear the overlay.
onDecodedSize: { [weak model] w, h in
Task { @MainActor in model?.resizeDecoded(width: w, height: h) }
},
endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage,
displayMeter: model.displayStage
@@ -418,17 +478,18 @@ struct ContentView: View {
}
.animation(.smooth(duration: 0.28), value: statsVerbosity)
}
#if os(macOS)
// The start-of-stream shortcut banner (Windows-client parity): the full
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
#if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the platform's
// 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
// 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) {
if captureEnabled && showShortcutHint {
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
.font(.geist(12, relativeTo: .caption))
Text(Self.shortcutHintText)
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
@@ -472,6 +533,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
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
@@ -517,7 +589,8 @@ struct ContentView: View {
// packet up front, so a genuinely-asleep host is waking while the connect times out; only
// when that dial FAILS do we fall into the visible "Waking" wait a cold box takes far
// longer to boot than a connect will sit and redial once it's back on mDNS.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable,
!host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so the wake-wait can observe it reappear
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
@@ -574,7 +647,9 @@ struct ContentView: View {
private func prepareWake(for host: StoredHost) {
if let live = discovery.hosts.first(where: { host.matches($0) }) {
store.updateMacs(host.id, macs: live.macAddresses) // learn on every platform
} else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
} else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty {
// Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the
// dial times out. With auto-wake off, connects go straight through (no packet).
let macs = host.wakeMacs
let ip = host.address
DispatchQueue.global(qos: .userInitiated).async {
@@ -0,0 +1,145 @@
// The unified "getting you connected" overlay one look for BOTH phases of reaching a host, so the
// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns
// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console
// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`.
//
// - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate
// so a host that takes a beat to answer no longer looks like nothing happened.
// - Waking (`waker.waking` non-nil): the dial failed on a sleeping host, so we're firing
// Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on
// timeout.
//
// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover the
// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the
// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among
// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that
// looked out of place there.
//
// The two phases hand off within a single view update (HostWaker clears `waking` and starts the
// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to
// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out).
import PunktfunkKit
import SwiftUI
struct ConnectOverlay: View {
/// Non-nil while a plain dial is in flight (the delegated-approval wait has its own prompt, so it
/// passes nil here). Drives the "Connecting" phase.
let connectingHostName: String?
@ObservedObject var waker: HostWaker
/// The console launcher is up full-screen aurora takeover; otherwise the default UI's Liquid
/// Glass modal.
var gamepadUI: Bool
/// Cancel a dial in flight tears down the (uncancelable) connect and returns the UI; the late
/// result is discarded by SessionModel's connect guard.
var onCancelConnect: () -> Void
private enum Phase {
case connecting(name: String)
case waking(HostWaker.Waking)
}
/// Waking takes precedence it only ever exists after a dial has already failed, so a stray
/// overlap can't strand the "Connecting" phase over a wake in progress.
private var phase: Phase? {
if let w = waker.waking { return .waking(w) }
if let name = connectingHostName { return .connecting(name: name) }
return nil
}
var body: some View {
if let phase {
ZStack {
if gamepadUI {
// Console: an opaque, living aurora over everything.
Color.black.ignoresSafeArea()
GamepadScreenBackground().ignoresSafeArea()
Color.clear.contentShape(Rectangle()).onTapGesture {}
content(phase).padding(40).frame(maxWidth: 460)
} else {
// Default UI: a Liquid Glass modal over a dim scrim.
Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea()
.contentShape(Rectangle()).onTapGesture {}
content(phase)
.padding(28)
.frame(maxWidth: 380)
.glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.padding(40)
}
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) }
#endif
}
}
@ViewBuilder private func content(_ phase: Phase) -> some View {
// The takeover carries larger type than the compact modal.
let titleSize: CGFloat = gamepadUI ? 24 : 19
let bodySize: CGFloat = gamepadUI ? 14 : 13
VStack(spacing: gamepadUI ? 16 : 14) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(.white)
Text("Connecting to \(name)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
case .waking(let w) where w.timedOut:
Image(systemName: "moon.zzz.fill")
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
Text("\(w.hostName) didn't wake")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
}
.padding(.top, 6)
case .waking(let w):
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered).padding(.top, 6)
}
}
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the overlay: B cancels whatever's in flight (a dial or the wake wait); A
/// retries once a wake has timed out. The closures read the live state on each press, so they stay
/// correct across the Connecting Waking handoff without the view re-mounting. A zero-size backing
/// view owning a `GamepadMenuInput` for the overlay's lifetime (the home is gated inactive while the
/// overlay is up, so nothing else is consuming the pad).
private struct ConnectControllerInput: View {
@ObservedObject var waker: HostWaker
var onCancelConnect: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.onAppear {
input.onBack = { if waker.waking != nil { waker.cancel() } else { onCancelConnect() } }
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
@@ -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 /
// 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
// 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.
// 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 SwiftUI
#if os(iOS) || os(macOS)
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.dismiss) private var dismiss
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
isActive: editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: 620)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 4) {
Text("Add Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(13, relativeTo: .caption))
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.multilineTextAlignment(.center)
.frame(maxWidth: 440)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
}
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in
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 {
#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 {
VStack(spacing: 10) {
GamepadKeyboard(
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
])
.frame(maxWidth: .infinity, alignment: .leading)
}
#endif
}
/// 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 {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
#if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Cancel")
}
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
}
private func rowView(_ row: Row, focused: Bool) -> some View {
HStack(spacing: 14) {
let m = GamepadFormMetrics.self
return HStack(spacing: 14) {
if row.isAction {
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))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body))
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
Spacer(minLength: 12)
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)
.lineLimit(1)
.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.
Rectangle()
.fill(Color.brand)
.frame(width: 2, height: 18)
.frame(width: 2, height: m.labelFont + 2)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// 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.
.consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous),
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1)
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
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
@@ -1,6 +1,11 @@
// 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
// 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)`
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
@@ -24,7 +29,7 @@
import PunktfunkKit
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 {
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 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
/// while the gamepad is driving that's the whole desync fix.
@State private var cursor = 0
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var body: some View {
GeometryReader { geo in
let inset = max(0, (geo.size.width - itemWidth) / 2)
ScrollViewReader { proxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
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)
.frame(width: itemWidth)
.contentShape(Rectangle())
.onTapGesture { tap(item) }
#endif
}
}
.frame(height: geo.size.height) // fill so shorter cards center vertically
.scrollTargetLayout()
}
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
// the poll's cursor design exists to avoid see the header), and on tvOS the
// focus engine's own reveal-scrolls are always in flight, so drops were routine
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
// below instead scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
#if !os(tvOS)
.scrollPosition(id: $scrolledID)
.scrollTargetBehavior(.viewAligned)
#endif
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden
// and paints a scroller across the console strip.
.scrollIndicators(.never)
.scrollClipDisabled() // let the focused card scale up past the strip bounds
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
#if os(tvOS)
// Land initial focus on the first card (the launcher's first host / the coverflow's
// first title) instead of wherever the engine guesses.
.defaultFocus($focusedID, items.first?.id)
// Focus moved (remote swipe / pad dpad) chase it: cursor, detail selection,
// controller detent, and an imperative center scroll.
.onChange(of: focusedID) { _, newValue in
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
lastNav = Date()
haptics.move()
selection = newValue
withAnimation(.easeOut(duration: scrollAnim)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
// 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(.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
// 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
// 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
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
guard let idx = index(of: newValue), idx != cursor else { return }
cursor = idx
selection = newValue
}
#endif
// 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
// 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
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.onConfirm = { activate() }
input.onSecondary = onSecondary
input.onTertiary = onTertiary
input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
#endif
}
private func move(_ direction: GamepadMenuInput.Direction) {
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
private func activate() {
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
haptics.confirm()
onActivate(items[cursor])
onActivate(item)
}
/// 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
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) {
@@ -2,11 +2,12 @@
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
// 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.
// 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 SwiftUI
#if os(iOS) || os(macOS)
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#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.
struct GamepadHint: Identifiable {
let glyph: String
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
struct GamepadHintBar: View {
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 {
HStack(spacing: 18) {
ForEach(hints) { hint in
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: 19))
.font(.system(size: Self.glyphFont))
.foregroundStyle(.white)
Text(hint.text)
}
.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))
.padding(13)
.padding(Self.pad)
.consoleGlass(Capsule())
.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
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
struct ControllerStatusChip: View {
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 {
HStack(spacing: 7) {
Image(systemName: controller.hasTouchpadAndMotion
? "playstation.logo" : "gamecontroller.fill")
.font(.system(size: 12))
.font(.system(size: Self.font))
Text(controller.name)
.lineLimit(1)
if let level = controller.batteryLevel {
Image(systemName: batterySymbol(level))
.font(.system(size: 12))
.font(.system(size: Self.font))
.foregroundStyle(level <= 0.2 && !controller.isCharging
? 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))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.padding(.horizontal, Self.hPad)
.padding(.vertical, Self.vPad)
.background(Capsule().fill(.white.opacity(0.08)))
.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
// 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
@@ -14,11 +14,13 @@
// `.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
// 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 SwiftUI
#if os(iOS) || os(macOS)
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
@@ -60,8 +62,12 @@ struct GamepadHomeView: View {
let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Same experimental gate the touch grid's "Browse Library" context-menu item uses.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
/// Same gate the touch grid's "Browse Library" context-menu item uses (default ON; the
/// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// Auto-wake on connect (default ON) when off, activating an offline host just dials (no wake),
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
#if os(iOS)
/// `.compact` in a landscape phone window drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
@@ -104,6 +110,12 @@ struct GamepadHomeView: View {
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`
// 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.
@@ -128,11 +140,17 @@ struct GamepadHomeView: View {
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@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)
// 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
// 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))
#endif
VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
@@ -145,7 +163,7 @@ struct GamepadHomeView: View {
private var titleBar: some View {
Text("Select a Host")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
@@ -158,6 +176,14 @@ struct GamepadHomeView: View {
}
}
private var cardSpacing: CGFloat {
#if os(tvOS)
44
#else
30
#endif
}
// MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
@@ -165,13 +191,16 @@ struct GamepadHomeView: View {
items: tiles,
selection: $selection,
itemWidth: cardWidth,
spacing: 30,
spacing: cardSpacing,
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
// Stop consuming the controller while another screen (or the wake overlay) is on top
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
isActive: libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
@@ -233,7 +262,7 @@ struct GamepadHomeView: View {
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !store.probedOnline.contains(host.id)
&& !host.wakeMacs.isEmpty,
activate: { connect(host) })
@@ -272,6 +301,31 @@ private struct GamepadHostTile: View {
let tile: HomeTile
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 {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 8) {
@@ -282,38 +336,38 @@ private struct GamepadHostTile: View {
HStack(spacing: 7) {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: 11, weight: .semibold))
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
if tile.isOnline {
Circle()
.fill(Color.green)
.frame(width: 9, height: 9)
.frame(width: Self.pipSide, height: Self.pipSide)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
}
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2))
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(tile.subtitle)
.font(.geist(13, relativeTo: .caption))
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.padding(.top, 2)
}
.padding(20)
.padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading)
// 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.
.consoleGlass(
RoundedRectangle(cornerRadius: 26, style: .continuous),
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.04)],
@@ -324,7 +378,7 @@ private struct GamepadHostTile: View {
}
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
@@ -335,15 +389,15 @@ private struct GamepadHostTile: View {
ProgressView().tint(.white)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: 24, weight: .semibold))
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(Color.brand)
} else {
Text(monogram(tile.title))
.font(.geistFixed(25, .bold))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? .white : Color.brand)
}
}
.frame(width: 52, height: 52)
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
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 gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
// 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
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
@@ -16,7 +22,7 @@
import PunktfunkKit
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 {
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 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`).
@State private var cursor = 0
/// 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) {
LazyVStack(spacing: 6) {
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)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
.id(item.id)
#endif
}
}
.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: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
// MARK: - Input wiring
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
switch direction {
case .up: step(by: -1)
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
}
input.onConfirm = { activate() }
input.onBack = onBack
#endif
}
private func step(by delta: Int) {
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
haptics.move()
}
private func adjust(by delta: Int) {
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
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)
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) {
@@ -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
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
/// Game-library browser (default ON; the Settings toggle opts out) the host-card
/// "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.
@State private var editTarget: StoredHost?
@@ -48,6 +49,13 @@ struct HomeView: View {
}
}
.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 {
discoveredSection
@@ -67,6 +75,14 @@ struct HomeView: View {
}
}
.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
}
}
@@ -198,6 +214,10 @@ struct HomeView: View {
}
.padding([.horizontal, .bottom])
.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
@@ -259,7 +279,9 @@ struct HomeView: View {
#if os(macOS)
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
#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
[GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif
@@ -22,8 +22,9 @@ private struct CardMetrics {
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
padding: 18, spacing: 18, radius: 14)
// 10-foot sizes the 24pt-name tier read like a phone card from the couch.
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
padding: 24, spacing: 22, radius: 18)
#else
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
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
// 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
@@ -15,7 +15,7 @@
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
struct LibraryCoverflowView: View {
@@ -21,9 +21,9 @@ struct LibraryView: View {
/// 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.
@State private var imageSession: URLSession?
#if os(iOS) || os(macOS)
// Gamepad-driven browsing (iOS/iPadOS/macOS) see ContentView's identical gate. tvOS keeps
// its existing plain-grid presentation of this same view unchanged.
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@ObservedObject private var gamepadManager = GamepadManager.shared
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
private var gamepadUIActive: Bool {
@@ -69,7 +69,6 @@ struct LibraryView: View {
} else if games.isEmpty {
emptyState
} else {
#if os(iOS) || os(macOS)
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
@@ -77,9 +76,6 @@ struct LibraryView: View {
} else {
grid
}
#else
grid
#endif
}
}
@@ -1,84 +0,0 @@
// The "Waking <host>" modal shown while HostWaker brings a sleeping host back a spinner + a
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
import PunktfunkKit
import SwiftUI
struct WakeOverlay: View {
@ObservedObject var waker: HostWaker
var body: some View {
if let w = waker.waking {
ZStack {
// Dim + swallow input to the home behind it.
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card(w)
.frame(maxWidth: 380)
.padding(28)
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.padding(40)
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { WakeControllerInput(waker: waker) }
#endif
}
}
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
VStack(spacing: 14) {
if w.timedOut {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
Text("\(w.hostName) didn't wake")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
}
.padding(.top, 6)
} else {
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
.monospacedDigit()
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered)
.padding(.top, 6)
}
}
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
/// gated inactive while a wake is up, so nothing else is consuming the pad).
private struct WakeControllerInput: View {
@ObservedObject var waker: HostWaker
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.onAppear {
input.onBack = { waker.cancel() }
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
ContentView()
#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)
#endif
// 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.
// 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))
#endif
}
// 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
@@ -49,8 +49,24 @@ enum ShotScenes {
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadAddHost())
},
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
AnyView(ShotWaking())
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .connecting))
},
ShotScene(name: "09b-waking", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .waking))
},
ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut))
},
// The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases.
ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .connecting, gamepadUI: false))
},
ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .waking, gamepadUI: false))
},
ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) {
AnyView(ShotConnect(kind: .timedOut, gamepadUI: false))
},
]
#endif
@@ -137,23 +153,53 @@ private struct ShotGamepadAddHost: View {
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
}
private struct ShotWaking: View {
/// The unified connect overlay (the real `ConnectOverlay`) in each phase instant "Connecting"
/// feedback, the "Waking" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation:
/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass
/// modal over the touch host grid.
private struct ShotConnect: View {
enum Kind { case connecting, waking, timedOut }
let kind: Kind
var gamepadUI = true
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in }
)
.overlay { WakeOverlay(waker: waker) }
backdrop
.overlay {
ConnectOverlay(
connectingHostName: kind == .connecting ? "Battlestation" : nil,
waker: waker,
gamepadUI: gamepadUI,
onCancelConnect: {})
}
.onAppear {
switch kind {
case .connecting:
break
case .waking:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14))
case .timedOut:
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true))
}
}
}
@ViewBuilder private var backdrop: some View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in })
} else {
ShotHome()
}
}
}
@@ -22,7 +22,7 @@ final class HostWaker: ObservableObject {
var timedOut = false
}
/// nil = idle; non-nil drives `WakeOverlay`.
/// nil = idle; non-nil drives the "Waking" phase of `ConnectOverlay`.
@Published private(set) var waking: Waking?
/// How long to wait for the host to reappear before giving up. Generous a cold boot + service
@@ -0,0 +1,41 @@
// The resize overlay (design/midstream-resolution-resize.md client resize UX). A Match-window
// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox
// decoder on the first new-mode IDR an unavoidable sub-second gap where the last frame lingers,
// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter,
// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption
// acknowledges the transition. It clears the instant a frame at the requested size decodes (the
// `onDecodedSize` END signal) or on the follower's safety timeout see `SessionModel.resizing`.
//
// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a
// resize the user triggers by dragging the window never swallows their next click.
import PunktfunkKit
import SwiftUI
struct ResizeIndicatorView: View {
/// Mirrors `SessionModel.resizing`; the fade in/out is driven off this.
let active: Bool
var body: some View {
ZStack {
if active {
VStack(spacing: 12) {
ProgressView().controlSize(.large).tint(.white)
Text("Resizing…")
.font(.geist(15, .medium, relativeTo: .callout))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 30)
.padding(.vertical, 24)
.glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.transition(.opacity.combined(with: .scale(scale: 0.92)))
}
}
.environment(\.colorScheme, .dark) // the spinner + glass read over any frame
.animation(.easeInOut(duration: 0.22), value: active)
.allowsHitTesting(false) // the stream keeps receiving input the whole time
}
}
@@ -2,6 +2,7 @@
// handshake phase, and the pump-thread main-actor stats relay.
import Foundation
import os
import PunktfunkKit
import SwiftUI
@@ -10,6 +11,15 @@ import SwiftUI
#elseif canImport(UIKit)
import UIKit
#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
/// values. NSLock instead of an actor the writer is the (non-async) pump thread.
@@ -99,6 +109,16 @@ final class SessionModel: ObservableObject {
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
/// HUD's "click to capture" / " releases" hint).
@Published var mouseCaptured = false
/// Resize overlay (design/midstream-resolution-resize.md client resize UX): true from the
/// instant a Match-window resize starts steering toward a new size until a frame at that size
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
/// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in
/// `ResizeIndicator`; this mirrors its `active` for SwiftUI.
@Published private(set) var resizing = false
/// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump,
/// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a
/// differently-sized frame. Ticked from the 1 Hz stats timer.
private var resizeIndicator = ResizeIndicator()
let meter = FrameMeter()
/// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's
@@ -119,6 +139,12 @@ final class SessionModel: ObservableObject {
private var audio: SessionAudio?
private var gamepadCapture: GamepadCapture?
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 }
@@ -163,13 +189,25 @@ final class SessionModel: ObservableObject {
let displayHDR: Bool = {
#if os(macOS)
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
return UIScreen.main.potentialEDRHeadroom > 1.0
#endif
}()
let hdrCapable = hdrEnabled && displayHDR
// 4:4:4 opt-out (default on); the hardware-decode probe below is the real gate.
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? true
// 4:4:4 opt-IN (default off): full chroma is a per-client choice a clear win for
// desktop/text work, but at a fixed bitrate it spends bits on chroma that game content
// doesn't visibly need, and the encode/decode pixel rate rises. The host allows it by
// default (PUNKTFUNK_444, default on), so this toggle is the one real switch; the
// hardware-decode probe below still gates what can actually be advertised.
let want444 = (UserDefaults.standard.object(forKey: DefaultsKey.enable444) as? Bool) ?? false
Task.detached(priority: .userInitiated) {
// PunktfunkConnection.init blocks on the QUIC handshake keep it off the main
// actor. The persistent identity is presented on every connect so a paired
@@ -300,6 +338,10 @@ final class SessionModel: ObservableObject {
// connection is still up); the feedback drain joins off-main like audio.
gamepadCapture?.stop()
gamepadCapture = nil
#if os(tvOS)
remotePointer?.stop() // releases any held click while the connection is still up
remotePointer = nil
#endif
let feedback = gamepadFeedback
gamepadFeedback = nil
if let conn = connection {
@@ -332,6 +374,8 @@ final class SessionModel: ObservableObject {
lostFrames = 0
lostPct = 0
mouseCaptured = false
resizing = false
resizeIndicator = ResizeIndicator() // no stale target/timer into the next session
}
/// Called (via the main actor) when the pump hits end-of-session.
@@ -342,6 +386,23 @@ final class SessionModel: ObservableObject {
errorMessage = "Session ended by \(name)."
}
/// Resize overlay START (main actor from the Match-window follower's `onResizeTarget`): the
/// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is
/// imminent. Show the blur+spinner immediately, before the debounced request even leaves.
func resizeTargeted(width: UInt32, height: UInt32) {
resizeIndicator.steering(
width: width, height: height, now: Date().timeIntervalSinceReferenceDate)
resizing = resizeIndicator.active
}
/// Resize overlay END (main actor hopped from the decode pump's `onDecodedSize`): a new-mode
/// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're
/// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op).
func resizeDecoded(width: Int, height: Int) {
resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0)))
resizing = resizeIndicator.active
}
private func beginStreaming() {
guard let conn = connection else { return }
// Input capture itself is owned by StreamView (engaged by the captureEnabled
@@ -358,16 +419,26 @@ final class SessionModel: ObservableObject {
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
self.audio = audio
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
// session's virtual pad is a DualSense). Same trust gate as audio nothing is
// Gamepads: forward every controller GamepadManager selected each on its own wire pad
// index (a pin forwards only one, Automatic forwards all) and render the host's feedback
// back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers
// when a pad's virtual device is a DualSense). Same trust gate as audio nothing is
// forwarded during the trust prompt.
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()
gamepadCapture = capture
let feedback = GamepadFeedback(connection: conn, manager: .shared)
feedback.start()
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() {
@@ -376,6 +447,11 @@ final class SessionModel: ObservableObject {
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
// Resize-overlay safety net: clear a stuck overlay when a targeted size never
// decodes (a rejected/capped switch). The decoded-frame END clears it promptly on
// success; this only fires after the timeout.
self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate)
self.resizing = self.resizeIndicator.active
let (frames, bytes, total) = self.meter.drain()
self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000
@@ -429,12 +505,32 @@ final class SessionModel: ObservableObject {
} else {
self.decodeValid = false
}
if let d = self.displayStage.drain() {
let displayWindow = self.displayStage.drain()
if let d = displayWindow {
self.displayP50Ms = d.p50Ms
self.displayValid = true
} else {
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.
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
// this card its frame (and, on iOS, its clamped corner) animate to the new size rather
// than cross-fading a whole new card in. Only the inner content switches per tier.
tierContent
.padding(10)
.padding(cardPadding)
.glassBackground(cardShape)
.padding(edgeInset)
}
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
.foregroundStyle(.secondary)
}
#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)
// 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)
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
.font(.geist(12, relativeTo: .caption))
#else
#elseif os(iOS)
Button("Disconnect") { model.disconnect() }
.font(.geist(12, relativeTo: .caption))
#endif
#endif
}
}
// MARK: - Card metrics
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.)
/// 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); on macOS/tvOS windows the classic 10 reads fine.
/// The card's inner content padding. Roomier on tvOS the stat text auto-scales for the
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
private var cardPadding: CGFloat {
#if os(tvOS)
return 16
#else
return 10
#endif
}
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
/// keep the classic 10.
private var edgeInset: CGFloat {
#if os(iOS)
return 14
#elseif os(tvOS)
return 24
#else
return 10
#endif
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
private var cardCornerRadius: CGFloat {
#if os(iOS)
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
#elseif os(tvOS)
return 16 // scales with the roomier padding
#else
return 10
#endif
@@ -1,13 +1,25 @@
import PunktfunkKit
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.
struct AcknowledgementsView: View {
private var version: 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 {
ScrollView {
// 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.
LazyVStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 18) {
Text("punktfunk")
.font(.geist(22, .bold, relativeTo: .title2))
Text("Punktfunk")
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
if let version {
Text("Version \(version)")
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
}
Text(Licenses.appLicense)
LicenseWall(text: Licenses.appLicense)
.font(.caption.monospaced())
.modifier(SelectableText())
Divider()
Text("Bundled font")
.font(.geist(17, .semibold, relativeTo: .headline))
Text("punktfunk ships the Geist typeface (Geist Sans), "
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("Punktfunk ships the Geist typeface (Geist Sans), "
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
+ "License 1.1.")
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
if !Licenses.fontLicense.isEmpty {
Text(Licenses.fontLicense)
LicenseWall(text: Licenses.fontLicense)
.font(.caption2.monospaced())
.modifier(SelectableText())
}
Divider()
Text("Third-party software")
.font(.geist(17, .semibold, relativeTo: .headline))
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
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+ "
+ "(dynamically linked, replaceable)."
)
.font(.geist(12, relativeTo: .caption))
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
.font(.caption2.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(SelectableText())
.modifier(TVFocusable())
}
}
.frame(maxWidth: 900, alignment: .leading)
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
#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
// 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
@@ -13,7 +13,7 @@
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
struct GamepadSettingsView: View {
@@ -26,7 +26,7 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) private var enable444 = true
@AppStorage(DefaultsKey.enable444) private var enable444 = false
@AppStorage(DefaultsKey.codec) private var codec = "auto"
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
@@ -34,8 +34,10 @@ struct GamepadSettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
= StatsVerbosity.current.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.autoWake) private var autoWakeEnabled = true
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
@ObservedObject private var gamepads = GamepadManager.shared
#if os(iOS)
@@ -47,6 +49,9 @@ struct GamepadSettingsView: View {
private let compact = false // no size classes on macOS; the sheet is sized generously
#endif
@State private var focusID: String?
/// The 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 {
GamepadMenuList(
@@ -57,13 +62,13 @@ struct GamepadSettingsView: View {
onBack: { dismiss() }
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: 620)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
Text("Settings")
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(.white)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
@@ -74,7 +79,7 @@ struct GamepadSettingsView: View {
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(13, relativeTo: .caption))
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
@@ -107,61 +112,78 @@ struct GamepadSettingsView: View {
private var closeButton: some View {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
#if !os(tvOS)
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
#endif
.accessibilityLabel("Close settings")
}
// MARK: - Row rendering
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 {
Text(header)
.font(.geist(12, .semibold, relativeTo: .caption))
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
.tracking(1.4)
.foregroundStyle(.white.opacity(0.45))
.padding(.leading, 16)
.padding(.leading, m.rowHPad)
.padding(.top, 14)
}
HStack(spacing: 14) {
Image(systemName: row.icon)
.font(.system(size: 17))
.font(.system(size: m.iconFont))
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
.frame(width: 28)
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(16, .semibold, relativeTo: .body))
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(.white)
.lineLimit(1)
Spacer(minLength: 12)
HStack(spacing: 9) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .semibold))
.font(.system(size: m.chevronFont, weight: .semibold))
.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)
.font(.geist(15, .medium, relativeTo: .callout))
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? .white : .white.opacity(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
insertion: .offset(x: slide).combined(with: .opacity),
removal: .offset(x: -slide).combined(with: .opacity)))
}
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous),
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -193,10 +215,12 @@ struct GamepadSettingsView: View {
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
/// (never on state captured at wire time).
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) {
lastAdjustDelta = 1 // A always cycles forward
rows.first { $0.id == id }?.activate()
}
@@ -235,6 +259,11 @@ struct GamepadSettingsView: View {
+ "available on the host.",
options: SettingsOptions.compositors, current: compositor
) { compositor = $0 },
toggleRow(
id: "autoWake", icon: "power", label: "Auto-wake on connect",
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
+ "streaming. Off connects straight through.",
value: $autoWakeEnabled),
choiceRow(
id: "codec", header: "Video", icon: "film", label: "Video codec",
@@ -252,6 +281,12 @@ struct GamepadSettingsView: View {
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
+ "hardware decode.",
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(
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
@@ -287,8 +322,7 @@ struct GamepadSettingsView: View {
) { hudPlacement = $0 },
toggleRow(
id: "library", icon: "square.grid.2x2", label: "Game library",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
+ "(experimental).",
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
value: $libraryEnabled),
toggleRow(
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
@@ -37,6 +37,30 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS runs
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
/// an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(tvOS)
"stage3"
#else
"stage2"
#endif
}
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) the `tag` is the raw value.
static let statsVerbosities: [(label: String, tag: String)] =
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
@@ -105,8 +129,8 @@ enum SettingsOptions {
return options
}
#if os(iOS) || os(macOS)
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list)
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
// touch/remote tvOS SettingsView builds its own preset list)
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
@@ -124,8 +148,8 @@ enum SettingsOptions {
@MainActor
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
var native: [(name: String, w: Int, h: Int)] = []
#if os(iOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
#if os(iOS) || os(tvOS)
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
native = [("This device",
Int(max(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.
@MainActor
static func refreshRates(including current: Int) -> [Int] {
#if os(iOS)
#if os(iOS) || os(tvOS)
let maxHz = UIScreen.main.maximumFramesPerSecond
#else
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
@@ -155,5 +179,4 @@ enum SettingsOptions {
if !rates.contains(current) { rates.append(current) }
return rates.sorted()
}
#endif
}
@@ -13,6 +13,11 @@ extension SettingsView {
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var streamModeSection: some View {
Section {
#if os(iOS) || os(macOS)
// Match-window (design/midstream-resolution-resize.md D1): follow the session
// window/scene, renegotiating the host mode on a resize. Off the explicit mode below.
Toggle("Match window", isOn: $matchWindow)
#endif
#if os(iOS)
iosResolutionWheel
iosRefreshRows
@@ -35,8 +40,12 @@ extension SettingsView {
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
Text(matchWindow
? "The stream follows this window — the host resizes its virtual output to match "
+ "as you resize, so the picture stays pixel-exact (1:1) with no scaling. "
+ "\(Self.bitrateFooter)"
: "The host creates a virtual output at exactly this mode — native resolution, but "
+ "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -286,6 +295,24 @@ extension SettingsView {
}
}
/// Auto-wake on connect fire Wake-on-LAN + wait for a sleeping saved host to come back before
/// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted).
@ViewBuilder var wakeSection: some View {
Section {
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
} header: {
Text("Wake-on-LAN")
} footer: {
Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN "
+ "packet and waits for it to come back before streaming. Turn off if a host that's "
+ "already on just isn't visible here (e.g. over a VPN), so connects go straight "
+ "through instead of waiting out the wake. A host's “Wake” action still works either "
+ "way.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
@ViewBuilder var windowSection: some View {
#if os(macOS)
Section {
@@ -380,8 +407,9 @@ extension SettingsView {
Text("Codec is a preference; the host falls back if it can't encode your choice. "
+ "HDR (HDR10) and full chroma (4:4:4) are HEVC-only, and each engages only when "
+ "both this device and the host support it — otherwise the stream stays 8-bit "
+ "4:2:0 SDR. 4:4:4 sharpens text and UI for extra bandwidth. Applies from the "
+ "next session.")
+ "4:2:0 SDR. 4:4:4 (off by default) sharpens text and UI — best for desktop "
+ "work; for games the bits are better spent at 4:2:0. Applies from the next "
+ "session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -133,8 +133,10 @@ extension SettingsView {
.foregroundStyle(.secondary)
}
Spacer()
if gamepads.active?.id == controller.id {
Text("In use")
// Every forwarded controller is surfaced (not just the primary `active`) with its
// wire pad index as a player number a pin forwards only one, Automatic forwards all.
if let pad = gamepads.padIndex(for: controller) {
Text("Player \(pad + 1)")
.font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 8)
.padding(.vertical, 3)
@@ -21,10 +21,14 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.streamWidth) var width = 1920
@AppStorage(DefaultsKey.streamHeight) var height = 1080
@AppStorage(DefaultsKey.streamHz) var hz = 60
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
@AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
#if os(macOS)
@AppStorage(DefaultsKey.vsync) var vsync = false
#endif
@@ -32,8 +36,8 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
#endif
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
@AppStorage(DefaultsKey.enable444) var enable444 = true
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
@AppStorage(DefaultsKey.enable444) var enable444 = false
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@@ -43,9 +47,8 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
@ObservedObject var gamepads = GamepadManager.shared
#if !os(tvOS)
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
#endif
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
#if DEBUG && !os(tvOS)
@State var showControllerTest = false
#endif
@@ -107,6 +110,7 @@ struct SettingsView: View {
Form {
streamModeSection
compositorSection
wakeSection
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
@@ -236,6 +240,7 @@ struct SettingsView: View {
streamModeSection
pointerSection
compositorSection
wakeSection
}
.formStyle(.grouped)
.navigationTitle("General")
@@ -284,19 +289,6 @@ struct SettingsView: View {
("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> {
Binding(
get: { "\(width)x\(height)x\(hz)" },
@@ -313,6 +305,16 @@ struct SettingsView: View {
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 autoWakeEnabledTag: Binding<String> {
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
}
private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds
@@ -338,7 +340,7 @@ struct SettingsView: View {
selection: $audioChannels)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption))
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
@@ -347,15 +349,19 @@ struct SettingsView: View {
selection: $compositor)
TVSelectionRow(
title: "Presenter",
options: Self.presenterOptions,
options: SettingsOptions.presenters,
selection: $presenter)
TVSelectionRow(
title: "10-bit HDR",
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
TVSelectionRow(
title: "Auto-wake on connect",
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "is honored only if available on the host.")
.font(.geist(12, relativeTo: .caption))
+ "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a "
+ "sleeping saved host and waits for it before streaming.")
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
@@ -375,8 +381,11 @@ struct SettingsView: View {
TVSelectionRow(
title: "Controller type", options: SettingsOptions.padTypes,
selection: $gamepadType)
TVSelectionRow(
title: "Gamepad-optimized browsing",
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
Text(Self.controllersFooter)
.font(.geist(12, relativeTo: .caption))
.font(.geist(20, relativeTo: .caption))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
var interactive = false
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)
} else {
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 {
var g: Glass = .regular
if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() }
return g
}
#endif
}
extension View {
@@ -48,7 +48,7 @@ struct PairSheet: View {
+ "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.")
.font(.geist(16, relativeTo: .callout))
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TVFieldRow(
@@ -59,7 +59,7 @@ struct PairSheet: View {
) { editing = .clientName }
if let errorText {
Text(errorText)
.font(.geist(16, relativeTo: .callout))
.font(.geist(22, relativeTo: .callout))
.foregroundStyle(.red)
}
HStack(spacing: 32) {
@@ -263,19 +263,24 @@ public final class SessionAudio {
defer { drainDone.signal() }
// 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.
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?
do {
pcm = try connection.nextAudioPcm(timeoutMs: 100)
} 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
if let base = p.baseAddress {
ring.write(base, count: pcm.frameCount * pcm.channels)
}
}
return true
}
}
}
thread.name = "punktfunk-audio"
@@ -59,6 +59,26 @@ public extension PunktfunkInputEvent {
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad)
}
/// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the
/// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4,
/// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot
/// opens BEFORE that pad's first input so the host builds a matching virtual device and a
/// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few
/// times against datagram loss and folds per-pad state behind it; a host that predates the tag
/// ignores it and uses the session-default kind from the handshake. Idempotent on the host.
static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent {
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad)
}
/// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the
/// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared
/// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's
/// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag
/// ignores it (the pad then lingers until session end the pre-existing behaviour).
static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent {
make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad)
}
// Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes
// fingers and is reusable after touchUp; coordinates are absolute pixels on the
// client's touch surface, whose size rides in `flags` so the host can rescale
@@ -70,19 +70,10 @@ func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R)
public extension PunktfunkConnection {
/// Whether the Wake-on-LAN broadcast path is usable on this platform/build. macOS can always
/// broadcast (its App Sandbox network entitlements cover it). iOS/tvOS need the managed
/// `com.apple.developer.networking.multicast` entitlement, which is GATED pending Apple's
/// approval (see `Config/Punktfunk.entitlements`) until it's granted, sending a broadcast is
/// blocked by the OS, so the wake path + its UI are gated off there to avoid a dead action.
/// The MAC-learning path stays active on every platform, so flipping this on once the
/// entitlement lands makes wake work immediately. ON APPROVAL: change `#if os(macOS)` below to
/// `true` for iOS/tvOS too (and uncomment the entitlement).
static var wakeOnLANAvailable: Bool {
#if os(macOS)
return true
#else
return false
#endif
}
/// `com.apple.developer.networking.multicast` entitlement now approved and enabled (see
/// `Config/Punktfunk.entitlements`), so wake is available on every platform. Kept as the single
/// switch every call site gates on, should a future build ever need to disable it.
static var wakeOnLANAvailable: Bool { true }
/// Send a Wake-on-LAN magic packet to wake a sleeping host. `macs` are the host's NIC MAC(s)
/// (`aa:bb:cc:dd:ee:ff`, learned from its mDNS `mac` TXT while awake); malformed entries are
@@ -445,6 +436,35 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_request_keyframe(h)
}
/// Feed each received AU's `frameIndex` (in receive order) so the client recovers from loss with a
/// cheap reference-frame invalidation instead of always paying for a full IDR. On a forward gap
/// a `frameIndex` jump means the intervening frames were lost and the following AUs reference a
/// picture that never arrived the core fires a THROTTLED RFI request for the lost range, and an
/// RFI-capable host (AMD LTR / NVENC) recovers with a clean P-frame rather than a 20-40× IDR
/// spike. Call it for every received AU; the `framesDropped`-driven `requestKeyframe()` path stays
/// the backstop for when the recovery frame itself is lost. Cheap; silently dropped after close.
public func noteFrameIndex(_ frameIndex: UInt32) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_note_frame_index(h, frameIndex, nil)
}
/// Like `noteFrameIndex`, but also reports whether the core saw a FORWARD frame-index gap the
/// signal that intervening frames were lost and the following AUs reference a picture that never
/// arrived. The post-loss re-anchor gate arms its display freeze on a gap (the earliest, most
/// precise loss trigger ahead of the `framesDropped` climb). Same core side effect as
/// `noteFrameIndex` (the throttled RFI request); call it for every received AU. Returns false
/// after close.
public func noteFrameIndexGap(_ frameIndex: UInt32) -> Bool {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return false }
var gap = false
_ = punktfunk_connection_note_frame_index(h, frameIndex, &gap)
return gap
}
/// Cumulative access units the hostclient reassembler dropped as unrecoverable (FEC couldn't
/// rebuild them). The video pump polls this and calls `requestKeyframe()` when it climbs the
/// correct loss trigger under the host's infinite GOP, where unrecoverable loss yields
@@ -570,7 +590,8 @@ public final class PunktfunkConnection {
/// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
/// self-termination TTL use `nextRumble2` to honor the host lease.
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
feedbackLock.lock()
defer { feedbackLock.unlock() }
@@ -590,6 +611,33 @@ public final class PunktfunkConnection {
}
}
/// Pull the next force-feedback update *including its self-termination TTL* (v2 envelopes):
/// `(pad, low, high, ttlMs)`. `ttlMs` is how long to render this level before silencing unless
/// the host renews it; `RumbleTuning.noTTL` (`UInt32.max`) means "no lease" a legacy host, so
/// fall back to a client-side staleness timeout. The reorder gate (seq) already ran in the
/// core, so a stale/reordered envelope never surfaces here. Drain from the (single) feedback
/// thread, alongside `nextHidOutput`.
public func nextRumble2(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, ttl: UInt32 = .max
let rc = punktfunk_connection_next_rumble2(h, &pad, &low, &high, &ttl, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, ttl)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// One DualSense feedback event a game wrote to the host's virtual pad replay it on
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
@@ -1,24 +1,33 @@
// Gamepad capture punktfunk/1 datagrams. Forwards exactly ONE controller whatever
// GamepadManager selected as pad 0, for the lifetime of a streaming session.
// Gamepad capture punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected
// each on its own stable wire pad index (pf-client-core's slot model) for the lifetime of a
// streaming session. One physical controller with no pin is player 0 (byte-identical to the old
// single-pad path); a pin forwards only that one, also as pad 0.
//
// The wire is incremental (one button/axis transition per 18-byte event, accumulated
// host-side into the virtual pad see punktfunk_core::input::gamepad), so we snapshot the
// full GCExtendedGamepad state on every valueChanged and diff against the previous
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons,
// axes, touchpad fingers, motion throttle) for its pad index isolated per device so two
// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its
// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to
// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down).
//
// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side
// into the virtual pad see punktfunk_core::input::gamepad), so we snapshot the full
// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks
// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these
// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index,
// so all this layer must get right is the index one controller per slot, one slot per index.
//
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
// 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
// unless the session's virtual pad is a DualSense or DualShock 4 both carry a touchpad
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
// `touchpad*` surface as `GCDualSenseGamepad`).
// 0...65535 (origin top-left, +y down GC's ±1/+y-up is converted here) and motion samples in
// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g derived from the
// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale
// correction is a one-line change). The host ignores both unless a pad's virtual device is a
// DualSense or DualShock 4 both carry a touchpad and motion, so the capture below covers either
// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`).
//
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
// toggle a controller can't click local UI, so it always drives the host while the app
// is active. On deactivation, controller switch, or stop, every held control is released
// on the wire (the host pad would otherwise stay stuck on the last state).
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle a
// controller can't click local UI, so it always drives the host while the app is active. On
// deactivation, controller switch, or stop, every held control is released on the wire (the host
// pad would otherwise stay stuck on the last state).
#if os(macOS)
import AppKit
@@ -33,31 +42,70 @@ import GameController
public final class GamepadCapture {
private let connection: PunktfunkConnection
private let manager: GamepadManager
private var activeSub: AnyCancellable?
private var forwardedSub: AnyCancellable?
private var observers: [NSObjectProtocol] = []
private var bound: GCController?
/// App inactive GC stops delivering; everything is released and stays silent.
private var suspended = false
// Last wire state (the diff base also what releaseAll() unwinds).
private var buttons: UInt32 = 0
private var axes: [Int32] = [0, 0, 0, 0, 0, 0]
private var fingerActive: [Bool] = [false, false]
private var lastMotionNs: UInt64 = 0
/// One forwarded controller: the open device plus the last wire state for its pad index (the
/// diff base also what `flush` unwinds). Held per Slot so two controllers never clobber each
/// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`.
private final class Slot {
let controller: GCController
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
/// event this controller sends the low byte of `flags`.
let pad: UInt32
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
let pref: PunktfunkConnection.GamepadType
var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
var fingerActive: [Bool] = [false, false]
var lastMotionNs: UInt64 = 0
init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) {
self.controller = controller
self.pad = pad
self.pref = pref
}
}
/// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled
/// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`).
private var slots: [Slot] = []
/// Motion forwarding floor: 4 ms between samples ( 250 Hz, the DualSense's own rate).
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) {
self.connection = connection
self.manager = manager
}
public func start() {
// Fires immediately with the current selection, then on every change a switch
// releases the old controller's wire state before the new one takes over.
activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.rebind(to: dc?.controller) }
// Session-scoped index assignment: a controller pinned before the session forwards as
// pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time).
manager.resetForwardingAssignment()
// Fires immediately with the current forwarded set, then on every change a connect,
// disconnect, or pin change reconciles the open slots against it (opening/closing devices
// and flushing wire state so nothing sticks down).
forwardedSub = manager.$forwarded.sink { [weak self] list in
MainActor.assumeIsolated { self?.reconcile(list) }
}
#if os(macOS)
let resign = NSApplication.willResignActiveNotification
@@ -80,53 +128,56 @@ public final class GamepadCapture {
MainActor.assumeIsolated {
guard let self else { return }
self.suspended = false
if let ext = self.bound?.extendedGamepad { self.sync(ext) }
// Re-send every open pad's current state (GC delivered nothing while inactive).
for slot in self.slots {
if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) }
}
}
})
}
public func stop() {
releaseAll()
rebind(to: nil)
activeSub = nil
closeAllSlots()
forwardedSub = nil
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
}
private func rebind(to controller: GCController?) {
guard controller !== bound else { return }
releaseAll()
if let ext = bound?.extendedGamepad {
ext.valueChangedHandler = nil
let tp = Self.touchpad(ext)
tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil
/// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its
/// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into
/// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a
/// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`.
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) })
for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) {
closeSlot(slot)
}
// Hand the system gestures back to the OS before letting the old pad go outside a
// stream the share button's screenshot and the Home overlay are the user's, not ours.
if let old = bound {
for element in old.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .enabled
for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) {
openSlot(dc)
}
// A chord-holding pad may have just unplugged re-evaluate so a stale hold disarms.
updateEscapeChord()
}
if let motion = bound?.motion {
motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming
// gyro/accel over Bluetooth (battery drain) long after the session.
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
}
bound = controller
guard let c = controller, let ext = c.extendedGamepad else { return }
ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) }
/// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its
/// system gestures, declare its kind (GamepadArrival before any input), then wake the host
/// pad and send its initial state. Skipped when the pad has no wire index (every slot taken)
/// or exposes no extended profile.
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
let c = dc.controller
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
slots.append(slot)
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } }
}
// Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
// gestures to several controller buttons share/create local screenshot/recording,
// Home Game Center overlay (iOS) / Launchpad's Games folder (macOS) and with a
// gesture attached the press is the system's, not the game's. During capture the remote
// session IS the game: the share button must reach the host (e.g. Steam screenshots),
// the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
for element in c.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .disabled
}
@@ -136,41 +187,83 @@ public final class GamepadCapture {
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
home.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in
MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } }
}
}
// Wake the host pad immediately (pads are created lazily from the first event;
// a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
sync(ext)
// Declare this pad's controller KIND before any of its input, so the host builds a
// matching virtual device (mixed types pad 0 a DualSense, pad 1 an Xbox pad). The core
// re-sends it a few times against datagram loss; an older host ignores it and uses the
// session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
sync(slot, ext)
if let tp = Self.touchpad(ext) {
tp.primary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } }
}
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in
MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } }
}
}
if let motion = c.motion {
if motion.sensorsRequireManualActivation { motion.sensorsActive = true }
motion.valueChangedHandler = { [weak self] m in
MainActor.assumeIsolated { self?.forwardMotion(m) }
motion.valueChangedHandler = { [weak self, weak slot] m in
MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } }
}
}
}
/// Snapshot the profile into wire state and send every transition since the last one.
private func sync(_ g: GCExtendedGamepad) {
/// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear
/// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures
/// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the
/// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`.
private func closeSlot(_ slot: Slot) {
flush(slot)
// Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host
// seq-gates it, so a reordered snapshot can't resurrect the removed pad.
connection.send(.gamepadRemove(pad: slot.pad))
let c = slot.controller
if let ext = c.extendedGamepad {
ext.valueChangedHandler = nil
let tp = Self.touchpad(ext)
tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil
}
c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil
// Hand the system gestures back to the OS before letting the pad go outside a stream the
// share button's screenshot and the Home overlay are the user's, not ours.
for element in c.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .enabled
}
if let motion = c.motion {
motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming gyro/accel
// over Bluetooth (battery drain) long after the session.
if motion.sensorsRequireManualActivation { motion.sensorsActive = false }
}
slots.removeAll { $0 === slot }
}
private func closeAllSlots() {
while let slot = slots.first { closeSlot(slot) }
chordTimer?.invalidate()
chordTimer = nil
}
/// Snapshot the profile into a slot's wire state and send every transition since the last one,
/// tagged with the slot's wire pad index.
private func sync(_ slot: Slot, _ g: GCExtendedGamepad) {
guard !suspended else { return }
let newButtons = Self.buttonMask(g)
let changed = newButtons ^ buttons
let changed = newButtons ^ slot.buttons
if changed != 0 {
for bit in GamepadWire.allButtons where changed & bit != 0 {
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0))
connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
}
buttons = newButtons
slot.buttons = newButtons
}
let newAxes: [Int32] = [
Int32((g.leftThumbstick.xAxis.value * 32767).rounded()),
@@ -180,22 +273,23 @@ public final class GamepadCapture {
Int32((g.leftTrigger.value * 255).rounded()),
Int32((g.rightTrigger.value * 255).rounded()),
]
for (i, v) in newAxes.enumerated() where v != axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0))
axes[i] = v
for (i, v) in newAxes.enumerated() where v != slot.axes[i] {
connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad))
slot.axes[i] = v
}
updateEscapeChord()
}
/// Forward the guide (Home/PS) transition directly it's kept out of `buttonMask` (the legacy
/// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by
/// `releaseAll` on focus loss just like the others.
private func sendGuide(down: Bool) {
/// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is
/// released by `flush` on focus loss / close just like the others.
private func sendGuide(_ slot: Slot, down: Bool) {
guard !suspended else { return }
let bit = GamepadWire.guide
let now = down ? (buttons | bit) : (buttons & ~bit)
guard now != buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: 0))
buttons = now
let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit)
guard now != slot.buttons else { return }
connection.send(.gamepadButton(bit, down: down, pad: slot.pad))
slot.buttons = now
}
private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 {
@@ -216,7 +310,7 @@ public final class GamepadCapture {
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder }
// guide (Home/PS) is NOT read here it's forwarded directly by the Home button's
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`.
// pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`.
if g.buttonA.isPressed { b |= GamepadWire.a }
if g.buttonB.isPressed { b |= GamepadWire.b }
if g.buttonX.isPressed { b |= GamepadWire.x }
@@ -244,29 +338,29 @@ public final class GamepadCapture {
return nil
}
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
/// lift treated as the lift signal (a real finger landing on the precise center
/// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly
/// (0, 0) on lift treated as the lift signal (a real finger landing on the precise center
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
private func touch(finger: Int, x: Float, y: Float) {
private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) {
guard !suspended else { return }
let lifted = x == 0 && y == 0
if lifted {
if fingerActive[finger] {
fingerActive[finger] = false
connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0)
if slot.fingerActive[finger] {
slot.fingerActive[finger] = false
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0)
}
return
}
fingerActive[finger] = true
slot.fingerActive[finger] = true
let w = GamepadWire.touchpad(x: x, y: y)
connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y)
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y)
}
private func forwardMotion(_ m: GCMotion) {
private func forwardMotion(_ slot: Slot, _ m: GCMotion) {
guard !suspended else { return }
let now = DispatchTime.now().uptimeNanoseconds
guard now &- lastMotionNs >= Self.motionIntervalNs else { return }
lastMotionNs = now
guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return }
slot.lastMotionNs = now
// Total acceleration in g: gravity + user when split, else the raw vector.
let ax: Float
let ay: Float
@@ -283,6 +377,7 @@ public final class GamepadCapture {
let gs = GamepadWire.gyroLSBPerRadS
let as_ = GamepadWire.accelLSBPerG
connection.sendMotion(
pad: UInt8(slot.pad),
gyro: (
GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs),
GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs),
@@ -295,20 +390,49 @@ public final class GamepadCapture {
))
}
/// 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.
/// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the
/// moment none do a release, or the holding pad unplugged (pf-client-core's `chord_held` is
/// likewise any-slot). GC 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() {
let held = slots.contains { $0.buttons & 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
}
}
/// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The
/// host's virtual pad returns to rest instead of running with the last state. Wire events only
/// (no GC calls) safe against an already-removed device. Does NOT close the slot or send
/// GamepadRemove (that's `closeSlot`).
private func flush(_ slot: Slot) {
for bit in GamepadWire.allButtons where slot.buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: slot.pad))
}
slot.buttons = 0
for (i, v) in slot.axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad))
slot.axes[i] = 0
}
for (f, active) in slot.fingerActive.enumerated() where active {
connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0)
slot.fingerActive[f] = false
}
}
/// Flush every open slot's held state (app deactivation) keeps the slots open (GC just stops
/// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which
/// also sends GamepadRemove and detaches handlers.
private func releaseAll() {
for bit in GamepadWire.allButtons where buttons & bit != 0 {
connection.send(.gamepadButton(bit, down: false, pad: 0))
}
buttons = 0
for (i, v) in axes.enumerated() where v != 0 {
connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0))
axes[i] = 0
}
for (f, active) in fingerActive.enumerated() where active {
connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0)
fingerActive[f] = false
}
chordTimer?.invalidate()
chordTimer = nil
for slot in slots { flush(slot) }
}
}
@@ -1,20 +1,23 @@
// Hostclient gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
// HID-output (0xCD) planes and replays them on the active physical controller
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
// ADDRESSED TO by wire pad index
//
// rumble CHHapticEngine players (per-handle localities when the pad has them,
// one combined engine otherwise),
// one combined engine otherwise), a RumbleRenderer per pad,
// lightbar GCDeviceLight,
// player LEDs GCController.playerIndex (the DS bit patterns map to player 14),
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) the
// drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state
// is replayed onto the new one.
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
// (triggers off, player index unset) and its renderer silenced.
import Combine
import Foundation
@@ -22,26 +25,40 @@ import GameController
public final class GamepadFeedback {
private let connection: PunktfunkConnection
private let manager: GamepadManager
private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false
private let rumble = RumbleRenderer(policy: .session)
private var activeSub: AnyCancellable?
private var forwardedSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes.
@MainActor private var target: GCController?
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
@MainActor private var lastPlayerBits: UInt8?
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
/// One forwarded controller's non-rumble feedback state (main-actor) the GC target plus the
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
@MainActor private final class Slot {
var controller: GCController?
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
var lastPlayerBits: UInt8?
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
init(controller: GCController?) { self.controller = controller }
}
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
@MainActor private var slots: [UInt8: Slot] = [:]
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
/// thread can route an incoming envelope to the right pad's renderer while the main actor
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
/// drain thread is safe only the map lookup needs the lock.
private let routingLock = NSLock()
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
self.manager = manager
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
// an implicit strong one and the subscription (stored on self) never retain-cycles.
Task { @MainActor [weak self] in
guard let self else { return }
self.activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
MainActor.assumeIsolated { self?.reconcile(list) }
}
}
}
@@ -67,6 +84,38 @@ public final class GamepadFeedback {
}
}
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
/// forwarded (silence + release their renderer, reset their controller), add a slot +
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
/// same freed index) replaying its cached feedback onto the new device.
@MainActor
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
var want: [UInt8: GCController] = [:]
for dc in forwarded {
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
}
for (pad, slot) in slots where want[pad] == nil {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
}
for (pad, controller) in want {
if let slot = slots[pad] {
guard slot.controller !== controller else { continue }
reset(slot.controller)
slot.controller = controller
withRouting { rumbleByPad[pad]?.retarget(controller) }
replay(slot)
} else {
slots[pad] = Slot(controller: controller)
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(controller)
withRouting { rumbleByPad[pad] = renderer }
}
}
}
public func start() {
guard !drainStarted else { return }
drainStarted = true
@@ -74,7 +123,11 @@ public final class GamepadFeedback {
// 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 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 {
// 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
@@ -84,19 +137,19 @@ public final class GamepadFeedback {
// rumble/HID latency low while leaving the lock free between polls.
//
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)?
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
// queue, and its drop-newest overflow could shed a stop while stale nonzero
// states queued ahead of it buzzing until the host's next 500 ms refresh.
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) }
let r = try connection.nextRumble2(timeoutMs: 0) {
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high)
for (pad, n) in newestByPad {
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -106,12 +159,14 @@ public final class GamepadFeedback {
self?.render(ev)
burst += 1
}
return true
} 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
// 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()
}
@@ -120,7 +175,7 @@ public final class GamepadFeedback {
thread.start()
}
/// Stop the drain and silence the motors. Blocks until the drain thread exits ( one
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits ( one
/// poll cycle) call off the main actor, before `connection.close()`.
public func stop() {
flag.stop()
@@ -128,17 +183,32 @@ public final class GamepadFeedback {
drainDone.wait()
drainStarted = false
}
rumble.stop()
// Drop the retarget subscription and the dead session's cached feedback a
// controller change after teardown must not replay this session's triggers/LEDs.
Task { @MainActor in
self.activeSub = nil
self.lastLight = nil
self.lastPlayerBits = nil
self.lastTrigger = [nil, nil]
self.reset(self.target)
self.target = nil
let renderers = withRouting { () -> [RumbleRenderer] in
let r = Array(rumbleByPad.values)
rumbleByPad.removeAll()
return r
}
for r in renderers { r.stop() }
// Drop the subscription and every dead pad's cached feedback a controller change after
// teardown must not replay this session's triggers/LEDs.
Task { @MainActor in
self.forwardedSub = nil
for slot in self.slots.values { self.reset(slot.controller) }
self.slots.removeAll()
}
}
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
}
private func withRouting<R>(_ body: () -> R) -> R {
routingLock.lock()
defer { routingLock.unlock() }
return body()
}
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
@@ -151,40 +221,37 @@ public final class GamepadFeedback {
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
switch ev {
case let .led(pad, r, g, b):
guard pad == 0 else { return }
lastLight = (r, g, b)
target?.light?.color = GCColor(
guard let slot = slots[pad] else { return }
slot.lastLight = (r, g, b)
slot.controller?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
case let .playerLEDs(pad, bits):
guard pad == 0 else { return }
lastPlayerBits = bits
target?.playerIndex = Self.playerIndex(forBits: bits)
guard let slot = slots[pad] else { return }
slot.lastPlayerBits = bits
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
case let .triggerEffect(pad, which, effect):
guard pad == 0, which < 2 else { return }
guard which < 2, let slot = slots[pad] else { return }
let parsed = DualSenseTriggerEffect.parse(effect)
lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(which) {
slot.lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(slot.controller, which) {
parsed.apply(to: trigger)
}
}
}
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
@MainActor
private func retarget(_ controller: GCController?) {
guard controller !== target else { return }
reset(target)
target = controller
rumble.retarget(controller)
// Replay the session's feedback state so a swapped-in controller looks the same.
if let (r, g, b) = lastLight {
controller?.light?.color = GCColor(
private func replay(_ slot: Slot) {
if let (r, g, b) = slot.lastLight {
slot.controller?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
}
if let bits = lastPlayerBits {
controller?.playerIndex = Self.playerIndex(forBits: bits)
if let bits = slot.lastPlayerBits {
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
}
for which in 0..<2 {
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
if let effect = slot.lastTrigger[which],
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
effect.apply(to: trigger)
}
}
@@ -201,8 +268,8 @@ public final class GamepadFeedback {
}
@MainActor
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
return which == 0 ? ds.leftTrigger : ds.rightTrigger
}
}
@@ -1,14 +1,18 @@
// Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches
// GCController connect/disconnect from launch, so the Settings page shows live controller
// state without a session, and the session components (GamepadCapture / GamepadFeedback)
// follow `active` exactly ONE physical controller is forwarded to the host, as pad 0.
// follow `forwarded` every forwarded controller is streamed to the host, each on its own
// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`).
//
// Selection: the user can pin a controller in Settings (persisted under
// DefaultsKey.gamepadID); with no pin or the pinned one absent the most recently
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
// identical twin controllers may swap a pin across reconnects, which the Settings footer
// documents.
// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY
// extended controller is forwarded each assigned a stable lowest-free pad index held for
// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the
// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad
// an explicit single-player choice. `active` stays the single "primary" pad (the pinned
// one, else the most recently connected extended gamepad) that the Settings / launcher / menu
// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of
// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers
// may swap a pin across reconnects, which the Settings footer documents.
//
// A singleton (not a SwiftUI environment object) because macOS shows Settings in its own
// `Settings{}` scene there is no common ancestor view to inject from.
@@ -60,9 +64,23 @@ public final class GamepadManager: ObservableObject {
/// Every detected controller, in connect order (Settings lists these).
@Published public private(set) var controllers: [DiscoveredController] = []
/// The one controller forwarded to the host (pad 0); nil when none qualifies.
/// The single "primary" controller the pinned one, else the most recently connected
/// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the
/// connect-time `resolveType` read this; the streaming input path uses `forwarded`.
@Published public private(set) var active: DiscoveredController?
/// The controllers forwarded to the host this session, in wire-pad-index preference order
/// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards
/// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes
/// feedback back to it, each on the index from `padIndex(for:)`.
@Published public private(set) var forwarded: [DiscoveredController] = []
/// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by
/// GCController identity. Lowest-free, held while the controller stays forwarded a
/// disconnect frees only its own index so the others never renumber (pf-client-core's
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
/// reselects immediately, so a Settings Picker can bind straight to this.
@Published public var preferredID: String {
@@ -159,7 +177,52 @@ public final class GamepadManager: ObservableObject {
let candidates = controllers.filter(\.isExtended)
// The pin wins when present; otherwise the most recently connected extended pad
// (list is in connect order). A stale pin falls back to automatic.
active = candidates.last { $0.id == preferredID } ?? candidates.last
let pinned = candidates.last { $0.id == preferredID }
active = pinned ?? candidates.last
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
// (explicit single-player); Automatic forwards every extended controller in connect order
// (oldestnewest), so a game's player numbers are stable across hot-plug churn.
let next = pinned.map { [$0] } ?? candidates
// Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in
// `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the
// instant this assignment lands a stale map here would skip a newly-forwarded pad.
assignPadIndices(for: next)
forwarded = next
}
/// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays
/// forwarded) mirrors pf-client-core's slot model, where a disconnect frees only its own
/// index and the others keep theirs. A controller already holding an index keeps it across the
/// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded).
private func assignPadIndices(for next: [DiscoveredController]) {
let live = Set(next.map { ObjectIdentifier($0.controller) })
padIndexByController = padIndexByController.filter { live.contains($0.key) }
for dc in next {
let key = ObjectIdentifier(dc.controller)
guard padIndexByController[key] == nil,
let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue }
padIndexByController[key] = free
}
}
/// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in
/// use (pf-client-core's `lowest_free_index`).
private static func lowestFreeIndex(_ taken: Set<UInt8>) -> UInt8? {
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
}
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
public func padIndex(for controller: DiscoveredController) -> UInt8? {
padIndexByController[ObjectIdentifier(controller.controller)]
}
/// Drop every pad-index assignment and recompute from the current forwarded set called when
/// a streaming session begins so the assignment starts fresh (a controller pinned before the
/// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core
/// assigns indices at slot-open time; this reproduces that session-scoped start.
public func resetForwardingAssignment() {
padIndexByController.removeAll()
reselect()
}
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
@@ -1,10 +1,14 @@
// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback),
// and the tests button bits, axis ids, and the touchpad/motion unit conversions.
// and the tests the pad count, button bits, axis ids, and the touchpad/motion unit conversions.
import Foundation
/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`).
public enum GamepadWire {
/// Gamepads addressable on the wire the pad index rides the low byte of `flags` on every
/// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`).
public static let maxPads: Int = 16
public static let dpadUp: UInt32 = 0x0001
public static let dpadDown: UInt32 = 0x0002
public static let dpadLeft: UInt32 = 0x0004
@@ -23,10 +23,23 @@ enum RumbleTuning {
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
/// the **legacy-host fallback only** an old host sends no self-termination lease, so its
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
static let sessionStaleSeconds: TimeInterval = 1.6
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
static let noTTL: UInt32 = .max
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
/// ([`noTTL`]) the renderer falls back to [`sessionStaleSeconds`] else the self-termination
/// lease in seconds (render the level for at most this long unless the host renews it).
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
}
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
@@ -139,6 +152,10 @@ final class RumbleRenderer: @unchecked Sendable {
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease the
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
private var envelopeDeadline: DispatchTime?
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -212,13 +229,23 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
self.envelopeDeadline = .now() + lease
} else {
self.envelopeDeadline = nil
}
if active != self.wasActive {
self.wasActive = active
log.debug(
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.envelopeDeadline = nil
self.wasActive = false
self.teardown()
self.closeHID()
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
if let deadline = envelopeDeadline {
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
// the whole point of the envelope model: the motor can't outlive the host's intent.
if target != (0, 0), DispatchTime.now() >= deadline {
log.warning("rumble: envelope expired unrenewed — silencing")
target = (0, 0)
envelopeDeadline = nil
}
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
// channel (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
@@ -85,6 +85,12 @@ public final class InputCapture {
/// its Esc suppression need it in both states).
private var cmdKeysDown: Set<UInt32> = []
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
/// the Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
/// like the toggle), so it needs the live modifier state tracked in both forwarding states,
/// exactly like `cmdKeysDown`, and flushed by `releaseAll` when GC delivery stops.
private var chordModifiersDown: Set<UInt32> = []
/// While true, mouse/keyboard flow to the host and key NSEvents are swallowed
/// locally; while false the user is interacting with the local UI (dragging the
/// window, clicking the HUD) and nothing is forwarded. Main-queue only.
@@ -119,6 +125,21 @@ public final class InputCapture {
public var onDisconnect: (() -> Void)?
public var onCycleStats: (() -> Void)?
#if os(iOS)
/// Windows VKs of the three modifier classes in the Q release chord, both L/R sides:
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
/// Whether Control AND Option AND Shift are all currently held (either side of each counts)
/// the modifier precondition for the iPad Q release chord.
private var hasReleaseChordModifiers: Bool {
let m = chordModifiersDown
return (m.contains(0xA2) || m.contains(0xA3)) // control
&& (m.contains(0xA4) || m.contains(0xA5)) // option
&& (m.contains(0xA0) || m.contains(0xA1)) // shift
}
#endif
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
/// singletons hold ONE handler each): the preempted owner must drop its capture
/// state its handlers are gone, so it would otherwise sit "captured" with dead
@@ -294,6 +315,7 @@ public final class InputCapture {
/// in another app would otherwise stay "held" here forever hijacking Esc).
private func releaseAll() {
cmdKeysDown.removeAll()
chordModifiersDown.removeAll()
suppressedVK = nil
for vk in pressedVKs {
connection.send(.key(vk, down: false))
@@ -576,6 +598,13 @@ public final class InputCapture {
self.cmdKeysDown.remove(vk)
}
}
#if os(iOS)
// Track Control/Option/Shift for the Q release chord below in both forwarding
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
if Self.chordModifierVKs.contains(vk) {
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
}
#endif
// The toggle's Esc checked before the forwarding gate, because in the
// engage direction forwarding is already true when this fires.
if vk == self.suppressedVK {
@@ -592,6 +621,18 @@ public final class InputCapture {
}
#endif
guard self.forwarding else { return }
#if os(iOS)
// Q releases the captured mouse/keyboard (cross-client parity the same combo the
// macOS keyDown monitor handles). Recognized only while forwarding (nothing to release
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
// the modifiers were forwarded as they went down and are flushed by the release
// path (setCaptured(false) releaseAll). VK 0x51 is layout-independent (physical Q).
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
self.suppressedVK = 0x51
self.onReleaseCapture?()
return
}
#endif
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
// monitor never type Esc into the host while is held ( is reserved).
if vk == 0x1B, !self.cmdKeysDown.isEmpty {
@@ -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
@@ -11,6 +11,15 @@ public enum DefaultsKey {
public static let streamWidth = "punktfunk.width"
public static let streamHeight = "punktfunk.height"
public static let streamHz = "punktfunk.hz"
/// Match-window resolution policy (design/midstream-resolution-resize.md D1/D2): when on, the
/// stream mode FOLLOWS the session view the connect asks for the view's pixel size and a
/// mid-session resize (a windowed macOS window, an iPad Stage Manager / Split View scene)
/// renegotiates the host's virtual display + encoder (`PunktfunkConnection.requestMode`), so a
/// windowed session streams native-resolution pixels instead of scaling. Off (default): the
/// explicit `streamWidth`/`streamHeight` are used and never auto-resized (a fullscreen session
/// is native either way, so this degenerates to Auto-native there). Read per session by the
/// stream views' `MatchWindowFollower`.
public static let matchWindow = "punktfunk.matchWindow"
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
@@ -88,6 +97,12 @@ public enum DefaultsKey {
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
/// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire
/// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking"
/// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a
/// routed/VPN host), so connects go straight through instead of waiting out the wake timeout.
/// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession.
public static let autoWake = "punktfunk.autoWake"
}
extension Notification.Name {
@@ -13,14 +13,14 @@ public enum Licenses {
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 {
let mit = resource("LICENSE-MIT")
let apache = resource("LICENSE-APACHE")
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============================== 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
/// 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.
public static let thirdPartyNoticesChunks: [String] = {
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
let chunkSize = 200
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
/// Lines per chunk: tvOS reads much smaller chunks focus is how tvOS scrolls, and each
/// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
/// 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))
}
}
@@ -0,0 +1,153 @@
// Match-window resize follower (design/midstream-resolution-resize.md D1/D2, client C3).
//
// The presenting view feeds this its PHYSICAL-PIXEL size on every layout; it debounces to
// resize-end, spaces requests 1 s apart, and asks the connection to switch the host's virtual
// display + encoder to match (`PunktfunkConnection.requestMode`) so a windowed macOS session or
// an iPad Stage Manager / Split View scene streams native-resolution pixels instead of scaling.
// The decode/present side needs nothing: VideoToolbox recreates its session on the keyframe-derived
// format-description change (the first new-mode AU is an IDR with fresh parameter sets).
//
// The trigger discipline is the shared cross-client one (mirrors the session binary's
// `resize_decision`): physical pixels rounded DOWN to even (the host rejects odd dimensions) and
// clamped 320×200; debounce to resize-end; 1 s between requests; skip a size equal to the live
// mode; and request each distinct size at most once which both stops re-asking a rejected size
// and keeps a host-side rollback (accepted, rebuild failed, corrective ack restored the old mode)
// from looping request rollback request.
import Foundation
/// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
public enum MatchWindow {
/// Even-floor + clamp a physical-pixel size to a host-valid mode dimension: the host's
/// `validate_dimensions` rejects odd sizes, and we never ask below 320×200.
public static func normalize(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
let evenClamp: (Int, UInt32) -> UInt32 = { px, minimum in
let even = UInt32(max(px, 0)) / 2 * 2
return max(even, minimum)
}
return (evenClamp(widthPx, 320), evenClamp(heightPx, 200))
}
/// Whether to request `target` now (the debounce has already settled; spacing is the caller's
/// timer): `nil` to skip equal to the live mode, or already requested once (a rejected size /
/// a host rollback must not loop). `target` is expected already-[normalize]d.
public static func request(
target: (width: UInt32, height: UInt32),
current: (width: UInt32, height: UInt32),
lastRequested: (width: UInt32, height: UInt32)?
) -> (width: UInt32, height: UInt32)? {
if target.width == current.width, target.height == current.height { return nil }
if let lr = lastRequested, lr.width == target.width, lr.height == target.height { return nil }
return target
}
}
/// Owns the debounce timer + serialization state and drives `PunktfunkConnection.requestMode` from
/// the stream view's layout callbacks. Main-actor: the views feed it on the main thread and it reads
/// the connection's live mode there. Enabled per session from the `matchWindow` setting.
@MainActor
public final class MatchWindowFollower {
private weak var connection: PunktfunkConnection?
private let debounce: TimeInterval
private let minSpacing: TimeInterval
private var enabled: Bool
private var work: DispatchWorkItem?
private var pendingSize: (width: Int, height: Int)?
private var lastRequested: (width: UInt32, height: UInt32)?
private var lastRequestAt: Date?
/// The last size we reported via [`onResizeTarget`] dedups the per-layout stream of a drag so
/// the UI is notified once per distinct target, and reset to `nil` when the window is back in
/// sync with the live mode (so a later resize re-reports).
private var lastSteered: (width: UInt32, height: UInt32)?
/// Fired (on the main actor) the instant the window starts differing from the live mode i.e.
/// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the
/// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the
/// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target.
public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)?
/// `debounce` = quiet time after the last size event before requesting (Win32 gets
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
/// (a full host pipeline rebuild each). Defaults match the other clients.
public init(
connection: PunktfunkConnection,
enabled: Bool,
debounce: TimeInterval = 0.4,
minSpacing: TimeInterval = 1.0
) {
self.connection = connection
self.enabled = enabled
self.debounce = debounce
self.minSpacing = minSpacing
}
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
public func setEnabled(_ on: Bool) {
enabled = on
if !on {
work?.cancel()
work = nil
pendingSize = nil
lastSteered = nil
}
}
/// Feed the presenting view's current PHYSICAL-PIXEL size (its `bounds` × the backing/display
/// scale). Called from every layout pass; coalesced by the debounce so a drag-resize sends one
/// request at its end, never one per frame.
public func noteSize(widthPx: Int, heightPx: Int) {
guard enabled else { return }
pendingSize = (widthPx, heightPx)
schedule()
reportSteering(widthPx: widthPx, heightPx: heightPx)
}
/// Report the resize overlay's START signal (deduped): the moment the normalized window size
/// differs from the live mode we're steering toward a new size. No connection / no negotiated
/// mode yet nothing to compare against, skip.
private func reportSteering(widthPx: Int, heightPx: Int) {
guard let connection else { return }
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return }
if target.width == mode.width, target.height == mode.height {
lastSteered = nil // back in sync a later change re-reports
return
}
if lastSteered?.width == target.width, lastSteered?.height == target.height { return }
lastSteered = target
onResizeTarget?(target.width, target.height)
}
private func schedule() {
work?.cancel()
let item = DispatchWorkItem { [weak self] in self?.fire() }
work = item
DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item)
}
private func fire() {
guard enabled, let connection, let size = pendingSize else { return }
// 1 s spacing: a request went out recently re-arm the debounce and retry later rather
// than fire early (keeps at most ~one request outstanding the accept ack round-trips in
// milliseconds, ahead of the host's rebuild).
if let last = lastRequestAt, Date().timeIntervalSince(last) < minSpacing {
schedule()
return
}
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
let mode = connection.currentMode()
pendingSize = nil
guard let req = MatchWindow.request(
target: target,
current: (mode.width, mode.height),
lastRequested: lastRequested
) else { return }
// Keep the current refresh Match-window follows SIZE, not rate.
connection.requestMode(width: req.width, height: req.height, refreshHz: mode.refreshHz)
lastRequested = req
lastRequestAt = Date()
}
}
@@ -28,17 +28,39 @@ private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "pre
/// dimmer. Matches the host's standard PQ reference white.
private let hdrReferenceWhiteNits: Float = 203.0
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
/// and BT.2020-PQ HDR YCbCrRGB fragment shaders. 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).
/// PUNKTFUNK_SDR_COLORSPACE=srgb A/B hatch for the SDR layer's colour tag. Today the SDR layer
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
/// stream is displayed with the panel's native primaries mild oversaturation on every P3 Mac.
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
/// 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 = """
#include <metal_stdlib>
using namespace metal;
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]]) {
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
VOut o;
@@ -94,43 +116,89 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
return uv;
}
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) full-range RGB. Chroma is sampled at the
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
fragment float4 pf_frag(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) {
// The shared sample + row-multiply: YCbCr (bicubic luma, siting-corrected bilinear chroma)
// RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
// offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
// gamma-encoded RGB, an HDR frame's rows yield PQ-encoded RGB the fragment variants below
// 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);
float y = catmullRomLuma(lumaTex, s, in.uv);
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
// BT.709, 8-bit limited (video) range full-range RGB.
y = (y - 16.0/255.0) * (255.0/219.0);
float u = (c.x - 128.0/255.0) * (255.0/224.0);
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);
#ifdef PF_BILINEAR_LUMA
// DEBUG (PUNKTFUNK_BILINEAR_LUMA=1): plain bilinear luma Catmull-Rom OFF. A/B lever to see if
// the bicubic overshoot contributes to edge fringing. NOTE: at a true 1:1 present both paths
// reduce to the identity texel, so if this toggle VISIBLY changes the picture, the present is
// NOT 1:1 (there's a resample); if it looks identical, the fringing is upstream (codec/source/OS).
float lumaY = lumaTex.sample(s, uv).r;
#else
float lumaY = catmullRomLuma(lumaTex, s, uv);
#endif
float3 yuv = float3(lumaY,
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
dot(csc.r1.xyz, yuv) + csc.r1.w,
dot(csc.r2.xyz, yuv) + csc.r2.w));
}
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), YCbCr that is PQ-encoded. We apply the BT.2020
// matrix to get PQ-encoded RGB and output it 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. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
// SDR: 8-bit NV12 / 4:4:4 full-range RGB, transfer left baked (shown as-is, the proven SDR
// layer config).
fragment float4 pf_frag(VOut in [[stage_in]],
texture2d<float> lumaTex [[texture(0)]],
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]],
texture2d<float> lumaTex [[texture(0)]],
texture2d<float> chromaTex [[texture(1)]]) {
constexpr sampler s(filter::linear, address::clamp_to_edge);
float y = catmullRomLuma(lumaTex, s, in.uv);
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);
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
float r = y + 1.4746 * v;
float g = y - 0.16455 * u - 0.57135 * v;
float b = y + 1.8814 * u;
return float4(saturate(float3(r, g, b)), 1.0);
texture2d<float> chromaTex [[texture(1)]],
constant CscUniform& csc [[buffer(0)]]) {
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
}
// HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
// disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
// wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
// composites UNtone-mapped the CAMetalLayer header says so outright which showed as a badly
// overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF linear
// light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
// 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 +212,19 @@ public final class MetalVideoPresenter {
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
private let pipelineSDR: 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?
/// 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
/// `configure` call is ordered before the thread starts, so it doesn't race).
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
/// 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
@@ -163,6 +238,11 @@ public final class MetalVideoPresenter {
private let stagingLock = NSLock()
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
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
/// Last logged "decodeddrawable" signature, so the diagnostic logs only on a size/HDR change.
@@ -177,8 +257,18 @@ public final class MetalVideoPresenter {
else { return nil }
let pipelineSDR: MTLRenderPipelineState
let pipelineHDR: MTLRenderPipelineState
let pipelineHDRToneMap: MTLRenderPipelineState?
do {
let library = try device.makeLibrary(source: shaderSource, options: nil)
// DEBUG A/B lever: PUNKTFUNK_BILINEAR_LUMA=1 compiles the shader with Catmull-Rom OFF
// (plain bilinear luma) by prepending a #define ahead of the source. Default (unset) is
// the normal bicubic path. Read at presenter creation set it in the environment and
// relaunch to flip; the log line confirms which path built.
let bilinearLuma = ProcessInfo.processInfo.environment["PUNKTFUNK_BILINEAR_LUMA"] == "1"
let source = (bilinearLuma ? "#define PF_BILINEAR_LUMA 1\n" : "") + shaderSource
if bilinearLuma {
presenterLog.info("stage2: PUNKTFUNK_BILINEAR_LUMA=1 — Catmull-Rom luma DISABLED (bilinear)")
}
let library = try device.makeLibrary(source: source, options: nil)
let vtx = library.makeFunction(name: "pf_vtx")
let sdr = MTLRenderPipelineDescriptor()
sdr.vertexFunction = vtx
@@ -188,8 +278,20 @@ public final class MetalVideoPresenter {
let hdr = MTLRenderPipelineDescriptor()
hdr.vertexFunction = vtx
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)
#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 {
return nil
}
@@ -229,17 +331,19 @@ public final class MetalVideoPresenter {
return MetalVideoPresenter(
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
textureCache: textureCache, layer: layer)
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
}
private init(
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
textureCache: CVMetalTextureCache, layer: CAMetalLayer
) {
self.device = device
self.queue = queue
self.pipelineSDR = pipelineSDR
self.pipelineHDR = pipelineHDR
self.pipelineHDRToneMap = pipelineHDRToneMap
self.textureCache = textureCache
self.layer = layer
}
@@ -251,30 +355,68 @@ public final class MetalVideoPresenter {
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
/// SDR uses the plain 8-bit sRGB path.
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 }
hdrActive = hdr
#endif
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
/// 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
/// it gets the PQ pixel format + colour space only the tvOS compositor tone-maps from those.
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there)
/// 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) {
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.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = true
// 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.
layer.edrMetadata = makeEDR(lastHdrMeta)
#endif
} else {
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
// (the proven SDR path never showed the "too bright" issue, which was HDR-only).
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
// 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.colorspace = nil
layer.colorspace = sdrColorspaceOverride
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil
@@ -360,6 +502,11 @@ public final class MetalVideoPresenter {
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|| 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,
let luma = makeTexture(
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
@@ -395,9 +542,17 @@ public final class MetalVideoPresenter {
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
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)
#endif
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
if let onPresented {
@@ -453,8 +608,17 @@ public final class MetalVideoPresenter {
let sig = "\(Int(decoded.width))x\(Int(decoded.height))\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)"
if sig != lastSizeSig {
lastSizeSig = sig
// Explicit verdict: is the shader presenting 1:1 (decoded == drawable) or resampling? The
// scale ratio makes a residual match-window mismatch obvious. If this says 1:1 but the
// picture is still soft, the resample is downstream of us (macOS compositor a scaled
// display mode, or a fractional-pixel window position), not the shader.
let sx = decoded.width > 0 ? drawable.width / decoded.width : 0
let sy = decoded.height > 0 ? drawable.height / decoded.height : 0
let verdict = decoded == drawable
? "1:1 (no resample)"
: String(format: "RESAMPLE scale=%.4fx%.4f", sx, sy)
let msg =
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)"
"stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) [\(verdict)] hdr=\(hdrActive)"
presenterLog.info("\(msg, privacy: .public)")
}
}
@@ -0,0 +1,99 @@
// Swift wrapper around the punktfunk-core C ABI's post-loss re-anchor gate
// (`punktfunk_reanchor_gate_*`, ABI v6). The shared Rust gate (crates/punktfunk-core/src/reanchor.rs)
// is what the Linux/Windows desktop pump and the Android client use directly; the Swift clients reach
// it across the C ABI so the freeze-until-reanchor policy is defined ONCE for every platform.
//
// Why a freeze at all: after unrecoverable loss the host keeps sending delta frames that reference a
// picture the client never got. Hardware decoders (VideoToolbox included) don't reliably error on
// that they CONCEAL, returning a gray/garbage frame with a success status. Presenting those is the
// visible "gray flash with motion" of the loss reports. The gate withholds concealed frames and holds
// the last good picture on glass until a PROVEN clean re-anchor lands an IDR (wire `FLAG_SOF`), an
// RFI recovery anchor (`USER_FLAG_RECOVERY_ANCHOR`), or the 2nd of two intra-refresh recovery marks
// (`USER_FLAG_RECOVERY_POINT`) with a bounded backstop so a lost re-anchor can never freeze forever.
// See punktfunk-planning design/client-reanchor-freeze-parity.md.
//
// Threading: one gate per session. Its calls arrive from two threads the pump thread (`arm` on a
// frame-index gap / a submit failure, `poll` per iteration) and a VideoToolbox decode thread
// (`onDecoded` per decoded frame, `onNoOutput` on a decode error). The raw Rust gate is a plain
// struct behind an opaque pointer with no internal synchronization, so every call is serialized under
// `lock` here the calls are cheap field updates, so contention is negligible. `@unchecked Sendable`:
// the lock enforces the contract.
import Foundation
import PunktfunkCore
final class ReanchorGate: @unchecked Sendable {
private let lock = NSLock()
/// The opaque `ReanchorGate *`. `var` so `reseed` can swap it at session start. Never NULL
/// (`punktfunk_reanchor_gate_new` never returns NULL).
private var ptr: OpaquePointer
/// Seed the baseline with the connection's current `framesDropped` so the first `poll` doesn't
/// read the session's starting drop count as a fresh loss.
init(framesDropped: UInt64) {
ptr = punktfunk_reanchor_gate_new(framesDropped)
}
deinit { punktfunk_reanchor_gate_free(ptr) }
/// Re-anchor the drop-count baseline to `framesDropped` for a (re)started session. The gate is
/// created in the pipeline's init (before a connection exists, seeded 0); `start` calls this once
/// the live connection's count is known so a mid-life connection's non-zero baseline isn't
/// mistaken for loss on the first poll.
func reseed(framesDropped: UInt64) {
lock.lock()
defer { lock.unlock() }
punktfunk_reanchor_gate_free(ptr)
ptr = punktfunk_reanchor_gate_new(framesDropped)
}
/// Arm the freeze: a loss was detected (a frame-index gap, or a decoder wedge). Zeroes the
/// recovery-mark count and (re)sets the backstop deadline.
func arm() {
lock.lock()
punktfunk_reanchor_gate_arm(ptr)
lock.unlock()
}
/// Fold one decoded frame. `flags` is the AU's wire `user_flags`. Returns true to PRESENT the
/// frame, false to WITHHOLD it as a post-loss concealment (hold the last good picture). Pass
/// `decoderKeyframe: false` VideoToolbox doesn't flag IDRs, so the wire `FLAG_SOF` covers it.
func onDecoded(flags: UInt32, decoderKeyframe: Bool = false) -> Bool {
lock.lock()
defer { lock.unlock() }
var present = false
_ = punktfunk_reanchor_gate_on_decoded(ptr, flags, decoderKeyframe, &present)
return present
}
/// A received AU produced no decoded frame (a VideoToolbox decode error). Returns true when the
/// no-output streak has tripped (the gate armed the freeze) and the caller should throttled
/// request a keyframe.
func onNoOutput() -> Bool {
lock.lock()
defer { lock.unlock() }
var requestKf = false
_ = punktfunk_reanchor_gate_on_no_output(ptr, &requestKf)
return requestKf
}
/// Periodic fold of the session's `framesDropped` plus the overdue backstop. Returns true when the
/// caller should throttled request a keyframe (a drop-count climb armed a fresh freeze, or the
/// freeze is overdue and re-asks while it keeps holding).
func poll(framesDropped: UInt64) -> Bool {
lock.lock()
defer { lock.unlock() }
var requestKf = false
_ = punktfunk_reanchor_gate_poll(ptr, framesDropped, &requestKf)
return requestKf
}
/// Whether the gate is currently withholding concealed frames (frozen on the last good picture).
var isHolding: Bool {
lock.lock()
defer { lock.unlock() }
var holding = false
_ = punktfunk_reanchor_gate_is_holding(ptr, &holding)
return holding
}
}
@@ -0,0 +1,63 @@
// Resize-in-progress indicator state (design/midstream-resolution-resize.md client UX).
//
// A mid-stream resize takes the host 0.32 s to rebuild its virtual display + encoder, and the
// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale
// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a
// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp
// new-resolution frame is on screen so the wait reads as intentional, not as lag.
//
// This is driven ENTIRELY by signals the client already has (no new protocol):
// * START the Match-window follower reports the size it is steering toward (instant, on the
// first resize layout, before the debounced request even leaves).
// * END the decode pipeline reports each new-mode IDR's dimensions; when they reach the target
// the new picture is here.
// * TIMEOUT the safety net for a switch that never delivers the exact target: the host rejected
// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size.
//
// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI
// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published.
import Foundation
/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes
/// `ProcessInfo.processInfo.systemUptime` or a test clock).
public struct ResizeIndicator {
/// Whether the blur + spinner should be shown.
public private(set) var active = false
/// The size the follower is steering toward cleared once a decoded frame reaches it.
private var target: (width: UInt32, height: UInt32)?
/// When the current `active` span began the timeout is measured from here.
private var since: TimeInterval?
/// How long to keep the overlay up if the target frame never arrives (rejected / capped switch).
public var timeout: TimeInterval
public init(timeout: TimeInterval = 2.5) { self.timeout = timeout }
/// The follower is steering toward `width`×`height` a resize is under way. Show the overlay now
/// (instant feedback). Called only for a genuine change (the follower skips a target equal to the
/// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the
/// target actually changes so a slow drag never trips it mid-gesture.
public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) {
if !active || target?.width != width || target?.height != height {
since = now
}
target = (width, height)
active = true
}
/// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it
/// matches the steered target the sharp new-resolution picture is on glass.
public mutating func decoded(width: UInt32, height: UInt32) {
guard active, let t = target, t.width == width, t.height == height else { return }
active = false
since = nil
}
/// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame
/// (a rejected or host-capped switch never delivers the exact target).
public mutating func tick(now: TimeInterval) {
guard active, let s = since, now - s >= timeout else { return }
active = false
since = nil
}
}
@@ -10,6 +10,9 @@
import AVFoundation
import Foundation
import QuartzCore
#if os(tvOS)
import UIKit
#endif
/// 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
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
/// falls back to the platform default. `allowStage1` is false in release builds, where a
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
/// freeze-prone fallback.
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : .stage2
case "stage1": return allowStage1 ? .stage1 : platformDefault
case "stage2": return .stage2
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 {
@@ -52,6 +70,15 @@ final class SessionPresenter {
private var stage2Link: CADisplayLink?
private var metalLayer: CAMetalLayer?
private var connection: PunktfunkConnection?
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
/// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to
/// `connection.currentMode()`, which (a) lags a mid-stream resize it only updates on the
/// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward
/// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back
/// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong
/// one here is the "black bars + stretched" resize artifact. nil until the first frame `layout`
/// falls back to `currentMode()`. Main-thread only.
private var contentSize: CGSize?
/// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer:
/// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer
@@ -67,7 +94,8 @@ final class SessionPresenter {
displayMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
stop()
self.connection = connection
@@ -110,12 +138,14 @@ final class SessionPresenter {
link.add(to: .main, forMode: .common)
stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
} else {
let pump = StreamPump()
pump.start(
connection: connection, layer: baseLayer,
onFrame: onFrame, onSessionEnd: onSessionEnd)
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump
}
}
@@ -163,28 +193,68 @@ final class SessionPresenter {
guard let metalLayer, let connection else { return }
let mode = connection.currentMode()
syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh
let fit: CGRect = (mode.width > 0 && mode.height > 0)
? AVMakeRect(
aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)),
insideRect: bounds)
: bounds
// Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a
// host that delivered a different size than requested), else the negotiated mode. The shader
// stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that
// keeps the picture undistorted a stale aspect here is the post-resize black-bars+stretch.
let aspect: CGSize? = {
if let c = contentSize, c.width > 0, c.height > 0 { return c }
if mode.width > 0, mode.height > 0 {
return CGSize(width: Int(mode.width), height: Int(mode.height))
}
return nil
}()
let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds
// Snap the sublayer frame to the BACKING PIXEL GRID. AVMakeRect centers the aspect-fit rect,
// so its origin/size are usually fractional points; a metal sublayer whose frame doesn't land
// on whole device pixels is RESAMPLED by the macOS/UIKit compositor during composite a
// uniform "everything looks soft" blur even when the drawable itself is pixel-exact 1:1
// (verified via the stage2 "[1:1 (no resample)]" log while the picture was still soft). Round
// origin AND size to device pixels so the composite is a true 1:1 blit. Idempotent when the
// frame is already aligned (e.g. fullscreen fit == integer bounds), so it's a no-op there.
let scale = contentsScale > 0 ? contentsScale : 1
let snapped = CGRect(
x: (fit.origin.x * scale).rounded() / scale,
y: (fit.origin.y * scale).rounded() / scale,
width: (fit.width * scale).rounded() / scale,
height: (fit.height * scale).rounded() / scale)
// No implicit resize animation; contentsScale tracks the view's backing/display scale.
CATransaction.begin()
CATransaction.setDisableActions(true)
metalLayer.contentsScale = contentsScale
metalLayer.frame = fit
metalLayer.frame = snapped
CATransaction.commit()
// Hand the resulting pixel size to the render thread (it must not read layer geometry
// cross-thread) this is what the presenter sizes its drawable to.
// cross-thread) this is what the presenter sizes its drawable to. Uses the SNAPPED size so
// the drawable's texel count equals the on-screen device-pixel count exactly (1 texel 1
// device pixel); with the frame snapped, this equals the pre-snap rounded value, so the
// decodeddrawable 1:1 the log confirmed is preserved.
stage2?.setDrawableTarget(CGSize(
width: (fit.width * contentsScale).rounded(),
height: (fit.height * contentsScale).rounded()))
width: (snapped.width * scale).rounded(),
height: (snapped.height * scale).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
}
/// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main
/// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly-
/// stale `currentMode()`. Only stores the caller re-runs `layout` right after, because a
/// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size.
func setContentSize(_ size: CGSize) {
guard size.width > 0, size.height > 0, size != contentSize else { return }
contentSize = size
}
/// Stop the active pump/pipeline ( one poll timeout; stage-2 joins its pump) and detach the
/// stage-2 layer + link. Does not close the connection that stays with whoever owns it.
/// Idempotent.
func stop() {
contentSize = nil // a new session re-derives it from its first frame
pump?.stop()
pump = nil
stage2Link?.invalidate()
@@ -259,6 +259,10 @@ public final class Stage2Pipeline {
private let endToEndMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
private let recovery = KeyframeRecovery()
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
private let gate = ReanchorGate(framesDropped: 0)
private var token = StopFlag()
private var offsetNs: Int64 = 0
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
@@ -306,21 +310,29 @@ public final class Stage2Pipeline {
let ring = ring
let recovery = recovery
let renderSignal = renderSignal
let gate = gate
self.decoder = VideoDecoder(
onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
// skew applies). Stamped at decode completion, so it covers every decoded frame,
// including ones the newest-wins ring drops before present.
// including ones the re-anchor gate withholds or the newest-wins ring drops.
decodeMeter?.record(
ptsNs: UInt64(frame.receivedNs), atNs: frame.decodedNs, offsetNs: 0)
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
// garbage VideoToolbox returns Ok for a reference-missing delta) don't submit it,
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
// present) on a proven clean re-anchor (IDR / RFI anchor / 2nd recovery mark) or the
// bounded backstop. decoderKeyframe=false: VT doesn't flag IDRs, the wire FLAG_SOF does.
guard gate.onDecoded(flags: frame.flags) else { return }
ring.submit(frame)
// FRAME ARRIVAL is the render trigger (never the display link see the header).
renderSignal.signal()
},
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): the pump resets to
// re-gate on the next IDR, and we ask the host to send one now (infinite GOP it wouldn't
// Async decode failure (a bad P-frame referencing a lost/corrupt IDR): fold it into the
// gate's no-output streak (which arms the freeze after a short run, matching the desktop),
// and when that trips ask the host for a fresh IDR now (infinite GOP it wouldn't
// otherwise come soon). Throttled in KeyframeRecovery.
onDecodeError: { _ in recovery.request() })
onDecodeError: { _ in if gate.onNoOutput() { recovery.request() } })
}
/// Start pulling AUs into the decoder. MAIN THREAD. `onFrame` fires per AU at receipt (the
@@ -329,10 +341,12 @@ public final class Stage2Pipeline {
public func start(
connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
offsetNs = connection.clockOffsetNs
recovery.bind(connection) // arm host-keyframe recovery for this session
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
token = StopFlag() // fresh token per start a stop is permanent (like StreamPump)
// Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The
@@ -347,9 +361,13 @@ public final class Stage2Pipeline {
let recovery = recovery
let presenter = presenter
let pumpStopped = pumpStopped
let reanchorGate = gate
let thread = Thread {
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
var format: CMVideoFormatDescription?
// Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per
// loss-recovery IDR at the same size (see StreamPump).
var lastDecodedDims: CMVideoDimensions?
var lastFramesDropped = connection.framesDropped()
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale):
// keep asking until an IDR lands so a request swallowed by the throttle is re-sent.
@@ -358,7 +376,12 @@ public final class Stage2Pipeline {
// 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.
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 {
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
// decoder conceals the reference-missing deltas often WITHOUT an error callback
@@ -370,6 +393,9 @@ public final class Stage2Pipeline {
awaitingIDR = true
}
if awaitingIDR { recovery.request() }
// Freeze backstop: a drop-count climb arms the gate (in case the frame-index gap
// below was itself lost), and an overdue freeze re-asks for the re-anchor.
if reanchorGate.poll(framesDropped: dropped) { recovery.request() }
// Drain HDR mastering metadata (0xCE) and hand it to the PRESENTER ( CAEDRMetadata).
// Polled UNCONDITIONALLY (not gated on connection.isHDR, the fixed Welcome flag): the
// host sends 0xCE only for HDR, INCLUDING a mid-session SDRHDR transition (a game
@@ -378,13 +404,25 @@ public final class Stage2Pipeline {
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
presenter.setHdrMeta(meta)
}
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze
// the following concealed frames are withheld until a clean re-anchor.
if connection.noteFrameIndexGap(au.frameIndex) { reanchorGate.arm() }
onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included)
let dims = CMVideoFormatDescriptionGetDimensions(f)
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
lastDecodedDims = dims
onDecodedSize?(Int(dims.width), Int(dims.height))
}
awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
}
guard let f = format, !token.isStopped else { continue }
guard let f = format, !token.isStopped else { return true }
if decoder.decode(au: au, format: f) {
decodeFailRun = 0
} else {
@@ -397,12 +435,14 @@ public final class Stage2Pipeline {
// recovers within a GOP) 4:4:4 isn't decodable here; end the session.
if connection.isChroma444, decodeFailRun >= 180 {
if !token.isStopped { onSessionEnd?() }
break
return false
}
}
return true
} catch {
if !token.isStopped { onSessionEnd?() }
break // session closed
return false // session closed
}
}
}
}
@@ -435,10 +475,14 @@ public final class Stage2Pipeline {
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
let renderThread = Thread {
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 {
debugStats?.flushIfDue(ring: ring, gate: gate)
continue
return
}
// Stage-3: while a present is in flight, don't take from the ring at all frames
// keep coalescing there (newest wins, the intended drop point) and the presented
@@ -447,13 +491,13 @@ public final class Stage2Pipeline {
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
debugStats?.gatedWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
continue
return
}
guard !token.isStopped, let frame = ring.take() else {
gate?.release() // armed but nothing to render don't hold the gate stale
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: gate)
continue
return
}
// 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.
@@ -488,7 +532,7 @@ public final class Stage2Pipeline {
ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: gate)
}
} }
}
renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
@@ -512,6 +556,13 @@ public final class Stage2Pipeline {
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
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
/// (the stop is permanent).
@@ -21,12 +21,18 @@ final class StreamPump {
connection: PunktfunkConnection,
layer: AVSampleBufferDisplayLayer,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
let token = token
// Coalesced host keyframe requests (100 ms throttle see KeyframeRecovery).
let recovery = KeyframeRecovery()
recovery.bind(connection)
// Post-loss freeze-until-reanchor (shared core policy via the C ABI). Stage-1 has no per-frame
// decode callback, so the gate is folded at ENQUEUE (from the AU's wire flags): a withheld
// frame is still enqueued but flagged DoNotDisplay so the layer's decoder keeps the reference
// chain fed while the last GOOD picture stays on glass until a clean re-anchor lifts it.
let gate = ReanchorGate(framesDropped: connection.framesDropped())
// The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after
// this point only the pump thread drives it assert that so the @Sendable Thread closure
// may capture it.
@@ -35,6 +41,9 @@ final class StreamPump {
let thread = Thread {
var format: CMVideoFormatDescription?
// Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR),
// not on every loss-recovery IDR at the same size so it fires once per real switch.
var lastDecodedDims: CMVideoDimensions?
var lastFramesDropped = connection.framesDropped()
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a
// decoder reset), retry the throttled request EVERY iteration, and clear it only when a
@@ -47,7 +56,12 @@ final class StreamPump {
var awaitingIDR = false
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
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 {
// Loss recovery (the primary path). Under the host's infinite GOP the only
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
@@ -68,12 +82,26 @@ final class StreamPump {
awaitingIDR = true
}
if awaitingIDR { recovery.request() }
// Freeze backstop: a drop-count climb arms the gate (should the frame-index gap
// below be lost too), and an overdue freeze re-asks for the re-anchor.
if gate.poll(framesDropped: dropped) { recovery.request() }
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
// Loss recovery (RFI): a forward frame-index gap fires a throttled reference-
// frame-invalidation request so an RFI-capable host (AMD LTR / NVENC) recovers
// with a cheap clean P-frame instead of a full IDR. The framesDropped-driven
// recovery above stays the backstop for when the recovery frame itself is lost.
// The same gap is the earliest, most precise signal to ARM the display freeze.
if connection.noteFrameIndexGap(au.frameIndex) { gate.arm() }
onFrame?(au)
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat {
format = f // refreshed on every IDR (mode changes included)
let dims = CMVideoFormatDescriptionGetDimensions(f)
if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height {
lastDecodedDims = dims
onDecodedSize?(Int(dims.width), Int(dims.height))
}
if awaitingIDR {
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
@@ -88,6 +116,7 @@ final class StreamPump {
// delta into a failed layer can't recover it.
if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") }
layer.flush()
gate.arm() // a wedged decoder is a loss freeze until the re-anchor
if idrFormat == nil {
format = nil
awaitingIDR = true
@@ -97,13 +126,22 @@ final class StreamPump {
guard let f = format,
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
!token.isStopped // don't enqueue a stale frame after a restart
else { continue }
else { return true }
// Freeze-until-reanchor: while holding, WITHHOLD this concealed post-loss frame by
// flagging it DoNotDisplay the layer still decodes it (keeping the reference
// chain fed) but shows the last GOOD picture until a clean re-anchor lifts the
// gate. Folded from the AU's wire flags (stage-1 has no decode callback).
if !gate.onDecoded(flags: au.flags) {
StreamPump.setDoNotDisplay(sample)
}
layer.enqueue(sample)
return true
} catch {
if !token.isStopped {
onSessionEnd?()
}
break // session closed
return false // session closed
}
}
}
}
@@ -112,6 +150,21 @@ final class StreamPump {
thread.start()
}
/// Flag a sample decode-but-don't-display (`kCMSampleAttachmentKey_DoNotDisplay`). Used to
/// withhold decoder-concealed post-loss frames while the re-anchor gate holds: the layer keeps
/// its reference chain fed without flipping the frozen picture. No-op if the attachments array
/// can't be materialized (then the frame just displays the freeze degrades to the old behavior).
private static func setDoNotDisplay(_ sample: CMSampleBuffer) {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
sample, createIfNecessary: true), CFArrayGetCount(attachments) > 0
else { return }
let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
CFDictionarySetValue(
dict,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DoNotDisplay).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
}
/// Stop pumping ( one poll timeout). Does not close the connection.
func stop() {
token.stop()
@@ -27,19 +27,40 @@ public struct ReadyFrame: @unchecked Sendable {
/// True when the stream is HDR (BT.2020 PQ): the buffer is 10-bit P010 and the presenter must
/// configure EDR + BT.2020 PQ output. Derived from the decoded buffer's pixel format.
public let isHDR: Bool
/// The AU's wire `user_flags` (`AccessUnit.flags`), threaded through the decode via the frame
/// context so the re-anchor gate can classify this decoded frame (IDR / RFI anchor / recovery
/// mark) at present time the async decode callback has no other access to it. 0 when unknown.
public let flags: UInt32
}
/// Per-frame context threaded through the VideoToolbox frame refcon: the AU's receipt instant (for
/// the decode-stage meter) and its wire `user_flags` (for the re-anchor gate). Retained across the
/// async decode and reclaimed exactly once by the output callback for every frame VideoToolbox
/// accepts, or by `decode`'s error branch for a frame `DecodeFrame` rejected outright (the callback
/// then never fires). A tiny per-frame allocation, the price of smuggling two values (a 64-bit
/// instant plus the flags) through the single `void*` a bit-pattern scalar can't hold.
private final class FrameContext {
let receivedNs: Int64
let flags: UInt32
init(receivedNs: Int64, flags: UInt32) {
self.receivedNs = receivedNs
self.flags = flags
}
}
/// The C output callback can't capture context, so VideoToolbox hands it the refcon we set at
/// session creation a pointer back to the owning `VideoDecoder`. The per-frame refcon carries
/// the AU's `receivedNs` as a pointer bit pattern (a scalar smuggled through the C void*, never
/// dereferenced) so the decode stage can be computed against decode-completion.
/// session creation a pointer back to the owning `VideoDecoder`. The per-frame refcon is the
/// retained `FrameContext` set at submit; reclaim it here (balancing `passRetained`) and unpack the
/// AU's receipt instant (for the decode stage) and wire flags (for the re-anchor gate).
private let decoderOutputCallback: VTDecompressionOutputCallback = {
refcon, frameRefcon, status, _, imageBuffer, pts, _ in
guard let refcon else { return }
let receivedNs = frameRefcon.map { Int64(Int(bitPattern: $0)) } ?? 0
let ctx = frameRefcon.map { Unmanaged<FrameContext>.fromOpaque($0).takeRetainedValue() }
Unmanaged<VideoDecoder>.fromOpaque(refcon)
.takeUnretainedValue()
.handleDecoded(status: status, imageBuffer: imageBuffer, pts: pts, receivedNs: receivedNs)
.handleDecoded(
status: status, imageBuffer: imageBuffer, pts: pts,
receivedNs: ctx?.receivedNs ?? 0, flags: ctx?.flags ?? 0)
}
/// Owns a `VTDecompressionSession` rebuilt whenever the format description changes (every IDR /
@@ -117,16 +138,21 @@ public final class VideoDecoder: @unchecked Sendable {
let sample = codec.sampleBuffer(au: au, format: newFormat)
else { lock.unlock(); return false }
var infoOut = VTDecodeInfoFlags()
// The AU's receipt instant + wire flags ride through as a retained context; the output
// callback reclaims it. Retain immediately before submit so no early return can leak it.
let ctx = FrameContext(receivedNs: au.receivedNs, flags: au.flags)
let refcon = Unmanaged.passRetained(ctx).toOpaque()
let status = VTDecompressionSessionDecodeFrame(
session,
sampleBuffer: sample,
flags: [._EnableAsynchronousDecompression],
// The AU's receipt instant rides through as a bit pattern (nil for 0 the output
// callback maps that back to 0); the callback needs it to stamp the decode stage.
frameRefcon: UnsafeMutableRawPointer(bitPattern: Int(au.receivedNs)),
frameRefcon: refcon,
infoFlagsOut: &infoOut)
lock.unlock()
if status != noErr {
// DecodeFrame rejected the frame outright the output callback will NOT fire, so
// reclaim the context here (balancing passRetained) to avoid leaking it.
Unmanaged<FrameContext>.fromOpaque(refcon).release()
onDecodeError(status)
return false
}
@@ -231,9 +257,10 @@ public final class VideoDecoder: @unchecked Sendable {
}
/// VT thread. Stamp decode-completion and enqueue, or report the error. `receivedNs` is the
/// AU's receipt instant threaded through the frame refcon (0 = unknown).
/// AU's receipt instant and `flags` its wire `user_flags`, both threaded through the frame refcon
/// (0 = unknown).
fileprivate func handleDecoded(
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64
status: OSStatus, imageBuffer: CVImageBuffer?, pts: CMTime, receivedNs: Int64, flags: UInt32
) {
guard status == noErr, let imageBuffer else {
onDecodeError(status)
@@ -259,6 +286,6 @@ public final class VideoDecoder: @unchecked Sendable {
onDecoded(
ReadyFrame(
ptsNs: ptsNs, receivedNs: receivedNs, decodedNs: decodedNs,
pixelBuffer: imageBuffer, isHDR: isHDR))
pixelBuffer: imageBuffer, isHDR: isHDR, flags: flags))
}
}
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
private let onDisconnectRequest: (() -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)?
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
self.onDisconnectRequest = onDisconnectRequest
self.onFrame = onFrame
self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view
}
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter
view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed.
if view.connection !== connection {
@@ -165,6 +175,15 @@ public final class StreamLayerView: NSView {
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
public private(set) var connection: PunktfunkConnection?
/// Match-window resize follower (C3) non-nil while a session is active AND the `matchWindow`
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
private var matchFollower: MatchWindowFollower?
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
/// re-fits the metal sublayer to the REAL content aspect here `layout()` only re-runs on a
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
private var lastDecodedContentSize: CGSize?
private let cursorCapture = CursorCapture()
private var inputCapture: InputCapture?
private var appObservers: [NSObjectProtocol] = []
@@ -201,6 +220,13 @@ public final class StreamLayerView: NSView {
/// view can't do that itself (the connection's owner disconnects).
public var onDisconnectRequest: (() -> Void)?
/// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget`
/// (main thread, via the follower) fires the instant the window starts steering toward a new
/// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives
/// the blur+spinner from these set before `start()`.
public var onResizeTarget: ((UInt32, UInt32) -> Void)?
public var onDecodedSize: (@Sendable (Int, Int) -> Void)?
/// Main-thread only. False = input capture disabled outright (UI layered over the
/// stream); flipping to true auto-engages once.
public var captureEnabled = true {
@@ -618,6 +644,10 @@ public final class StreamLayerView: NSView {
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
// NSView.displayLink so it tracks the display this view is on.
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
let overlayDecodedSize = onDecodedSize
presenter.start(
connection: connection,
baseLayer: displayLayer,
@@ -626,15 +656,39 @@ public final class StreamLayerView: NSView {
displayMeter: displayMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd)
onSessionEnd: onSessionEnd,
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
overlayDecodedSize?(w, h)
})
// Match-window (C3): when ON, follow the window's pixel size so a windowed session streams
// 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
// non-matching window. The first real `layout()` feeds the initial size, so the stream
// converges to the window even though the connect used the explicit/display mode; entering
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
// OPT-IN `?? false` matches the Settings toggle (which also defaults off); an unset
// default keeps the explicit mode.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower
layoutPresenter()
requestAutoCapture() // entering a session is the deliberate "capture me" moment
}
/// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a
/// retinanon-retina move (see SessionPresenter.layout).
/// retinanon-retina move (see SessionPresenter.layout). Also feeds the Match-window follower
/// the view's physical-pixel size (bounds backing), so a window resize / retina move follows.
private func layoutPresenter() {
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
// Feed the follower only once in a window (backing scale is real then) and with real
// bounds a pre-window layout would report point-sized dimensions.
if window != nil, bounds.width > 0, bounds.height > 0 {
let px = convertToBacking(bounds).size
matchFollower?.noteSize(
widthPx: Int(px.width.rounded()), heightPx: Int(px.height.rounded()))
}
}
public override func viewDidChangeBackingProperties() {
@@ -642,6 +696,18 @@ public final class StreamLayerView: NSView {
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
}
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
private func noteDecodedContentSize(width: Int, height: Int) {
let size = CGSize(width: width, height: height)
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
lastDecodedContentSize = size
presenter.setContentSize(size)
layoutPresenter()
}
/// Stop pumping ( one poll timeout). Does not close the connection that stays with
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
public func stop() {
@@ -650,6 +716,8 @@ public final class StreamLayerView: NSView {
inputCapture?.stop()
inputCapture = nil
presenter.stop()
matchFollower = nil
lastDecodedContentSize = nil // the next session re-derives it from its first frame
connection = nil
}
@@ -24,7 +24,9 @@
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
// scroll) only while NOT locked so a pointer that emits both channels under lock can't double-send.
// Hardware keyboard forwarding shares InputCapture with macOS auto-engaged when streaming
// starts, toggles (detected from the HID stream; there is no NSEvent monitor here).
// starts, toggles and Q releases (both detected from the HID stream; there is no NSEvent
// monitor here). Q is the cross-client Ctrl+Alt+Shift+Q it un-captures so the Magic Keyboard
// trackpad drives the local iPad UI again.
//
// The public type is named StreamView like its macOS twin (each is platform-gated), so
// the SwiftUI app layer is identical on both platforms.
@@ -36,6 +38,9 @@ import PunktfunkCore
import SwiftUI
import UIKit
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
/// resolved pointer-lock state each time capture engages, so the user can see whether the
@@ -50,6 +55,8 @@ public struct StreamView: UIViewControllerRepresentable {
private let onCaptureChange: ((Bool) -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> Void)?
private let onResizeTarget: ((UInt32, UInt32) -> Void)?
private let onDecodedSize: (@Sendable (Int, Int) -> Void)?
private let endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter?
@@ -65,6 +72,8 @@ public struct StreamView: UIViewControllerRepresentable {
onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil
@@ -74,6 +83,8 @@ public struct StreamView: UIViewControllerRepresentable {
self.onCaptureChange = onCaptureChange
self.onFrame = onFrame
self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter
self.displayMeter = displayMeter
@@ -86,6 +97,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return controller
}
@@ -96,6 +109,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
if controller.connection !== connection {
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
}
@@ -108,7 +123,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?
private var observers: [NSObjectProtocol] = []
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
@@ -119,6 +147,11 @@ public final class StreamViewController: UIViewController {
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
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)
private var inputCapture: InputCapture?
fileprivate var captured = false
@@ -126,6 +159,12 @@ public final class StreamViewController: UIViewController {
/// Capture state at the last resign, restored on the next foreground otherwise the
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
private var wasCapturedOnResign = false
/// Match-window resize follower (C3) non-nil while a session is active AND the `matchWindow`
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
private var matchFollower: MatchWindowFollower?
#endif
/// Reads whether the scene's pointer is actually locked right now; nil = state
@@ -140,6 +179,18 @@ public final class StreamViewController: UIViewController {
}
var onCaptureChange: ((Bool) -> Void)?
/// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the
/// blur+spinner the instant the window differs from the live mode (iOS only tvOS has no
/// follower). See `MatchWindowFollower.onResizeTarget`.
var onResizeTarget: ((UInt32, UInt32) -> Void)?
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
/// overlay clears when a frame at the requested size actually decodes.
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
/// here `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
private var lastDecodedContentSize: CGSize?
var captureEnabled = true {
didSet {
@@ -157,6 +208,12 @@ public final class StreamViewController: UIViewController {
public override func loadView() {
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.
// 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
@@ -230,6 +287,18 @@ public final class StreamViewController: UIViewController {
}
#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(
connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?,
@@ -270,7 +339,19 @@ public final class StreamViewController: UIViewController {
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
}
streamView.onPointerButton = { [weak self] button, down in
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
guard let self else { return }
// Released a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
// analogue of macOS's `mouseDown engageCapture(fromClick:)`, and the click-mirror of
// the / Q keyboard toggles). Only the button-DOWN engages; that click is the local
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
// re-captures.) Captured already the absolute path forwards the button as before.
if !self.captured {
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
return
}
guard self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendMouseButton(button, pressed: down)
}
streamView.onScroll = { [weak self] dx, dy in
@@ -283,16 +364,38 @@ public final class StreamViewController: UIViewController {
guard let self else { return }
self.setCaptured(!self.captured)
}
// Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the toggle it
// only ever RELEASES re-pressing it while already released is a no-op (setCaptured guards).
capture.onReleaseCapture = { [weak self] in
self?.setCaptured(false)
}
capture.onPreempted = { [weak self] in
self?.setCaptured(false)
}
capture.start()
inputCapture = capture
// Match-window (C3): when ON, follow the scene's pixel size so a resizable iPad scene
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
// `viewDidLayoutSubviews` feeds it covers Stage Manager / Split View resizes and rotation.
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
// OPT-IN `?? false` matches the Settings toggle (which also defaults off); an unset
// default keeps the explicit mode.
let follower = MatchWindowFollower(
connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
follower.onResizeTarget = onResizeTarget
matchFollower = follower
#endif
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
let overlayDecodedSize = onDecodedSize
presenter.start(
connection: connection,
baseLayer: streamView.displayLayer,
@@ -301,7 +404,11 @@ public final class StreamViewController: UIViewController {
displayMeter: displayMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame,
onSessionEnd: onSessionEnd)
onSessionEnd: onSessionEnd,
onDecodedSize: { [weak self] w, h in
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
overlayDecodedSize?(w, h)
})
layoutMetalLayer()
#if os(iOS)
@@ -337,11 +444,37 @@ public final class StreamViewController: UIViewController {
) { [weak self] _ in
self?.syncPointerLock()
})
// The Stream menu's "Release Mouse" (Q) posts this the discoverable menu surface for
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
// foreground-active scene's stream acts the iPad analogue of macOS's key-window guard, so a
// second Stage Manager scene isn't released out from under the user.
observers.append(NotificationCenter.default.addObserver(
forName: .punktfunkReleaseCapture, object: nil, queue: .main
) { [weak self] _ in
guard let self,
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
self.setCaptured(false)
})
if captureEnabled {
setCaptured(true) // entering a session is the deliberate "capture me" moment
}
#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() {
@@ -359,15 +492,75 @@ public final class StreamViewController: UIViewController {
streamView.onPointerButton = nil
streamView.onScroll = nil
streamView.currentHostMode = nil
matchFollower = nil
#endif
#if os(tvOS)
// Return the TV to the user's preferred mode the home screen must not stay in the
// session's HDR10/refresh mode.
sessionDisplayManager?.preferredDisplayCriteria = nil
sessionDisplayManager = nil
#endif
presenter.stop()
lastDecodedContentSize = nil // the next session re-derives it from its first frame
connection = nil
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutMetalLayer()
#if os(iOS)
// Match-window (C3): feed the follower the view's physical-pixel size (points × scale).
let b = streamView.bounds
if b.width > 0, b.height > 0 {
let scale = renderScale
matchFollower?.noteSize(
widthPx: Int((b.width * scale).rounded()),
heightPx: Int((b.height * scale).rounded()))
}
#endif
#if os(tvOS)
applyDisplayCriteriaIfNeeded()
#endif
}
#if os(tvOS)
/// Ask the TV for a display mode matching the session HDR10 at the stream's refresh rate
/// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation
/// playback layers do this implicitly). Honored only when the user allows matching (tvOS
/// Settings Video and Audio Match Content); the presenter reads the RESULT off UIScreen's
/// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
/// stop() teardown clears it.
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
// A synthetic HDR10-HEVC format description carrying the negotiated mode what the
// stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the
// display to it (tvOS 17+, our deployment floor).
let ext: [CFString: Any] = [
kCMFormatDescriptionExtension_ColorPrimaries:
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
kCMFormatDescriptionExtension_TransferFunction:
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
kCMFormatDescriptionExtension_YCbCrMatrix:
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
]
var desc: CMFormatDescription?
CMVideoFormatDescriptionCreate(
allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC,
width: Int32(mode.width), height: Int32(mode.height),
extensions: ext as CFDictionary, formatDescriptionOut: &desc)
guard let desc else { return }
manager.preferredDisplayCriteria = AVDisplayCriteria(
refreshRate: Float(mode.refreshHz), formatDescription: desc)
sessionDisplayManager = manager
}
#endif
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
/// canonical render scale and is reliable once the controller is in the hierarchy;
@@ -385,12 +578,28 @@ public final class StreamViewController: UIViewController {
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
}
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
private func noteDecodedContentSize(width: Int, height: Int) {
let size = CGSize(width: width, height: height)
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
lastDecodedContentSize = size
presenter.setContentSize(size)
layoutMetalLayer()
}
#if os(iOS)
private func setCaptured(_ on: Bool) {
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
/// that click's press/release are suppressed toward the host it's the local engage gesture,
/// not a host click exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
/// engages () pass false so a normal click still reaches the host.
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
if on {
// `connection != nil` is the session-active gate (presenter internals are opaque here).
guard captureEnabled, !captured, connection != nil else { return }
inputCapture?.setForwarding(true)
inputCapture?.setForwarding(true, suppressClick: fromClick)
captured = true
} else {
guard captured else { return }
@@ -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))
}
}
@@ -3,6 +3,7 @@
// player-LED-bits GCControllerPlayerIndex map. All pure functions.
import GameController
import PunktfunkCore
import XCTest
@testable import PunktfunkKit
@@ -40,6 +41,43 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.axisRT, 5)
}
func testPadIndexRidesFlagsOnEveryPerPadEvent() {
// The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis.
let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3)
XCTAssertEqual(btn.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue))
XCTAssertEqual(btn.code, GamepadWire.a)
XCTAssertEqual(btn.x, 1)
XCTAssertEqual(btn.flags, 3)
let axis = PunktfunkInputEvent.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 5)
XCTAssertEqual(axis.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue))
XCTAssertEqual(axis.code, GamepadWire.axisRT)
XCTAssertEqual(axis.x, 200)
XCTAssertEqual(axis.flags, 5)
// Single-controller path stays byte-identical: pad 0 flags 0, exactly as before.
XCTAssertEqual(PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: false, pad: 0).flags, 0)
XCTAssertEqual(PunktfunkInputEvent.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0).flags, 0)
}
func testArrivalAndRemoveWireLayout() {
// GamepadArrival (kind 14): code = the GamepadType wire byte, flags = pad index.
let arrival = PunktfunkInputEvent.gamepadArrival(
pref: PunktfunkConnection.GamepadType.dualSense.rawValue, pad: 2)
XCTAssertEqual(arrival.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue))
XCTAssertEqual(arrival.code, PunktfunkConnection.GamepadType.dualSense.rawValue) // 2
XCTAssertEqual(arrival.flags, 2)
// The GamepadType raw values ARE the GamepadPref wire bytes the host resolves.
XCTAssertEqual(PunktfunkConnection.GamepadType.xbox360.rawValue, 1)
XCTAssertEqual(PunktfunkConnection.GamepadType.dualSense.rawValue, 2)
XCTAssertEqual(PunktfunkConnection.GamepadType.xboxOne.rawValue, 3)
XCTAssertEqual(PunktfunkConnection.GamepadType.dualShock4.rawValue, 4)
// GamepadRemove (kind 13): flags = pad index (the core stamps the per-pad seq).
let remove = PunktfunkInputEvent.gamepadRemove(pad: 7)
XCTAssertEqual(remove.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue))
XCTAssertEqual(remove.flags, 7)
// 16 addressable pads (punktfunk_core::input::MAX_PADS).
XCTAssertEqual(GamepadWire.maxPads, 16)
}
func testTouchpadConversionCorners() {
// GC ±1 with +y up wire 0...65535 with origin top-left, +y down.
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
@@ -88,16 +88,19 @@ final class LoopbackIntegrationTests: XCTestCase {
// one feedback burst on the hostclient planes drain both and verify, end to
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
var rumble: (pad: UInt16, low: UInt16, high: UInt16)?
var rumble: (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
var hidout: [PunktfunkConnection.HidOutputEvent] = []
let feedbackDeadline = Date().addingTimeInterval(10)
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r }
if rumble == nil, let r = try conn.nextRumble2(timeoutMs: 100) { rumble = r }
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
}
XCTAssertEqual(rumble?.pad, 0)
XCTAssertEqual(rumble?.low, 0x4000)
XCTAssertEqual(rumble?.high, 0x8000)
// The synthetic host emits a v2 envelope (400 ms TTL) assert the self-terminating tail
// survived the full wire C ABI Swift path, not just the level.
XCTAssertEqual(rumble?.ttlMs, 400)
XCTAssertTrue(
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
"missing the scripted lightbar event: \(hidout)")
@@ -0,0 +1,43 @@
// The Match-window trigger discipline (design/midstream-resolution-resize.md D2), as pure
// functions the same rules the session binary's `resize_decision` unit-tests: physical pixels
// even-floored and clamped 320×200, skip a size equal to the live mode, and request each
// distinct size at most once (so a rejected size / a host rollback can't loop).
import XCTest
@testable import PunktfunkKit
final class MatchWindowTests: XCTestCase {
func testNormalizeEvenFloorsAndClamps() {
// Odd pixels floor to even (the host rejects odd dimensions).
let a = MatchWindow.normalize(widthPx: 1001, heightPx: 601)
XCTAssertEqual(a.width, 1000)
XCTAssertEqual(a.height, 600)
// Already-even sizes pass through.
let b = MatchWindow.normalize(widthPx: 2560, heightPx: 1440)
XCTAssertEqual(b.width, 2560)
XCTAssertEqual(b.height, 1440)
// Tiny / zero clamp to the host floor.
let c = MatchWindow.normalize(widthPx: 100, heightPx: 80)
XCTAssertEqual(c.width, 320)
XCTAssertEqual(c.height, 200)
let z = MatchWindow.normalize(widthPx: 0, heightPx: -4)
XCTAssertEqual(z.width, 320)
XCTAssertEqual(z.height, 200)
}
func testRequestSkipsEqualAndAlreadyRequested() {
// A new size (different from the live mode, not yet requested) request it.
let r = MatchWindow.request(
target: (1000, 600), current: (1280, 720), lastRequested: (800, 500))
XCTAssertEqual(r?.width, 1000)
XCTAssertEqual(r?.height, 600)
// Equal to the live mode nothing to do.
XCTAssertNil(MatchWindow.request(
target: (1280, 720), current: (1280, 720), lastRequested: nil))
// Already requested once don't re-ask (covers a rejected size AND a host rollback:
// accepted rebuild failed corrective ack restored the old mode must not loop).
XCTAssertNil(MatchWindow.request(
target: (1000, 600), current: (1280, 720), lastRequested: (1000, 600)))
}
}
@@ -0,0 +1,52 @@
import XCTest
@testable import PunktfunkKit
final class ResizeIndicatorTests: XCTestCase {
func testInactiveUntilSteered() {
var r = ResizeIndicator()
XCTAssertFalse(r.active)
// A decoded frame with nothing pending is a no-op (session start / steady state).
r.decoded(width: 1920, height: 1080)
XCTAssertFalse(r.active)
}
func testSteeringActivatesAndDecodedTargetClears() {
var r = ResizeIndicator()
r.steering(width: 2560, height: 1440, now: 0)
XCTAssertTrue(r.active)
// A frame at a DIFFERENT size (the old mode still draining) doesn't clear it.
r.decoded(width: 1920, height: 1080)
XCTAssertTrue(r.active)
// The target frame lands clear.
r.decoded(width: 2560, height: 1440)
XCTAssertFalse(r.active)
}
func testTimeoutClearsWhenTargetNeverArrives() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2560, height: 1440, now: 10)
r.tick(now: 12) // 2 s < timeout still up
XCTAssertTrue(r.active)
r.tick(now: 12.6) // 2.6 s timeout a rejected/capped switch clears
XCTAssertFalse(r.active)
}
func testDragReArmsTimeoutOnEachNewTarget() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2000, height: 1200, now: 0)
r.steering(width: 2200, height: 1200, now: 2) // target changed since re-armed to 2
r.tick(now: 4) // only 2 s since the last change still up (drag isn't a timeout)
XCTAssertTrue(r.active)
r.tick(now: 4.6) // 2.6 s since the last change clears
XCTAssertFalse(r.active)
}
func testSteadyDragDoesNotResetTimeout() {
var r = ResizeIndicator(timeout: 2.5)
r.steering(width: 2560, height: 1440, now: 0)
r.steering(width: 2560, height: 1440, now: 1) // SAME target since stays 0
r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer clears (not reset by the repeat)
XCTAssertFalse(r.active)
}
}
@@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase {
renderer.stop()
}
func testLeaseSecondsInterpretsWireTTL() {
// The legacy no-lease sentinel nil (fall back to the staleness watchdog).
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
// A real lease its duration in seconds (non-nil for any ttl != noTTL).
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 0.15, accuracy: 1e-9)
}
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
// The whole point of v2: a host-supplied lease silences the motor faster than the
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
// default 400 ms TTL is well under that, on every platform.
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
XCTAssertNotNil(defaultTTL)
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
// The ticker must be able to observe an expired lease promptly (well within one TTL).
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
}
/// A v2 envelope with a short TTL, left unrenewed, must self-silence the renderer's core
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
func testEnvelopeExpiresWhenUnrenewed() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
// A 100 ms lease, then no renewal the ticker (50 ms) must silence it on its own.
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
Thread.sleep(forTimeInterval: 0.3)
// No assertion on private state; this exercises the expiry path + serial-queue teardown
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going".
+1
View File
@@ -496,6 +496,7 @@ impl AppModel {
2, // audio_channels: stereo
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
None, // launch: probe connect, no game
pin,
Some(identity),
+33 -19
View File
@@ -264,21 +264,23 @@ pub fn show(
let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build();
let res_names: Vec<String> = RESOLUTIONS
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes.
let res_names: Vec<String> = std::iter::once("Native display".to_string())
.chain(std::iter::once("Match window".to_string()))
.chain(
RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".to_string()
} else {
format!("{w} × {h}")
}
})
.skip(1)
.map(|&(w, h)| format!("{w} × {h}")),
)
.collect();
let res_row = ChoiceRow::new(
&dialog,
inline,
"Resolution",
"The host creates a virtual output at exactly this size",
"The host creates a virtual output at exactly this size — Match window follows \
the stream window, including mid-stream resizes",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let hz_names: Vec<String> = REFRESH
@@ -345,13 +347,14 @@ pub fn show(
stream.add(stats_row.widget());
let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently connected
// real pad (Steam's virtual pad skipped). A pin is persisted by stable key
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad
// (Steam's virtual pad skipped); pinning one restricts the session to that single
// controller (single-player). The pin is persisted by stable key (`Settings::forward_pad`),
// so it survives restarts — and disconnects: an offline pinned pad keeps its entry here
// instead of silently snapping back to Automatic.
let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (most recent)".to_string()];
let mut pad_names = vec!["Automatic (all controllers)".to_string()];
let mut pad_keys: Vec<String> = Vec::new();
for p in &pads {
let kind = p.kind_label();
@@ -377,7 +380,7 @@ pub fn show(
if pads.is_empty() {
"No controllers detected"
} else {
"Exactly one controller is forwarded to the host"
"All controllers are forwarded, each as its own player; pick one to force single-player"
},
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
@@ -470,10 +473,15 @@ pub fn show(
// Seed from the current settings.
{
let s = settings.borrow();
let res_i = RESOLUTIONS
let res_i = if s.match_window {
1
} else {
RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0);
.map(|i| if i == 0 { 0 } else { i + 1 })
.unwrap_or(0)
};
res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32);
@@ -508,8 +516,14 @@ pub fn show(
dialog.add(&page);
dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut();
let (w, h) = RESOLUTIONS[(res_row.selected() as usize).min(RESOLUTIONS.len() - 1)];
(s.width, s.height) = (w, h);
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
s.match_window = res_i == 1;
(s.width, s.height) = if res_i <= 1 {
(0, 0)
} else {
RESOLUTIONS[res_i - 1]
};
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
+89 -2
View File
@@ -111,6 +111,11 @@ struct Args {
/// `--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.
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> {
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
.iter()
.any(|a| a == "--discover")
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
}
}
@@ -485,6 +491,10 @@ async fn session(args: Args) -> Result<()> {
| punktfunk_core::quic::CODEC_AV1,
// `--codec` soft preference (0 = auto). The host honors it when it can emit it.
preferred_codec: args.preferred_codec,
// PUNKTFUNK_CLIENT_PEAK_NITS=<nits> advertises a synthetic display volume — the host
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
// path can be validated headlessly (check the host's monitor caps / ADD log line).
display_hdr: punktfunk_core::client::display_hdr_env_override(),
}
.encode(),
)
@@ -523,7 +533,8 @@ async fn session(args: Args) -> Result<()> {
// 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
// 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) => {
tracing::info!(
offset_ns = skew.offset_ns,
@@ -536,6 +547,42 @@ async fn session(args: Args) -> Result<()> {
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
// 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.
@@ -960,6 +1007,10 @@ async fn session(args: Args) -> Result<()> {
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Set when a self-terminating v2 rumble envelope (0xCA with the seq+ttl tail) arrives — the
// Rust-side contract check for `PUNKTFUNK_TEST_FEEDBACK` (asserted at report time). A legacy v1
// datagram leaves it false, so this only ever fails when a v2 tail we EXPECTED went missing.
let saw_v2_rumble = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
// Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts
// and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
let (host_timing_tx, host_timing_rx) =
@@ -971,6 +1022,7 @@ async fn session(args: Args) -> Result<()> {
rumble_pkts.clone(),
hidout_pkts.clone(),
);
let saw_v2 = saw_v2_rumble.clone();
let ht_tx = host_timing_tx;
let conn2 = conn.clone();
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
@@ -979,6 +1031,7 @@ async fn session(args: Args) -> Result<()> {
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut hdr_logged = false;
let mut rumble_logged = false;
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
let mut audio_dec =
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
@@ -1004,7 +1057,24 @@ async fn session(args: Args) -> Result<()> {
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
}
}
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
envelope = ?u.envelope,
"rumble (0xCA)"
);
}
// Record that a v2 tail was present — the Rust-side seq/ttl contract check for
// PUNKTFUNK_TEST_FEEDBACK (asserted at report time).
if u.envelope.is_some() {
saw_v2.store(true, Relaxed);
}
r.fetch_add(1, Relaxed);
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
@@ -1245,6 +1315,23 @@ async fn session(args: Args) -> Result<()> {
}
}
// Rust-side rumble-envelope contract check: when the host was told to script a feedback burst
// (PUNKTFUNK_TEST_FEEDBACK, shared by a loopback harness), fail if no self-terminating v2 tail
// (seq + TTL) arrived — a regression that reverted the host to v1 level datagrams increments the
// rumble counter identically and would otherwise pass silently. Only tightens an otherwise-OK run
// (a video failure stays the primary error). The level + hidout planes are asserted end-to-end by
// the Apple loopback; this covers the seq/ttl tail on the Rust/Linux path.
let result = if std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
&& result.is_ok()
&& !saw_v2_rumble.load(std::sync::atomic::Ordering::Relaxed)
{
Err(anyhow::anyhow!(
"PUNKTFUNK_TEST_FEEDBACK: expected a v2 rumble envelope (0xCA seq+ttl tail), received none"
))
} else {
result
};
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
let close_code = if args.quit {
+4
View File
@@ -145,6 +145,10 @@ pub fn run(target: Option<&str>) -> u8 {
trust::touch_last_used(&trust::hex(&fingerprint));
})),
overlay: Some(Box::new(overlay)),
window_size: crate::session_main::window_size(&settings_at_start),
// Latched at console start (like the stats tier above): toggling Match window in
// the console's settings screen applies from the next console launch.
match_window: crate::session_main::match_window(&settings_at_start),
};
let result =
+32
View File
@@ -146,6 +146,10 @@ mod session_main {
} else {
0
},
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
// pump) pins one manually.
display_hdr: None,
mic_enabled: settings.mic_enabled,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
@@ -160,6 +164,32 @@ mod session_main {
}
}
/// The window's starting size under Match-window: the persisted last size, so the
/// first connect's mode already matches the glass; `None` (policy off / never
/// stored) = the 1280×720 default.
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
.then_some((settings.last_window_w, settings.last_window_h))
}
/// The Match-window policy hook for the presenter loop
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
/// logical window size (load-modify-save, like the console settings screen) so the
/// next launch opens at it.
pub(crate) fn match_window(settings: &trust::Settings) -> Option<Box<dyn FnMut(u32, u32)>> {
settings.match_window.then(|| {
Box::new(|w: u32, h: u32| {
let mut s = trust::Settings::load();
if (s.last_window_w, s.last_window_h) != (w, h) {
s.last_window_w = w;
s.last_window_h = h;
s.save();
}
}) as Box<dyn FnMut(u32, u32)>
})
}
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
/// failure through the same contract when spawned with `--json-status`.
@@ -339,6 +369,8 @@ mod session_main {
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
#[cfg(not(feature = "ui"))]
overlay: None,
window_size: window_size(&settings),
match_window: match_window(&settings),
};
let outcome =
+4 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "punktfunk-client-windows"
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, D3D11/SwapChainPanel present, FFmpeg decode, WASAPI audio, SDL3 gamepads"
description = "Native Windows punktfunk/1 client — WinUI 3 (windows-reactor) shell, SDL3 gamepads; streaming runs in the spawned punktfunk-session binary"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -57,13 +57,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
"Win32_UI_WindowsAndMessaging",
] }
# Video decode (same FFmpeg pin as the host/Linux client) — software HEVC on the GPU-less dev
# box; D3D11VA hardware decode is a follow-up for the real-GPU box.
# FFmpeg — used only to enumerate which codecs this client can decode (probe::decodable_codecs),
# advertised to the host on the speed-test connect. Same pin as the host/Linux client. (Real
# decode + present live in the spawned punktfunk-session binary.)
ffmpeg-next = "8"
opus = "0.3"
# Audio render + mic capture (the WASAPI analogue of the Linux client's PipeWire backend).
wasapi = "0.23"
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
# built from source via the bundled CMake on Windows (no system SDL3).
@@ -71,12 +68,8 @@ sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
mdns-sd = "0.20"
async-channel = "2"
# The decoded-frame channel (session pump → render thread): crossbeam because the render loop
# blocks with `recv_timeout`, which async-channel has no sync analogue of.
crossbeam-channel = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+5 -205
View File
@@ -6,10 +6,7 @@
use super::style::*;
use super::{AppCtx, Screen, Svc, Target};
use crate::discovery::DiscoveredHost;
use crate::session::{self, SessionEvent, SessionParams, Stats};
use crate::trust::{self, KnownHost, KnownHosts, Settings};
use crate::video::DecoderPref;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use crate::trust::{self, KnownHost, KnownHosts};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -117,82 +114,6 @@ pub(crate) fn initiate_launch(
);
}
/// The mode to request: explicit settings, with `0` fields resolved to the native size/refresh
/// of the display our window is on (mirrors the Linux/Swift clients' native-display default).
pub(crate) fn resolve_mode(s: &Settings) -> Mode {
let mut mode = Mode {
width: s.width,
height: s.height,
refresh_hz: s.refresh_hz,
};
if mode.width == 0 || mode.refresh_hz == 0 {
if let Some((w, h, hz)) = current_display_mode() {
if mode.width == 0 {
(mode.width, mode.height) = (w, h);
}
if mode.refresh_hz == 0 {
mode.refresh_hz = hz;
}
}
}
// No display info (headless session, RDP oddities) — a sane floor.
if mode.width == 0 {
(mode.width, mode.height) = (1920, 1080);
}
if mode.refresh_hz == 0 {
mode.refresh_hz = 60;
}
mode
}
/// The current mode (physical pixels + refresh) of the display our window occupies:
/// `MonitorFromWindow` on the foreground window — ours, the user just clicked in it — then
/// `EnumDisplaySettingsW(ENUM_CURRENT_SETTINGS)` on that monitor's device. Defaults to the
/// primary display when we're not foreground (e.g. a scripted connect).
fn current_display_mode() -> Option<(u32, u32, u32)> {
use windows::core::PCWSTR;
use windows::Win32::Graphics::Gdi::{
EnumDisplaySettingsW, GetMonitorInfoW, MonitorFromWindow, DEVMODEW, ENUM_CURRENT_SETTINGS,
MONITORINFO, MONITORINFOEXW,
};
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
unsafe {
let monitor = MonitorFromWindow(
GetForegroundWindow(),
windows::Win32::Graphics::Gdi::MONITOR_DEFAULTTOPRIMARY,
);
let mut info = MONITORINFOEXW::default();
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
if !GetMonitorInfoW(
monitor,
&mut info as *mut MONITORINFOEXW as *mut MONITORINFO,
)
.as_bool()
{
return None;
}
let mut dm = DEVMODEW {
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
..Default::default()
};
if !EnumDisplaySettingsW(
PCWSTR(info.szDevice.as_ptr()),
ENUM_CURRENT_SETTINGS,
&mut dm,
)
.as_bool()
{
return None;
}
// dmDisplayFrequency of 0/1 means "hardware default" — unusable as a mode request.
(dm.dmPelsWidth > 0 && dm.dmDisplayFrequency > 1).then_some((
dm.dmPelsWidth,
dm.dmPelsHeight,
dm.dmDisplayFrequency,
))
}
}
/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
/// plain "Connecting" screen.
@@ -220,9 +141,7 @@ pub(crate) struct ConnectOpts {
/// so it can't loop.
wake_on_fail: bool,
/// A library title id (`steam:570`, …) the host launches during the connect handshake —
/// the library page's tap-to-play. Spawn mode passes it as `--launch`; the legacy
/// in-process path has no launch plumbing (it predates the library and is slated for
/// deletion).
/// the library page's tap-to-play, passed to the spawned session child as `--launch`.
launch: Option<String>,
}
@@ -265,128 +184,11 @@ fn connect_with(
opts: ConnectOpts,
) {
// Session-always: every stream runs in the spawned punktfunk-session Vulkan binary.
// The in-process D3D11VA path below stays reachable via the "Streaming engine"
// setting / PUNKTFUNK_BUILTIN_STREAM=1 as the A/B baseline until its deletion.
if !super::use_builtin_stream(ctx) {
return connect_spawn(ctx, target, pin, set_screen, set_status, opts);
}
let s = ctx.settings.lock().unwrap().clone();
let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
Some(explicit) => explicit,
};
let handle = session::start(SessionParams {
host: target.addr.clone(),
port: target.port,
mode: resolve_mode(&s),
compositor: CompositorPref::from_name(&s.compositor).unwrap_or(CompositorPref::Auto),
gamepad: gamepad_pref,
bitrate_kbps: s.bitrate_kbps,
audio_channels: s.audio_channels,
mic_enabled: s.mic_enabled,
hdr_enabled: s.hdr_enabled,
decoder: DecoderPref::from_name(&s.decoder),
preferred_codec: s.preferred_codec(),
pin,
identity: ctx.identity.clone(),
connect_timeout: opts.connect_timeout,
});
set_status.call(String::new());
set_screen.call(if opts.awaiting_approval {
Screen::RequestAccess
} else {
Screen::Connecting
});
let tofu = pin.is_none();
let persist_paired = opts.persist_paired;
let cancel = opts.cancel;
let wake_on_fail = opts.wake_on_fail;
let ctx = ctx.clone();
let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
let (ss, st) = (set_screen.clone(), set_status.clone());
let target = target.clone();
std::thread::spawn(move || loop {
let event = match handle.events.recv_blocking() {
Ok(e) => e,
Err(_) => {
gamepad.detach();
ss.call(Screen::Hosts);
break;
}
};
// A cancelled request-access connect that resolved late (the host approved or the park
// timed out after the user walked away): tear down silently. Cancel already returned the
// UI to the host list; dropping `event` (and with it any connector) closes the connection
// without popping a stream or a stray error over the screen a new session may own.
if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
break;
}
match event {
SessionEvent::Connected {
connector,
fingerprint,
..
} => {
if persist_paired || tofu {
// Request-access: the operator approved this device, so record the host as a
// trusted PAIRED host — future connects are then silent (rule 1), exactly like
// after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
let mut k = KnownHosts::load();
k.upsert(KnownHost {
name: target.name.clone(),
addr: target.addr.clone(),
port: target.port,
fp_hex: trust::hex(&fingerprint),
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
});
let _ = k.save();
}
trust::touch_last_used(&trust::hex(&fingerprint));
gamepad.attach(connector.clone());
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
*shared.handoff.lock().unwrap() =
Some((connector, handle.frames.clone(), handle.stop.clone()));
ss.call(Screen::Stream);
}
SessionEvent::Failed {
msg,
trust_rejected,
} => {
st.call(msg);
gamepad.detach();
if trust_rejected {
// Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
// The host ANSWERED, so this never takes the wake fallback.
*shared.target.lock().unwrap() = target.clone();
ss.call(Screen::Pair);
} else if wake_on_fail {
// The dial-first attempt to a non-advertising host failed — it may genuinely
// be asleep. NOW wake and wait (its resolved redial uses default opts, so a
// second failure lands on the host list, not back here).
wake_and_connect(&ctx, target.clone(), &ss, &st);
} else {
ss.call(Screen::Hosts);
}
break;
}
SessionEvent::Ended(err) => {
// `None` = the user ended the session themselves (the disconnect shortcut) —
// return to the host list silently; an error banner would read as a failure.
st.call(err.unwrap_or_default());
gamepad.detach();
ss.call(Screen::Hosts);
break;
}
SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
}
});
connect_spawn(ctx, target, pin, set_screen, set_status, opts)
}
/// Spawn-mode connect: run the stream in the punktfunk-session binary and translate its
/// stdout contract into the same navigation the in-process event loop drove. The child
/// stdout contract into the app's connect-flow navigation. The child
/// NEVER connects unpinned — a stored/ceremony pin, else the host's advertised
/// fingerprint (TOFU: persisted once the child reports ready, which proves the host
/// really holds that identity, mirroring the GTK shell); no fingerprint at all routes to
@@ -723,9 +525,7 @@ pub(crate) fn request_access_page(
.on_click(move || {
// Return the UI immediately; trip the flag this request's event loop
// captured so it tears down silently when the connect resolves (see
// ConnectOpts::cancel). Spawn mode: killing the parked child IS the abort
// (builtin mode's in-process connect is blocking with none — it just
// resolves/times out later).
// ConnectOpts::cancel). Killing the parked session child IS the abort.
if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
c.store(true, Ordering::SeqCst);
}
+2 -4
View File
@@ -1,8 +1,7 @@
//! The Shortcuts screen: a short note on the in-stream capture model plus a reference of the
//! keyboard shortcuts — reached from the Shortcuts button on the host list. The Windows
//! counterpart of the GTK client's Keyboard Shortcuts window; the bindings themselves live in
//! the session window (and [`crate::input`] for the legacy builtin path), so both clients
//! document the same set.
//! the session window, so both clients document the same set.
use super::style::*;
use super::Screen;
@@ -10,8 +9,7 @@ use windows_reactor::*;
/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it
/// does. Read-only — the keyboard bindings live in the session window (`pf-presenter`'s run
/// loop; the legacy builtin path's in [`crate::input`]), the controller chord in its gamepad
/// service.
/// loop), the controller chord in its gamepad service.
const STREAM_SHORTCUTS: &[(&str, &str)] = &[
("F11 / Alt+Enter", "Toggle fullscreen"),
(
+49 -10
View File
@@ -189,14 +189,21 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
/// `initial` seeds the text box's displayed value and is CONSTANT for the life of the edit — the
/// field is uncontrolled, its live value kept in `live` (read at Save). Driving a *controlled* box
/// from an always-deferred `AsyncSetState` round-trip fights the caret on fast typing and can drop
/// the last char if Save is clicked before the write lands; an uncontrolled box + a ref sidesteps
/// both (and skips a full-page re-render per keystroke). See the seed block in `hosts_page`.
fn rename_editor(
draft: &str,
initial: &str,
fp: String,
live: HookRef<String>,
set_rename: AsyncSetState<Option<(String, String)>>,
) -> Element {
let commit = {
let (fp, draft, sr) = (fp.clone(), draft.to_string(), set_rename.clone());
let (fp, live, sr) = (fp.clone(), live.clone(), set_rename.clone());
move || {
let draft = live.borrow();
let name = draft.trim();
if !name.is_empty() {
let mut known = KnownHosts::load();
@@ -209,12 +216,12 @@ fn rename_editor(
}
};
let on_changed = {
let sr = set_rename.clone();
move |s: String| sr.call(Some((fp.clone(), s)))
let live = live.clone();
move |s: String| live.set(s)
};
card(
vstack((
text_box(draft)
text_box(initial)
.placeholder_text("Host name")
.on_text_changed(on_changed),
hstack((
@@ -240,6 +247,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let set_screen = &props.svc.set_screen;
let set_status = &props.svc.set_status;
let (manual, set_manual) = cx.use_state(String::new());
// The Add-host field's live value, read by Connect at click time. This page's `use_state` is
// unreliable as the click's source of truth: while the modal is open the page usually has no
// reason to re-render (you open it precisely because the host ISN'T being discovered, so no
// discovery tick fires), and the top-down reconcile skips this unchanged-props subtree — so a
// sync `set_manual` write never re-renders the Connect button to re-capture the address, and it
// would connect to the empty mount-time value. Mirror every keystroke into this stable ref (the
// pair-screen PIN pattern). `manual` still drives the text box's displayed value.
let manual_live = cx.use_ref(String::new());
// "Add host" modal open state lives in ROOT (see `HostsProps`).
let show_add = props.show_add;
let set_show_add = &props.set_show_add;
@@ -249,6 +264,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let rename = props.rename.clone();
let set_forget = &props.set_forget;
let set_rename = &props.set_rename;
// The live rename draft, read at Save time (see `rename_editor`). Root `rename` carries only the
// INITIAL name, so it no longer round-trips per keystroke. Seed the draft each time the rename
// TARGET changes (start, cancel, or a switch to another host).
let rename_draft = cx.use_ref(String::new());
let rename_seed = cx.use_ref(Option::<String>::None);
{
let active = rename.as_ref().map(|(fp, _)| fp.clone());
if *rename_seed.borrow() != active {
rename_draft.set(rename.as_ref().map(|(_, n)| n.clone()).unwrap_or_default());
rename_seed.set(active);
}
}
let hover = Hover {
current: props.hover.clone(),
set: props.set_hover.clone(),
@@ -393,8 +420,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
let (fp, draft) = rename.clone().unwrap();
tiles.push(rename_editor(&draft, fp, set_rename.clone()));
let (fp, initial) = rename.clone().unwrap();
tiles.push(rename_editor(
&initial,
fp,
rename_draft.clone(),
set_rename.clone(),
));
continue;
}
let target = Target {
@@ -595,14 +627,15 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
let connect_manual = {
let (ctx2, ss, st, text, sa) = (
let (ctx2, ss, st, live, sa) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual.clone(),
manual_live.clone(),
set_show_add.clone(),
);
move || {
let text = live.borrow();
let text = text.trim();
if text.is_empty() {
return;
@@ -640,7 +673,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
text_box(manual)
.header("Address")
.placeholder_text("192.168.1.20 or my-pc.local")
.on_text_changed(move |s| set_manual.call(s))
.on_text_changed({
let live = manual_live.clone();
move |s: String| {
live.set(s.clone());
set_manual.call(s);
}
})
.margin(edges(0.0, 6.0, 0.0, 0.0)),
hstack((
button("Connect")
+1 -1
View File
@@ -23,7 +23,7 @@ pub(crate) fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
let app_card = card(
vstack((
text_block("punktfunk").font_size(15.0).semibold(),
text_block("Punktfunk").font_size(15.0).semibold(),
text_block("Licensed under MIT OR Apache-2.0, at your option.")
.font_size(12.0)
.wrap()
+15 -42
View File
@@ -1,8 +1,8 @@
//! The WinUI 3 (windows-reactor) application shell.
//!
//! Declarative React-like model: this root component routes on a `Screen` value held in
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
//! Each screen lives in its own submodule:
//! `use_async_state` so background threads (discovery, the spawned session's stdout reader) can
//! drive navigation. Each screen lives in its own submodule:
//!
//! * [`hosts`] — saved/discovered/manual host list, plus per-host forget + speed test
//! * [`connect`] — the trust gate and session lifecycle glue (connect / request-access flows)
@@ -10,7 +10,7 @@
//! * [`speed`] — the per-host network speed test (probe burst over the real data plane)
//! * [`settings`] — persisted preferences · [`licenses`] — the license notices screen ·
//! [`help`] — the in-stream keyboard-shortcuts reference (reached from the host list)
//! * [`stream`] — the live stream: `SwapChainPanel` + D3D11 presenter + HUD overlay
//! * [`stream`] — the stream status card (the stream itself runs in the spawned session window)
//! * [`style`] — the shared look (cards, pills, monograms), following the windows-reactor
//! gallery: Mica backdrop, a centred max-width column, theme brushes (`ThemeRef`)
//!
@@ -19,9 +19,6 @@
//! marks it dirty and re-renders it; an `AsyncSetState` written from a background thread does
//! NOT (the child is pruned when its props are unchanged) — so everything thread-driven
//! (discovery, HUD stats, speed-test results) is held as *root* state and passed down as props.
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
//! present must not go through state/rerender.
mod connect;
mod help;
@@ -36,7 +33,6 @@ mod style;
use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::session::Stats;
use crate::trust::{KnownHosts, Settings};
use hosts::HostsProps;
use punktfunk_core::client::NativeClient;
@@ -45,7 +41,6 @@ use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use stream::StreamProps;
use windows_reactor::*;
#[derive(Clone, PartialEq)]
@@ -88,7 +83,7 @@ pub(crate) struct Target {
}
/// Stable app services handed to the page components as props. Each routed screen that uses
/// hooks (`hosts_page`/`pair_page`/`stream_page`/`speed_page`) is mounted as its own
/// hooks (`hosts_page`/`pair_page`/`speed_page`/`library_page`) is mounted as its own
/// `component(...)`, so its hooks live in an isolated slot list — calling them on the shared
/// parent `cx` would change the hook order whenever the screen changes (reactor's
/// Rules-of-Hooks guard aborts).
@@ -115,18 +110,12 @@ impl PartialEq for Svc {
}
}
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread):
/// the connector (input sends), the decoded-frame channel (render thread), and the session's
/// stop flag (the disconnect shortcut trips it).
/// Cross-thread shell state driven off the UI thread: the current target, the live spawned
/// session child (Disconnect/Cancel kill it) and its latest stats line, plus the connect-flow
/// cancel flag and the discovery/library/speed-test generation guards.
#[derive(Default)]
pub(crate) struct Shared {
#[allow(clippy::type_complexity)]
pub(crate) handoff:
Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx, Arc<AtomicBool>)>>,
pub(crate) target: Mutex<Target>,
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
/// by the HUD poll thread to drive the overlay.
pub(crate) stats: Mutex<Stats>,
/// The live session child (spawn mode) — the status page's Disconnect and the
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
pub(crate) session: Mutex<crate::spawn::SessionChild>,
@@ -157,14 +146,6 @@ pub struct AppCtx {
pub(crate) shared: Arc<Shared>,
}
/// The legacy in-process streaming path (SwapChainPanel + D3D11VA) instead of the
/// spawned punktfunk-session window: the `PUNKTFUNK_BUILTIN_STREAM=1` env override — a
/// developer A/B knob only (the former Settings "Streaming engine" pick is gone), removed
/// with the legacy path once the Vulkan session is fully validated.
pub(crate) fn use_builtin_stream(_ctx: &AppCtx) -> bool {
std::env::var_os("PUNKTFUNK_BUILTIN_STREAM").is_some_and(|v| v == "1")
}
pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
let ctx = Arc::new(AppCtx {
identity,
@@ -302,10 +283,9 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
}
});
// HUD sample: the session event loop writes `shared.stats` and the input hooks track capture
// state; this poll thread mirrors both into root state so the stream page gets them as a
// *prop* (thread-driven state must be root state — see the module docs). The compare in
// `AsyncSetState::call` makes the idle case free.
// HUD sample: the spawned session child's latest `stats:` line, mirrored into root state so
// the stream status page gets it as a *prop* (thread-driven state must be root state — see the
// module docs). The compare in `AsyncSetState::call` makes the idle case free.
cx.use_effect((), {
let shared = ctx.shared.clone();
let set_hud = set_hud.clone();
@@ -315,10 +295,6 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
.spawn(move || loop {
std::thread::sleep(std::time::Duration::from_millis(400));
set_hud.call(stream::HudSample {
stats: *shared.stats.lock().unwrap(),
captured: crate::input::is_captured(),
visible: crate::input::hud_visible(),
present: crate::render::present_stats(),
stats_line: shared.stats_line.lock().unwrap().clone(),
});
})
@@ -525,16 +501,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
state: library,
},
),
// Spawn mode (the default): the stream runs in the punktfunk-session child's own
// window; this screen is a status page (no hooks — inline is sound). The legacy
// in-process SwapChainPanel page stays behind the "Streaming engine" setting /
// PUNKTFUNK_BUILTIN_STREAM=1.
Screen::Stream if !use_builtin_stream(ctx) => stream::session_page(ctx, &hud),
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
// The stream runs in the punktfunk-session child's own window; this screen is a
// status page (no hooks — inline is sound).
Screen::Stream => stream::session_page(ctx, &hud),
};
// The Stream screen owns the SwapChainPanel + per-frame present; never wrap it in an animated
// opacity/offset layer. Everything else slides + fades in on navigation.
// The Stream screen is a plain status card (the session child owns the real stream window);
// it's shown without the navigation entrance tween. Everything else slides + fades in.
if matches!(screen, Screen::Stream) {
return body;
}
+20 -4
View File
@@ -14,21 +14,28 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
let set_screen = &props.set_screen;
let set_status = &props.set_status;
let (code, set_code) = cx.use_state(String::new());
// The PIN's live value, read directly by the click handler. This page's props (`Svc`) never
// change, and root wraps every screen in an animated `border` that compares equal once the
// entrance tween settles — so the top-down reconcile `can_skip_update`s this subtree and never
// re-renders the pair component off its *local* `use_state`. A button rebuilt only at mount
// would forever capture the empty mount-time PIN (pairing then fails as a "wrong PIN"). Mirror
// every keystroke into this stable ref instead, so the click reads exactly what was typed.
let live_pin = cx.use_ref(String::new());
let target = ctx.shared.target.lock().unwrap().clone();
let pair_btn = {
let (ctx2, ss, st, code2, target2) = (
let (ctx2, ss, st, live, target2) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
code.clone(),
live_pin.clone(),
target.clone(),
);
button("Pair & Connect")
.accent()
.icon(Symbol::Accept)
.on_click(move || {
let pin = code2.trim().to_string();
let pin = live.borrow().trim().to_string();
let (ctx3, ss, st, target3) =
(ctx2.clone(), ss.clone(), st.clone(), target2.clone());
std::thread::spawn(move || {
@@ -109,7 +116,16 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
text_box(code)
.placeholder_text("PIN")
.font_size(28.0)
.on_text_changed(move |s| set_code.call(s)),
.on_text_changed({
let live = live_pin.clone();
move |s: String| {
// Record the live value for the click handler (the source of truth for the
// PIN), and mirror it into `code` so the field stays correct if anything ever
// does re-render this page (theme/DPI change).
live.set(s.clone());
set_code.call(s);
}
}),
hstack((pair_btn, cancel_btn)).spacing(8.0),
text_block(
"Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
+44 -21
View File
@@ -136,29 +136,37 @@ pub(crate) fn settings_page(
let s = ctx.settings.lock().unwrap().clone();
// --- Display ---------------------------------------------------------------------------
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
// `match_window` flag), then the explicit sizes.
let (res_names, res_i) = {
let names: Vec<String> = RESOLUTIONS
let names: Vec<String> = std::iter::once("Native display".to_string())
.chain(std::iter::once("Match window".to_string()))
.chain(
RESOLUTIONS
.iter()
.map(|&(w, h)| {
if w == 0 {
"Native display".into()
} else {
format!("{w} \u{00D7} {h}")
}
})
.skip(1)
.map(|&(w, h)| format!("{w} \u{00D7} {h}")),
)
.collect();
let i = RESOLUTIONS
let i = if s.match_window {
1
} else {
RESOLUTIONS
.iter()
.position(|&(w, h)| w == s.width && h == s.height)
.unwrap_or(0);
.map(|i| if i == 0 { 0 } else { i + 1 })
.unwrap_or(0)
};
(names, i)
};
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
(s.width, s.height) = RESOLUTIONS[i];
s.match_window = i == 1;
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
})
.tooltip(
"The host creates a virtual display at exactly this size. \u{201C}Native display\u{201D} \
resolves to the monitor this window is on at connect.",
resolves to the monitor this window is on at connect; \u{201C}Match window\u{201D} \
follows the stream window, including mid-stream resizes.",
);
let (hz_names, hz_i) = {
let names: Vec<String> = REFRESH
@@ -259,12 +267,13 @@ pub(crate) fn settings_page(
);
// --- Input -----------------------------------------------------------------------------
// Which physical controller forwards as pad 0: automatic = the most recently connected.
// Persisted by stable key (`Settings::forward_pad`, GTK parity) so the pin survives
// restarts AND reaches the spawned session binary, whose service applies the same key.
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
// pinning one restricts the session to that single controller (single-player). Persisted
// by stable key (`Settings::forward_pad`, GTK parity) so the pin survives restarts AND
// reaches the spawned session binary, whose service applies the same key.
let pads = ctx.gamepad.pads();
let (fwd_names, fwd_i) = {
let mut names = vec!["Automatic (most recent)".to_string()];
let mut names = vec!["Automatic (all controllers)".to_string()];
names.extend(pads.iter().map(|p| {
let kind = p.kind_label();
if kind.is_empty() {
@@ -293,16 +302,16 @@ pub(crate) fn settings_page(
} else {
keys.get(sel - 1).cloned()
};
// Apply live (the in-process service, legacy builtin streams) and persist —
// the spawned session reads `forward_pad` at connect.
// Apply live to the gamepad service and persist — the spawned session
// reads `forward_pad` at connect.
svc.set_pinned(key.clone());
let mut s = ctx2.settings.lock().unwrap();
s.forward_pad = key.unwrap_or_default();
s.save();
})
.tooltip(
"Exactly one controller is forwarded to the host; \u{201C}Automatic\u{201D} \
picks the most recently connected.",
"Every connected controller is forwarded, each as its own player. Pick one \
to force single-player \u{2014} only it reaches the host.",
)
};
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
@@ -361,6 +370,16 @@ pub(crate) fn settings_page(
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} pick a game and it \
launches in the stream. Mirrors the Apple client's toggle.",
);
// App identity + version at the top of the About card (the WinUI Settings convention; the About
// screen previously showed no version at all). CARGO_PKG_VERSION is the workspace version, baked
// in at compile time.
let about_identity = vstack((
text_block("Punktfunk").font_size(20.0).semibold(),
text_block(concat!("Version ", env!("CARGO_PKG_VERSION")))
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
))
.spacing(2.0);
// The selected section's content — per-control guidance lives on hover tooltips, so the
// card is just the controls.
@@ -395,7 +414,11 @@ pub(crate) fn settings_page(
),
"about" => (
"About",
settings_card(vec![library_toggle.into(), licenses_button.into()]),
settings_card(vec![
about_identity.into(),
library_toggle.into(),
licenses_button.into(),
]),
),
_ => (
"Display",
+1 -1
View File
@@ -4,7 +4,7 @@
use super::style::*;
use super::{Screen, Svc};
use crate::session::run_speed_probe;
use crate::probe::run_speed_probe;
use windows_reactor::*;
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via

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