Compare commits

..
Author SHA1 Message Date
enricobuehler e2239fd964 Merge remote-tracking branch 'origin/main' into worktree-android-aaudio-shield-silence
ci / web (pull_request) Successful in 1m6s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / bun-nix (pull_request) Successful in 1m16s
ci / docs-site (pull_request) Successful in 1m56s
ci / rust (pull_request) Successful in 5m29s
android / android (pull_request) Successful in 7m31s
# Conflicts:
#	CHANGELOG.md
2026-08-14 08:07:00 +02:00
enricobuehler f17fe1fc2b Merge pull request 'Unpair every device from one button, over a collection DELETE per plane' (#213) from worktree-unpair-all-button into main
arch / build-publish (push) Failing after 1m7s
ci / docs-site (push) Successful in 1m21s
ci / bun-nix (push) Successful in 4m16s
ci / rust-arm64 (push) Successful in 4m43s
ci / rust (push) Successful in 5m49s
ci / web (push) Successful in 5m48s
deb / build-publish-gamescope (push) Failing after 1m33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
deb / build-publish-host (push) Successful in 4m42s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 30s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 36s
android / android (push) Successful in 8m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m29s
docker / builders-arm64cross (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 4m25s
deb / build-publish (push) Failing after 8m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4m57s
deb / smoke-install (push) Successful in 2m36s
windows-host / package (push) Successful in 16m54s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
docker / deploy-docs (push) Successful in 6m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m28s
2026-08-13 22:53:06 +00:00
enricobuehler a3a6444e6e feat(host,web): unpair every device from one button, over a collection DELETE per plane
ci / bun-nix (pull_request) Successful in 23s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 5m59s
ci / web (pull_request) Successful in 6m25s
android / android (pull_request) Successful in 4m20s
ci / rust (pull_request) Successful in 16m23s
Clearing a host's trust store meant clicking the row trash icon once per
device and confirming each time — tedious with a handful of clients, and
easy to leave half-done.

The "Paired devices" card header now carries an "Unpair all" action behind
a single confirmation. It is backed by two new endpoints rather than a loop
over the per-fingerprint deletes:

    DELETE /api/v1/clients          -> {"unpaired": N}
    DELETE /api/v1/native/clients   -> {"unpaired": N}

one per pairing plane, because the two planes own separate trust stores
with separate persistence and separate revocation duties. Each empties its
store in ONE persisted write. Doing it as N deletes would rewrite (and
atomically rename) the store once per client, and a failure partway would
leave the operator with a half-emptied store and no way to tell which half.

They are collection deletes, so they carry the single delete's revocation
guarantees across the whole set: a live session owned by any removed
certificate is ended, and on the Moonlight side the ENet control port
closes, because no pairing is left to hold it open.

200 with a count rather than the single delete's 204/404: "unpair
everything" is idempotent, an already-empty store satisfies it, and the
count still tells the operator whether that meant three devices or none.

Both gates match on (method, path), so the roster's plugin-readable GET
does not carry over to emptying it — both new routes are admin-token only,
like every other pairing-administration route, with explicit rows in the
route-classification table.

The console calls only the planes that actually have a row: the native
endpoint answers 503 on a host built without that plane, which would
otherwise report a failure for devices that were never there.
2026-08-14 00:35:33 +02:00
enricobuehler 38631d43df fix(client/android): the audio plane trusted AAudio, so a TV that opened a dead stream was silent all session
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m13s
android / android (pull_request) Canceled after 1m41s
ci / rust (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m41s
ci / rust-arm64 (pull_request) Canceled after 1m43s
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.

This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that is why this was
Android-only. Opening AAudio is a negotiation with a vendor HAL and this
plane treated it as a formality: one Exclusive attempt, one Shared retry,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:

  - a configuration that opens but routes nowhere — nothing ever checked
    that the device pulled a single sample, so the decode thread fed Opus
    into a dead stream indefinitely;
  - request_start failing — we gave up on the spot rather than trying
    anything else, so one unhappy config disabled audio for the session;
  - a disconnect — by AAudio's contract the stream is then DEAD and the
    only recovery is close + open a new one, but the error callback logged
    a warning and did nothing. On a TV that is not rare: this client drives
    an HDMI mode switch on the video plane, and the platform's own
    match-content-frame-rate setting drives more.

The open now walks a ladder, every rung must prove the device is pulling
before it is accepted, and a supervisor owns the plane for the session and
reopens it when the device goes away — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked rather
than assumed: the realtime callback casts AAudio's buffer to f32 and writes
num_frames * channels of them, so a HAL that disagreed was an out-of-bounds
write on the audio thread, not just a mistuning.

TV boxes now start at Shared. Exclusive is MMAP, the lowest-latency path
AAudio has and the one rung whose routing cannot be verified from inside
the process; the latency it buys was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.

nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.

debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. A stream that stops taking samples after it started now says
so at error level instead of looking exactly like an app with no sound.

Not verified on a Shield — no such device here.
2026-08-14 00:15:48 +02:00
enricobuehler 4caf2b76e8 Merge pull request 'gamescope +pfhdr7 — linger no longer dies of its own capture teardown (luxus's fix, overlay#9)' (#212) from worktree-gamescope-linger-pw-destroy-race into main
ci / web (push) Successful in 1m15s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
ci / rust (push) Successful in 7m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
ci / rust-arm64 (push) Successful in 4m37s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1m29s
ci / bun-nix (push) Successful in 4m9s
docker / deploy-docs (push) Successful in 33s
arch / build-publish (push) Successful in 10m38s
docker / builders-arm64cross (push) Successful in 1m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m28s
Reviewed-on: #212
2026-08-13 22:15:40 +00:00
enricobuehler 0f64551c56 fix(packaging/gamescope): +pfhdr7 — linger no longer dies of its own capture teardown
ci / rust-arm64 (pull_request) Successful in 2m5s
ci / bun-nix (pull_request) Successful in 5m28s
ci / web (pull_request) Successful in 7m2s
ci / docs-site (pull_request) Successful in 8m0s
ci / rust (pull_request) Successful in 16m28s
Patch 0009, reported, written and proven live by luxus (punktfunk-overlay#9): when the capture
consumer leaves, stream_handle_remove_buffer — and the stale-push path in dispatch_nudge —
destroyed idle buffers on the PipeWire thread. Dropping the last CVulkanTexture reference there
calls into the Vulkan driver (vkDestroyImage / FreeMemory / dmabuf fds) while steamcompmgr can
still be inside vulkan_screenshot on another buffer of the same 4-buffer pool; on NVIDIA the race
lands as a SIGSEGV in CVulkanCmdBuffer::insertBarrier. The timing is what made it selectively
lethal: it fires at stream END — exactly the window where the host keeps the headless display
lingering for a reconnect. So the kept display was already dead (journal: linger line → coredump →
"kept display was dead — recreating") and the "resumed" session was a fresh compositor with the
game lost.

The fix queues the corpses (bury_buffer, mutex-guarded) and steamcompmgr reaps them on every
vblank, including while the stream is only paused — the linger state itself. Field-proven on the
reporter's NVIDIA host: 4 coredumps in one evening of BG3 at 4K60 HDR with --pipewire-composite-
cursor (the heaviest paint path we ship), zero after; disconnect/reconnect confirmed live to reuse
the lingered session (2026-08-13). Three of the four stacks are this race; the fourth
(~CVulkanDevice during exit) is patch 0006's already-fixed static-destruction bug — do not
re-diagnose it as part of this.

Ours differs from the overlay's original only by the meson.build banner hunk: +pfhdr6 → +pfhdr7,
PKGBUILD 3.16.25.pfhdr7-1. No new capability — same rule as pfhdr5/6: "reconnect lost my game"
triage has to read a box's exposure off its banner, and every probe is >=. Known residual,
deliberately untouched: add_buffer's error path still deletes on the PW thread. By the later
`goto error`s a texture may be attached, so the race is reachable there in theory — but only when
an add FAILS mid-renegotiation, which no field coredump shows; the patch stays byte-identical with
what was proven on-glass.

Verified: the full 0001..0009 series applies onto the bare 5fb8dce4 pin with plain `git am` (the
build script's own invocation, no -3, no fuzz) and with `git am -3`; the fc44 CI image
(punktfunk-fedora44-rpm) builds the result with rpm.yml's exact dep recipe to a binary whose
banner reads `3.16.25-20-g40fe8b5+pfhdr7 (gcc 16.1.1)`. After 0009, destroy_buffer has exactly
two callers left —
pipewire_reap_dead_buffers (steamcompmgr vblank) and pipewire_destroy_buffer (steamcompmgr's
copy-completion path) — both on the compositor thread. Nix, deb, sysext and rpm all glob
patches/*.patch and read the level off the banner, so no other packaging file moves.
2026-08-14 00:03:17 +02:00
enricobuehler 8fe5acf7f2 Merge pull request 'The OSD stage line stays a partition — an async decode figure is not one of its terms' (#211) from worktree-decode-stat-overlap into main
android / android (push) Failing after 1m54s
ci / docs-site (push) Successful in 1m32s
deb / build-publish (push) Successful in 3m38s
ci / rust-arm64 (push) Successful in 7m6s
ci / web (push) Successful in 6m49s
deb / build-publish-gamescope (push) Failing after 1m33s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / bun-nix (push) Successful in 6m10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
arch / build-publish (push) Successful in 10m47s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m57s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 1m57s
deb / build-publish-client-arm64 (push) Successful in 3m27s
deb / build-publish-host (push) Successful in 8m5s
docker / builders-arm64cross (push) Successful in 47s
ci / rust (push) Canceled after 15m18s
docker / deploy-docs (push) Canceled after 3m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
deb / smoke-install (push) Successful in 4m34s
flatpak / build-publish (push) Successful in 11m50s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 11m16s
Reviewed-on: #211
2026-08-13 21:56:44 +00:00
enricobuehler fb33555059 Merge pull request 'HDR was a stop bright, in-game HDR was unreachable on every distro, and SDR shipped untagged' (#210) from worktree-hdr-colour-anchor-fixes into main
arch / build-publish (push) Failing after 6s
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m1s
deb / build-publish-gamescope (push) Failing after 1m48s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
apple / swift (push) Successful in 2m5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 5m37s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
android / android (push) Canceled after 6m33s
ci / rust (push) Canceled after 6m33s
ci / docs-site (push) Canceled after 6m29s
deb / build-publish (push) Canceled after 6m29s
deb / smoke-install (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m16s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 16s
nix / flake (push) Failing after 2m20s
windows-host / package (push) Successful in 12m56s
windows-host / winget-source (push) Skipped
apple / distribute (push) Successful in 11m11s
windows-host / canary-manifest (push) Successful in 41s
apple / screenshots (push) Successful in 6m56s
Reviewed-on: #210
2026-08-13 21:50:11 +00:00
enricobuehler 81022bcc80 fix(client/stats): keep the stage line a partition — an async decode figure is not one of its terms
ci / web (pull_request) Successful in 2m11s
ci / bun-nix (pull_request) Successful in 3m25s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 6m20s
ci / docs-site (pull_request) Successful in 5m37s
ci / rust (pull_request) Successful in 6m40s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m49s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m34s
A 2026-08-13 field report read the OSD's stage line as a breakdown of e2e and
asked why the parts did not add up: `host 5.4 · net 0.3 · decode 6.6 ·
display 1.4` against `e2e 8.1/9.1`. Fair question, and the numbers are all
individually true. They add up without `decode`: 5.4 + 0.3 + 1.4 ≈ 8.1.

The stages ARE a per-frame partition of e2e — pts →(host+net)→ received
→(decode)→ decoded →(display)→ displayed — and that holds for as long as the
`decoded` stamp is a COMPLETION stamp. On the synchronous rungs it is. On the
native-Vulkan rung `receive_frame` returns at SUBMISSION (~0.1 ms) and the
stamp shipped to the presenter is taken there, so `display` is measured from
submit and the GPU decode happens INSIDE it. `host+net` and `display` already
tile e2e between them; the `decode` figure, measured received → fence-complete,
re-counts the GPU work `display` contains. Two figures, one overlap, printed
side by side as though they tiled.

So on that rung `decode` leaves the stage line and gets its own, carrying the
two caveats a reader needs before the number means anything: it is ONE sample
per window there, not the p50 every other figure on that line is, and it is
already inside `display` so adding it double-counts. The synchronous rungs are
untouched — `decode` is a real term there and stays inline.

Deliberately NOT changed: the one-sample-per-window design. `pf_client_core::
session` argues it at length — a per-frame fence wait serialises the decode
pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps), and M4
already re-examined and rejected polling, which quantises every sample up by a
frame interval (8.3 ms at 120 Hz against decodes of ~0.1-2 ms). That reasoning
still holds; the reporting around it was the defect. Making `decode` a genuine
per-frame term would need a completion stamp off the hot path — a waiter thread
on the timeline, which that comment already names as the remaining option — and
is a bigger change than this one.

Also not answered here: why the sampled frame read 6.6 ms when the sampling
comment expects 0.1-2 ms. It is a tail frame by construction (a frame that took
6.6 ms to decode also took ≥ 6.6 ms to display, against a 1.4 ms display p50),
but whether the first frame of a window is SYSTEMATICALLY a tail frame needs
instrumenting rather than guessing.

Verified in the linux/amd64 container: pf-presenter 47/47 (incl. the new case,
which pins both shapes and the timed-out-window zero), pf-client-core 188/188,
`clippy --all-targets -D warnings` clean on both, fmt clean. The pf-client-core
leg was proven non-vacuous with a planted compile_error! first.
2026-08-13 23:49:41 +02:00
enricobuehler 1b167f8e35 Merge pull request 'The host tile gets a menu, and the start-of-stream banner becomes an About tab' (#209) from worktree-gamepad-ui-host-mgmt-about into main
ci / bun-nix (push) Successful in 24s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 26s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m16s
apple / swift (push) Successful in 2m2s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
ci / web (push) Successful in 3m13s
docker / builders-arm64cross (push) Successful in 1m48s
docker / deploy-docs (push) Successful in 31s
ci / docs-site (push) Successful in 3m24s
apple / distribute (push) Canceled after 1m47s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 3m50s
ci / rust-arm64 (push) Canceled after 3m49s
2026-08-13 21:46:19 +00:00
enricobuehler 082c65755f fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 3m29s
ci / docs-site (pull_request) Successful in 3m53s
android / android (pull_request) Successful in 5m4s
ci / rust (pull_request) Successful in 5m9s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
nix / flake (pull_request) Failing after 6m17s
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.

The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.

Channels, all of them:
  rpm     spec gains Source1/Source2 and %files entries
  deb     build-gamescope-deb.sh copies the layer into the package root
  Arch    PKGBUILD (previous commit); the sysext extracts the whole usr tree
  sysext  bazzite takes --gamescope-stage; arch asserts the layer arrived
  nix     the derivation keeps, renames and rewrites the layer rather than
          deleting it with everything else

A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.

Two things needed care:

The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.

NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.

The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.

Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
2026-08-13 23:37:48 +02:00
enricobuehler 9c2c8d1643 fix(apple/about): drop the identity card for a version line under the rows
ci / bun-nix (pull_request) Successful in 42s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 13m50s
apple / swift (pull_request) Successful in 2m0s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
The card led the About tab with the app icon, and on tvOS that icon is a 400x240
rectangle meeting a layout built for square art. Three passes at framing it —
aspect-correct frame, then dropping the zero-radius clip that was cropping it,
then a max frame so it could shrink instead of overflow — and it was still cut
off on real hardware.

So the card goes. A version string answers the only question anyone opens About
to ask, it has no aspect ratio to get wrong, and it belongs under the rows rather
than over them: quiet and centred, reading as a footer instead of a row you
failed to press. `Row.Kind.footer` draws it.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
on glass on an Apple TV as 0.29.0 (100004).
2026-08-13 23:28:04 +02:00
enricobuehler c591b7b4af Merge pull request 'The Apple HUD froze its clock offset at connect, so every host-anchored number lied — and the tvOS present floor is closed on sound evidence now' (#208) from worktree-appletv-present-depth into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 47s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 46s
ci / bun-nix (push) Successful in 56s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / web (push) Successful in 1m30s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 1m38s
ci / rust-arm64 (push) Successful in 1m39s
apple / swift (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
ci / rust (push) Successful in 4m49s
docker / deploy-docs (push) Failing after 6m59s
apple / distribute (push) Successful in 10m26s
apple / screenshots (push) Successful in 6m45s
2026-08-13 21:21:40 +00:00
enricobuehler 3ac4548cf8 fix(gamescope): ship the WSI layer built beside our compositor, instead of guessing at the distro's
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and
from nothing else -- gamescope advertises no runtime colour-management protocol a
Mesa/NVIDIA WSI could negotiate through. That layer talks `gamescope_swapchain`
to the compositor, and when the two disagree the compositor rejects the client's
swapchain_feedback and every Vulkan client dies on a black screen with sound and
input and no error anywhere.

We ship our own compositor and did NOT ship a layer, on the recorded grounds that
the layer is "version-independent of the compositor binary". It is not, and
wsi_layer_matches_our_gamescope() exists because it is not. So the host was left
guessing from version triples, and that guess is wrong in both directions: a
distro at the same upstream tag that patched the protocol compares EQUAL and
keeps a layer that will kill every game, while a distro at a different tag with a
byte-identical protocol compares unequal and loses HDR for nothing. Since we pin
a rev, the second case is the normal one -- on essentially every box with a
distro gamescope, the layer was disabled and no game could render HDR.

Ship the layer instead. It is built from the same tree at the same rev as the
compositor, so the two cannot drift, and the guess stops being load-bearing. It
is installed under our own name (VK_LAYER_PUNKTFUNK_gamescope_wsi) at our own
path with our own enable/disable variables, so it coexists with the distro's
rather than colliding -- the Vulkan loader keys implicit layers on that name --
and the host switches the two independently in one session.

WsiPlan makes the three states explicit and resolves them once per launch, since
the fallback spawns `--version` probes:
  Ours            our layer is installed: enable it, force the distro's off
  DistroKept      no layer of ours, distro's looks compatible: touch nothing
  DistroDisabled  no layer of ours, distro's untrusted: today's behaviour

That last arm is the fail-safe. A host newer than its gamescope package behaves
exactly as it does today rather than enabling a layer that is not there, so this
can roll out one packaging surface at a time without a flag day.

Only the Arch PKGBUILD carries the new files so far. The rpm path takes a
CI-cached binary rather than the build script's stage dir, so it needs the cache,
build-gamescope-rpm.sh and the spec moved together; the deb, both sysexts and
gamescope.nix need the same two files added. Until each lands, those boxes take
the DistroDisabled arm and are no worse off than before.

Verified: 214 pf-vdisplay tests pass in a linux container (including a new one
pinning that the Ours arm enables ours AND forces the distro's off together --
either half alone is a bug), clippy -D warnings and rustfmt clean, both shell
files pass bash -n, and the manifest rewrite was run against a synthetic FROG
manifest to confirm it renames/repoints/regates while preserving the `functions`
block. NOT verified: an actual gamescope build, any package build, or a game
taking an HDR swapchain on glass.
2026-08-13 23:18:02 +02:00
enricobuehler 90d13de81e fix(apple): the stats overlay lied three ways — a frozen clock offset, silently trimmed impossible samples, and Int -1 fallbacks printing as NaN
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m47s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 2m57s
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / rust (pull_request) Successful in 4m20s
Field 2026-08-13, Apple TV vs Bazzite VM host, two sessions minutes apart on
the same wire: hostnet_p50 read 17-21 ms, then a physically impossible
4.4 ms (host-side encode alone is ~4.7). Root causes, each its own defect:

- The client consumed the CONNECT-TIME skew offset and froze it: cached in
  a Stage2Pipeline field, in a StreamPump let, and in a ContentView closure
  CAPTURE LIST feeding the hostnet meter and the host/network splitter.
  The core keeps a live estimate (punktfunk_connection_clock_offset_now_ns,
  ABI v10, re-synced every 60 s + on suspected wall-clock steps) and its
  own doc says the connect-time value 'silently corrupts every
  capture-clock comparison' after an NTP step — a VM host steps. Now
  PunktfunkConnection.clockOffsetNs IS the live read (an atomic load
  behind the FFI) and every consumer reads it at use: per record, per AU,
  per enqueue. The Swift audio plane's AvSync observation gets the live
  value through the same property.

- LatencyMeter's impossible-sample guard (≤ 0 after offset correction)
  dropped samples SILENTLY, so a wrong offset didn't invalidate a window —
  it trimmed the impossible half of the shifted distribution and presented
  the surviving tail as a plausible small number ('e2e 0-3 ms p50 /
  23 ms p95' on a session whose true hostnet was ~18 ms; also the
  historical '0 ms network / 0 ms e2e' readings). The refusals are now
  counted and drained separately from Stats — deliberately, because a
  fully-poisoned window drains to nil and a count inside Stats would
  vanish with it. The HUD shows an orange 'clock offset suspect' line and
  the stats line grew skew_trim=N; nonzero means disregard e2e/hostnet.

- Every invalid-field fallback in the 1 Hz stats line was a bare -1: in
  the variadic CVarArg context the ternary does NOT unify to Double, the
  literal goes in as Int, and %f reads Int64(-1)'s all-ones bit pattern —
  which is a quiet NaN. Latent since the line existed; stage-1 (the first
  rung with invalid fields while frames flow) printed nan for every one.
  All fallbacks are now typed -1.0 / Double()-wrapped.
2026-08-13 23:15:30 +02:00
enricobuehler 1abf5c91b9 feat(apple): the tvOS present-floor verdict rested on a property readback, so open the two untested doors
The 2026-08-13 field ladder closed 'the tvOS two-refresh present floor is
immovable' on the strength of a 'link granted latency 1.00 frames' HUD line.
But that line reads back preferredFrameLatency — a plain read-write float
(CAMetalDisplayLink.h carries no doc contract) that echoes whatever we
stored. A readback is not a grant; the measured vend lead (1.95 refresh
periods) was the only truth-teller, and two levers were never actually
pulled. This commit also carries the ladder instrumentation that run used:
the 1 Hz stats mirror to stdout (the only log channel that exists on an
Apple TV), the PresentLinkInfo HUD plumbing, the stage-4 drawable-pool
clamp to 2, and the tvOS fixed-rate range pin.

- PUNKTFUNK_FRAME_LATENCY makes the ask a lever (float 0...4, default 1) so
  an on-device ladder can prove whether the property does ANYTHING on tvOS:
  ask=2 growing the vend lead to ~3 means it works and the floor is ~ask+1;
  a lead pinned at ~2 means it is inert and the compositor regime is fixed.
  ask=0.5 is the in-regime win probe (the property is a float for a reason).
  Ask + readback go to the HUD line and the stats line (link_ask/
  link_readback) so the ladder reads HUD-off over stdout.

- PUNKTFUNK_PRESENTER=stage1 now resolves on Release builds (env only; the
  persisted picker stays DEBUG-gated — an env var is never a leftover, it
  takes a devicectl/Xcode launch to exist). Stage-1 presents on the hardware
  video plane (AVSampleBufferDisplayLayer + DisplayImmediately) instead of
  through the GPU compositor — the only rung that can dodge the two-refresh
  regime — and the field A/B silently ran stage-4 because the gate keyed on
  build config. The pump gains stage-1's only latency instrument:
  capture→enqueue into the e2e meter (offset-corrected, displayed frames
  only), so cross-rung runs can pin any felt difference on the present tail.
2026-08-13 23:15:30 +02:00
enricobuehler 6fd5769b3b fix(apple/about): a zero-radius clip is still a clip, and it cropped the TV's wide icon
`cornerRadius: 0` reads as "no rounding", but a RoundedRectangle clip is not a
no-op at zero — it still clips to the layout frame, so any art whose aspect ratio
isn't the frame's loses its ends. The TV's 400x240 icon did exactly that as soon
as there was a real icon to draw instead of the square monogram. The mask now
applies only where it is wanted: iOS, whose icon ships unmasked because the
springboard rounds it at draw time.

The frame goes from fixed to MAX for the same failure one step further out: at a
fixed width the image cannot shrink when its row is tight, so it overflows and is
cropped by whatever is above it. `.fit` inside a max frame gives the whole icon
back, just smaller. And the icon takes layout priority in the identity card — the
tagline beside it is happy to wrap, and a 5:3 icon is what suffers first if the
text is given the width it asks for.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
2026-08-13 23:10:34 +02:00
enricobuehler ed8c080603 fix(apple/about): the identity card ignored the row column, and the TV had no icon to show
Both found on glass on an Apple TV.

The card was laid out against the SCREEN while everything under it is laid out
against a centred column of `rowMaxWidth` — 920pt against a 1920-wide TV. So it
began a few hundred points to the left of every row it introduced and read as a
separate banner rather than the head of the list. It now takes the same column
and the same inner inset as a row's contents, so the icon sits directly above
the row icons.

And it was drawing the "P" monogram, never the app's mark. That fallback exists
because tvOS ships its icon as a parallax image STACK (Back/Circle1/Circle2/
Front) with no single image to load, so `AppIconView.bundleIcon` returned nil
there and always had. `AboutAppIcon` is those four layers flattened into one
asset, generated from the SAME art the stack uses so the two cannot drift into
being subtly different icons. A TV icon is a 400x240 rectangle rather than a
squircle, so `side` means HEIGHT on tvOS and the width follows the real 5:3 art
— framed square it would have sat in a box two thirds empty.

Verified the asset actually survives compilation (`assetutil` finds AboutAppIcon
in the built Assets.car at both scales) — a missing imageset would silently fall
back to the monogram again, which is exactly the bug being fixed.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0.
2026-08-13 23:04:29 +02:00
enricobuehler a190fdb1ae fix(apple/tvos): an SDR stream forced the TV into HDR10 output
applyDisplayCriteriaIfNeeded builds a synthetic format description hardcoding
BT.2020 primaries, ST.2084 PQ and the BT.2020 matrix, then hands it to
AVDisplayManager to pick a display mode. Its guard checked only that no criteria
had been set yet and that the user's HDR setting was on -- never that the stream
itself was HDR. Since that setting defaults to true, an ordinary SDR session
drove an HDR-capable TV into PQ output.

That is a standard way to raise the black floor: the Apple TV switches HDMI to
limited-range levels in its HDR modes, and a set configured for full range then
renders code 16 as grey rather than black.

Now gated on connection.isHDR as well. Layout re-runs this, so a session that
flips to HDR mid-stream still picks the mode up on the next pass.

This was NOT the cause of the 2026-08-13 grey-blacks report -- that one had the
client's HDR setting off, so this path never ran (see the SDR layer tagging in
the previous commit for the mechanism that did apply). It is a real bug on its
own, found while investigating it.

Verified: full tvOS compile clean against the AppleTVOS SDK. Not yet verified on
glass.
2026-08-13 22:59:38 +02:00
enricobuehler 94f049ba70 fix(apple): the SDR layer shipped untagged, because an SDR session never configured colour
configure(hdr:) guards on hdr != hdrActive, and hdrActive starts false. A
session that is SDR from its first frame therefore matched the initial state and
fell straight through the guard, so configureColor never ran even once and the
layer kept make()'s bare configuration -- which never assigns a colour space.

An untagged CAMetalLayer gets no colour matching: the BT.709-encoded stream is
drawn in the display's native space. That is mild oversaturation on a P3 Mac or
iPad, and on a tvOS display composited for HDR it also lifts the black floor.
It matches a field report of greys where blacks should be, which arrived with
the client's own HDR switch already OFF -- so nothing else in the pipeline had
tagged those pixels either.

It also meant PUNKTFUNK_SDR_COLORSPACE was dead code on exactly the sessions it
existed to fix: an operator A/B-ing it in the field would have seen no change at
all, because the assignment it feeds was never executed.

So: configureColor now runs once regardless (same-state calls after the first
are still no-ops), and tagging is the default rather than opt-in, since drawing
a BT.709 stream in the panel's native space is not a rendering anyone asked for.
PUNKTFUNK_SDR_COLORSPACE=none restores the untagged look as the A/B lever.

The tvOS HDR tone-map branch gets the same tag -- pf_frag_hdr_tv outputs BT.709,
so it is an SDR layer by the time it is presented.

Verified: full tvOS compile of every PunktfunkKit source clean against the
AppleTVOS SDK. (The build's link step fails on three ABI symbols missing from
the checked-in PunktfunkCore.xcframework, which predates them -- pre-existing,
main fails identically, unrelated to these files.) Not yet verified on glass.
2026-08-13 22:59:36 +02:00
enricobuehler c29e720324 fix(gamescope): HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
Field report 2026-08-13, Bazzite host in gaming mode to an iPad: Steam's Big
Picture UI looked glaring and over-saturated while HDR game content looked
washed out, both on the same stream.

Those are one error. gamescope maps everything that is not an HDR game -- the
desktop, the Steam overlay, an SDR title -- into the session's PQ container at
--hdr-sdr-content-nits, and we only passed that flag when an operator had set
PUNKTFUNK_GAMESCOPE_SDR_NITS. Unset, gamescope used its own default of 400,
while every first-party client anchors diffuse white at 203 (BT.2408 reference
white; the Apple presenter hands exactly that to CAEDRMetadata.hdr10's
opticalOutputScale). The two ends were nearly a stop apart, so the UI landed
above SDR white and the client's tone-mapper worked from a reference point the
host had never used, flattening the content around it.

The flag is now always passed, defaulting to 203. The knob still overrides it
for anyone who wants a brighter or dimmer desktop.

Separately, and visible in the same log: the two HDR decisions in a gamescope
session are made independently. hdr_args() never consults
wsi_layer_matches_our_gamescope(), so when the WSI-layer version check fires --
which it does on essentially every Bazzite box, since we pin our own gamescope
rev and the check compares version triples -- the session launches advertising
HDR while having made an HDR10 swapchain unreachable for every game in it. That
layer is the only route to one, so a title told to render HDR renders it into an
SDR swapchain and looks washed out, with nothing anywhere saying why. It now
warns. The behaviour of the check itself is deliberately left alone: re-enabling
a genuinely mismatched layer black-screens every Vulkan client, which is worse
than losing HDR, and that trade needs a real box to retest.

Verified: scripts/xcheck.sh linux clippy clean on pf-vdisplay (-D warnings),
rustfmt clean. Not yet verified on glass.
2026-08-13 22:59:30 +02:00
enricobuehler e057bd60f4 refactor(apple/gamepad-ui): About is its own section, not the last row of Interface
Reachable, but wrong: About sat at the bottom of the Interface tab, under the
palette and the overlay position — a page about the app filed among the settings
that change how it looks, found only by scrolling past them.

It is a tab now, trailing the strip beside Profiles. Both are built from
something other than the settings store, and About is where the strip ends
because it is the one section that changes nothing.

The standalone GamepadAboutView goes away with it. Its content is the tab's rows,
its two reading surfaces (shortcuts, licences) are in-place layers like the pin
picker, and the identity card — icon, name, version, tagline — rides in the
header under the tab strip. In the header rather than as a first row so the list
holds no focus stop that does nothing when pressed; laid out sideways rather than
centred like the touch page, because this header already carries a title and a
strip and a centred icon-name-version-tagline stack would leave no room for the
rows under it.

Row grows a `kind`, so the About tab can draw a heading and a block of prose
without every other tab's rows pretending to be one.

swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0;
288 tests pass.
2026-08-13 22:57:17 +02:00
enricobuehler 96278eebb5 feat(apple/gamepad-ui): the host tile gets a menu, and the start-of-stream banner becomes a page you can open
The gamepad UI could add a host and connect to one, and that was all: a renamed
machine or a fat-fingered address stayed wrong forever, because the only surface
that could edit or remove one was the touch UI. The desktop console and the
Android console have both had a host menu on UP for a while — this is the Apple
port of it, so the three consoles are learned once.

UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.
Wiring UP takes the whole vertical axis away from scrolling (down goes inert): a
horizontal carousel has no vertical travel to spend, and one meaning per
direction is what makes the gesture learnable. Remove arms on the first press
and only fires on the second, and disarms if focus wanders off the row — the
touch grid gets a system confirmation dialog, and a thumbstick from across a
room is a good reason to be at least as strict. A pinned profile card offers
only Unpin: it is a shortcut, not a second host.

Edit reuses GamepadAddHostView, seeded from the record and writing a COPY back
through HostStore.update, so the fingerprint, MACs, pins and binding the form
never shows survive a rename. It REPLACES the menu rather than stacking on it,
which keeps the shell's "depth <= 1 by construction" true.

This also retires the start-of-stream shortcut banner. Telling someone the
controls for six seconds, over the stream they have just connected to, answers
the question at the one moment nobody is asking it — and it put a composited
overlay above the stream to do it. The words are now ShortcutsCatalog, rendered
by an About page on BOTH surfaces: the new gamepad one (icon, version, licenses,
shortcuts) and the touch AboutView. The touch half is not a bonus — the banner
fired in touch mode on a Mac too, so deleting it without that would have cost
those users the only place the keys were written down.

Verified: swift build clean on macOS, arm64-apple-ios17.0 and arm64-apple-tvos17.0
(the iOS pass is what typechecks the shell-layer code, which is #if os(iOS));
288 tests pass. NOT verified on glass — screen capture is unavailable in this
environment, so the new screens have been compiled and reasoned about but not
seen.
2026-08-13 22:34:46 +02:00
enricobuehler fdf48fcaa1 Merge pull request 'The console UI died on any Vulkan loader newer than the version we asked for' (#204) from worktree-deck-skia-browse-fix into main
ci / docs-site (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
android / android (push) Failing after 3m26s
deb / build-publish-gamescope (push) Failing after 14s
ci / web (push) Successful in 4m4s
deb / build-publish (push) Successful in 3m17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bun-nix (push) Successful in 5m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
arch / build-publish (push) Successful in 7m42s
deb / build-publish-client-arm64 (push) Successful in 4m23s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 2m57s
docker / builders-arm64cross (push) Successful in 19s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m55s
deb / smoke-install (push) Successful in 3m54s
docker / deploy-docs (push) Failing after 3m59s
flatpak / build-publish (push) Successful in 9m29s
ci / rust (push) Successful in 17m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m13s
Reviewed-on: #204
2026-08-13 20:26:24 +00:00
enricobuehler 05a08b9804 Merge pull request 'The plugin runner was located by FHS path only, so NixOS never found it' (#207) from worktree-nix-plugin-runner-resolve into main
deb / smoke-install (push) Successful in 2m38s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m19s
android / android (push) Failing after 2m13s
arch / build-publish (push) Failing after 2m15s
deb / build-publish-gamescope (push) Successful in 1m8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 38s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
ci / bun-nix (push) Successful in 3m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 26s
ci / rust-arm64 (push) Successful in 4m37s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m18s
ci / rust (push) Successful in 4m39s
deb / build-publish-client-arm64 (push) Successful in 2m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 58s
deb / build-publish-host (push) Successful in 4m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 59s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 54s
docker / builders-arm64cross (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m37s
deb / build-publish (push) Failing after 4m19s
nix / flake (push) Failing after 5m46s
windows-host / package (push) Successful in 12m31s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
docker / deploy-docs (push) Failing after 6m16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m32s
2026-08-13 20:10:12 +00:00
enricobuehler 99c245520c Merge pull request 'AV1 decoded on the D3D11VA rung because 31 is not a level, and two host warnings that named the wrong subsystem' (#205) from worktree-av1-level-sentinel into main
android / android (push) Failing after 19s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m16s
deb / build-publish-client-arm64 (push) Successful in 1m23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
apple / swift (push) Successful in 2m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
ci / web (push) Successful in 3m36s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m45s
ci / rust-arm64 (push) Successful in 5m38s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m7s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 4m37s
arch / build-publish (push) Successful in 8m33s
deb / build-publish (push) Successful in 10m27s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
flatpak / build-publish (push) Successful in 8m57s
apple / distribute (push) Successful in 11m16s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 17s
deb / build-publish-gamescope (push) Failing after 1m23s
ci / rust (push) Successful in 20m46s
apple / screenshots (push) Successful in 6m28s
windows-host / package (push) Successful in 13m22s
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 8m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m23s
windows-host / canary-manifest (push) Successful in 59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m34s
2026-08-13 19:35:37 +00:00
enricobuehler 4ab6a399e6 Merge pull request 'The console screens read the ink they publish, so a pale palette stayed white on Apple TV' (#206) from worktree-apple-tv-light-palette-ink into main
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 27s
ci / bun-nix (push) Successful in 1m37s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
apple / swift (push) Successful in 2m2s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 2m4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / web (push) Successful in 3m40s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 31s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 2m44s
docker / builders-arm64cross (push) Successful in 9s
ci / docs-site (push) Successful in 4m45s
apple / distribute (push) Successful in 10m52s
ci / rust (push) Successful in 15m14s
apple / screenshots (push) Canceled after 3m41s
2026-08-13 19:18:54 +00:00
enricobuehler 8216f1d92d fix(host): a 2 s keyframe cadence is the client's flush cooldown, not display churn
ci / bun-nix (pull_request) Successful in 45s
apple / swift (pull_request) Successful in 2m12s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 5m52s
ci / web (pull_request) Successful in 6m40s
ci / rust-arm64 (pull_request) Successful in 6m54s
android / android (pull_request) Successful in 7m42s
ci / rust (pull_request) Successful in 12m59s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m55s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m28s
The host's recovery-cadence detector warns that "client keyframe recoveries are
METRONOMIC — a periodic host/display disturbance (display-topology churn,
display-poller software, virtual-display timing) is the likely cause, not
random network loss". In a 2026-08-13 field log it fired at period_s=2.0 and
sent the investigation at three innocent host subsystems.

2.0 s is `punktfunk_core::client::FLUSH_COOLDOWN`. The client's receive-backlog
guard sheds a standing queue with a flush plus a keyframe request and is
rate-limited to one per cooldown, so a client that cannot sustain the stream
asks for a keyframe at EXACTLY that spacing for as long as it stays behind —
the constant's own doc says it "degrades into a periodic skip + a logged
warning", which is the behaviour the detector then read as physical. Perfect
periodicity argues FOR a fixed software cooldown, not against it.

In the field case the host was blameless and the chain ran the other way: the
client refused the negotiated codec on its Vulkan rung, demoted to a slower
decode path, could not hold 4K120 there, and built the standing queue. Three
layers between the symptom the host reported and the cause.

So the detector now routes: a period on the client's cooldown names the client
and says where to look in ITS log (`receive backlog stopped draining`, and a
demoted decode rung); anything else keeps the display-disturbance wording it
had. The comparison reads FLUSH_COOLDOWN itself — now `pub` for exactly this,
documented as such — rather than a copy of the number, so the two cannot drift.
±10 % absorbs scheduling jitter and the request's trip without being wide
enough to swallow the disturbance cadences the other branch exists to report.

Verified: 18/18 native::stream::tests on linux/amd64 (container), including the
new case, which derives its inputs from FLUSH_COOLDOWN so it survives a retune;
clippy --all-targets -D warnings clean; cargo check clean on the Windows CI
runner.
2026-08-13 21:00:48 +02:00
enricobuehler 1677d1c0c2 fix(host/audio): stop warning that "the stream will click" when there is no stream
A 2026-08-13 field host log carried ten "the audio encode thread could not keep
up — captured audio was DROPPED" warnings, the worst reading
dropped_chunks=11251. That reads like catastrophic audio loss. It was not: not
one sample anybody wanted was lost.

PipeWire negotiated a 128-frame quantum, so the plane produces 48000/128 = 375
chunks/s and a 30 s stats window holds exactly 11250 — those windows were a
100 % drop rate, at peak_db=-120.0 (digital silence). Every one of the ten
straddled a session boundary, and across all of them dropped_chunks/375 matches
the seconds with NO live session in that window to within a fraction of a
second (3890/375 = 10.4 s against a 10.5 s gap; 3616/375 = 9.6 s against 9.8 s).

The capturer is host-lifetime: the native and gamestream planes PARK it between
sessions (`AudioCapturer::idle`) rather than dropping it, but the consumer is
the per-session encode thread. The hand-off channel is a bounded
sync_channel(64), so ~170 ms after a session ends it is full and every
try_send fails for as long as the host sits idle — counted as the encode thread
falling behind, and reported with a sentence about a stream that does not
exist. It is the worst kind of false alarm: it names a real failure mode, in a
subsystem with real open audio work, at a volume that demands attention.

So the drop counter now only counts while a session is actually reading, via an
`active` flag shared with the capture thread and toggled by the same
open/drain/idle/Drop transitions that already own the routing claim. A full
channel under a live consumer still means exactly what it used to.

Both backends: the parking call sites are platform-independent, so the WASAPI
half had the identical defect (it had no `idle` at all, and gains one). Only
the Linux half has field evidence.

Verified: punktfunk-host clippy --all-targets -D warnings clean on
linux/amd64 (container) and cargo check clean on the Windows CI runner.
2026-08-13 21:00:26 +02:00
enricobuehler 9a52d725d5 fix(apple): every console screen read the ink it publishes, so a pale palette stayed white on tvOS
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m49s
ci / docs-site (pull_request) Successful in 2m21s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 2m31s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 6m49s
A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the
environment: the modifier covers its descendants, never the body's own `ink.…` references. So
each of these screens read whatever was published above it — and on tvOS, where they are
presented as covers rather than nested in the iOS shell, that is nothing at all. They got the
bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the
real palette, which is why a pale field came out with a white title, white row labels and white
values under correctly-pale glass, with the focus wash still brand violet instead of the
palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment
key over.

Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that
publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was
reading the parent's ink too and bleaching a pale field's scrim toward white.

Three more of the same family, all tvOS-only:
  - the pairing cover drew the system's dark chrome straight over the launcher showing through
    it (a tvOS cover has no background of its own): the PIN prompt was white on the bright
    aurora. It gets the console field and the palette now, in the launcher's branch only — the
    touch route to the same sheet still belongs to the system background.
  - the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView
    from outside its own ink, so the shelf's name stayed white over content that had already gone
    dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes').
  - the library's loading / error / empty states mounted no backdrop at all; only the coverflow
    did. They now take the same field, so the spinner no longer sits on the launcher's own
    aurora with the host tiles still visible behind it.

And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is
chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends
of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put
white on light grey. It takes `onAccent` now, like the selected settings tab.

Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings,
add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and
tvOS both build.
2026-08-13 20:58:36 +02:00
enricobuehler fbbfce9b0e fix(console-ui): Skia sized its function table to the loader, not to what we promised
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 3m33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 5m15s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m58s
ci / rust (pull_request) Successful in 16m40s
android / android (pull_request) Successful in 5m18s
The skia-safe 0.87 -> 0.99 move swapped `BackendContext::new` for
`new_builder(..., None)` and recorded the `None` as "byte-for-byte what the
(now removed) `BackendContext::new` did". That is true of the VALUE and false
of the BEHAVIOUR. `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel,
and the newer Skia acts on that sentinel by falling back to
`vkEnumerateInstanceVersion()` -- the LOADER's ceiling, not ours. The presenter
declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS 3.7, host and
inside the flatpak sandbox alike). Skia then validates a 1.4 function table
against an instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns
null for the entry points in between, validation fails, and `make_vulkan` hands
back `None`. At 0.87 the same sentinel was inert, because that Skia knew nothing
of Vulkan 1.4 -- which is why this surfaced the moment 0.28.0 landed.

`run.rs` makes an overlay that cannot init fatal for `--browse`, so on the Steam
Deck the console home died on update: the Decky panel's button and the
gamepad-UI library shortcut both launch `PF_BROWSE=1`, and neither would open.
In a stream the same failure only warns, so those sessions quietly lost their
stats OSD and capture HUD instead. `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.

The presenter now publishes the version an overlay may size itself to as
`SharedDevice::api_version`, and `SkiaOverlay::init` passes it instead of `None`.
It is `min(what we declared, what the loader reports)`: taking the loader's
number alone is this bug, and taking ours alone would break the mirror case,
where a 1.1+ loader accepts our 1.3 `apiVersion` as intent even when it cannot
deliver 1.3. Three unit tests pin both directions and the no-answer case. The
three `API_VERSION_1_3` spellings in setup.rs now read the one constant, so the
number the overlay is told can no longer drift from the number we asked for.

Measured on the Deck (RADV VANGOGH, loader 1.4.321) with a standalone repro
against the shipped crate -- the client build is not needed to see it:

  vkEnumerateInstanceVersion() -> 1.4.321 ; VkApplicationInfo -> 1.3.0
  max_api_version = None      => DirectContext NULL
  max_api_version = Some(1.3) => DirectContext OK

Verified: cargo fmt --all --check; and in the pf-lxcheck2 x86_64 container,
cargo build + cargo clippy --all-targets -- -D warnings for pf-console-ui and
pf-presenter, plus cargo test -p pf-presenter (46 passed). Note that
`cargo check -p pf-console-ui` on macOS is vacuous -- every mod in that crate is
cfg(linux|windows), so it compiles nothing there.
2026-08-13 20:50:09 +02:00
enricobuehler 3f738a9989 fix(pf-vkdecode): AV1's "maximum parameters" level is not a level above the ceiling
A 2026-08-13 field report from the same RTX 5060 client as a02014ec: every AV1
session demoted to D3D11VA with "outside device caps: stream level
(seq_level_idx 31) above the device's maxLevel (AV1 Std level 23)" — 4K120,
NVIDIA, the hardware decoding the stream trivially on the D3D11VA rung it fell
through to. a02014ec fixed the H.264/H.265 half of exactly this and left AV1
alone on the premise that "no over-declaration has been seen in the field";
the reporter's own log from that same day already showed otherwise.

seq_level_idx is a 5-bit field. Annex A defines 0…23 (levels 2.0…7.3),
reserves 24…30, and makes 31 the "maximum parameters" level — the spec's own
way of saying the bitstream is NOT constrained to a level. StdVideoAV1Level
stops at 7.3 = 23, so 31 has no Std code point and the index-coded comparison
that holds across 0…23 says nothing here: 31 > 23 is true even of a device
that decodes everything AV1 can name, which is what makes it useless as a
capability test. We write no AV1 level on any host encode path, so whichever
sentinel the vendor's encoder defaults to is what the client must accept.

So the gate warns once and proceeds, like its H.265 sibling. Unlike H.265
there is nothing to clamp: StdVideoAV1SequenceHeader carries no level field,
so the declaration never reaches the driver and cannot be invalid usage. The
stream's real demands stay enforced where they are physical facts — coded
extent and DPB depth, both checked at session build.

Not verified on glass: no RTX 5060 here, and the reporter's box is the only
one that has produced a seq_level_idx 31 stream. The unit test pins the
arithmetic that made the refusal look reasonable.
2026-08-13 20:05:41 +02:00
78 changed files with 3984 additions and 570 deletions
+5 -3
View File
@@ -517,15 +517,17 @@ jobs:
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
# actually produced a working compositor.
- name: Build the .deb
run: |
gs-cache/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
gs-cache/usr/bin/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
- name: Publish to the Gitea apt registry
env:
+10 -6
View File
@@ -293,7 +293,11 @@ jobs:
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only thing that can give a nested game an HDR10
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
# addition from needing a change here as well as in every packaging script.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
@@ -311,9 +315,9 @@ jobs:
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--stage gs-cache \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
@@ -372,9 +376,9 @@ jobs:
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope-stage gs-cache)
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
+69
View File
@@ -14,6 +14,75 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
host and settings that play fine on an Apple TV.** Video unaffected. Turning off the client's
low-latency mode — which is what gates the forced HDMI mode switch and the `usage=Game` tagging —
changed nothing.
The Android client opens AAudio directly (the Apple client goes through AVAudioEngine, which
reconfigures itself on a route change; that difference is why this was Android-only). Opening
AAudio is a negotiation with a vendor HAL, and this plane treated it as a formality: one Exclusive
attempt, one Shared retry, and everything after the open taken on trust. **Three distinct failures
all presented as "the app has no sound" behind a healthy-looking log**, and none of them was
detected:
- **A configuration that opens but routes nowhere.** Nothing ever checked that the device actually
pulled a sample, so the decode thread would happily decode Opus into a dead stream forever.
- **`request_start` failing.** The old code gave up on the spot instead of trying anything else, so
one unhappy configuration disabled audio for the whole session.
- **A disconnect.** By AAudio's contract a disconnected stream is dead and the only recovery is
close + open a new one. The error callback logged a warning and did nothing else — so an HDMI
mode switch, an AVR re-handshake or any route change meant silence for the rest of the session.
On a TV that is not a rare event: the client itself drives an HDMI mode switch on the video
plane, and the platform's own match-content-frame-rate setting drives more.
The open now walks a **ladder**, every rung has to **prove the device is pulling** before it is
accepted, and a **supervisor** owns the plane for the session and reopens it when the device goes
away (bounded retries across the settling time of a route change, so a reopen landing mid-switch
does not permanently disable audio). The granted rate/channel-count/format are checked against what
was asked for rather than assumed — the realtime callback casts AAudio's buffer to `f32` and writes
`num_frames × channels` of them, so a HAL that disagreed was an out-of-bounds write on the audio
thread, not merely a mistuning.
**Behaviour change on TV boxes: they now start at Shared instead of Exclusive.** Exclusive is
MMAP, the lowest-latency path AAudio has, and the one rung whose routing cannot be verified from
inside the process. The latency it buys here was never actually banked — the jitter-ring depths are
unchanged from the Shared-only era (`JitterTuning::AAUDIO` still primes at 25 ms) — so on a
mains-powered HDMI box the few ms are worth less than not betting the audio plane on it. Phones,
tablets and handhelds are unchanged and still try Exclusive first. If no rung proves itself, the
first one that opened and started is used anyway: the watchdog must never be able to turn working
audio into no audio.
**Embedder-visible:** `NativeBridge.nativeStartAudio` takes a third argument, `isTv`
(`FEATURE_LEANBACK`, the same source the video plane already used).
Three new sysprops bisect all of it on a device that cannot be handed a custom build, alongside the
existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusive`|`shared`),
`debug.punktfunk.audio_perf` (`lowlatency`|`none`) and `debug.punktfunk.audio_reopen` (`0` pins the
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
so at `error` level instead of looking exactly like an app with no sound.
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
same session — and gamescope could SIGSEGV in exactly that window, so the kept display was dead and
reconnect silently got a fresh compositor with the game lost.** When the capture consumer leaves,
PipeWire's `remove_buffer` (and the stale-push path in `dispatch_nudge`) destroyed idle buffers on
the **PipeWire thread**; dropping the last `CVulkanTexture` reference there calls into the Vulkan
driver (`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
in `CVulkanCmdBuffer::insertBarrier` — timed at stream end, which is why it selectively killed
linger. The journal signature: linger line → coredump → `kept display was dead — recreating`.
Patch 0009 queues those corpses on the PipeWire thread and has steamcompmgr reap them on every
vblank — including while the stream is paused, which is precisely the linger state. Found, fixed
and proven live by **luxus** ([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)):
four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect now reuses the
lingered session. Banner `+pfhdr6``+pfhdr7` (no new capability — but "reconnect lost my game"
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
### NixOS — the plugin runner was installed, running, and reported missing
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
+97 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.27.0"
"version": "0.28.0"
},
"paths": {
"/api/v1/clients": {
@@ -45,6 +45,36 @@
}
}
}
},
"delete": {
"tags": [
"clients"
],
"summary": "Unpair every client",
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
"operationId": "unpairAllClients",
"responses": {
"200": {
"description": "Every client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/clients/{fingerprint}": {
@@ -1767,6 +1797,56 @@
}
}
}
},
"delete": {
"tags": [
"native"
],
"summary": "Unpair every native client",
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
"operationId": "unpairAllNativeClients",
"responses": {
"200": {
"description": "Every native client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/clients/{fingerprint}": {
@@ -7687,6 +7767,22 @@
}
}
},
"UnpairAllResult": {
"type": "object",
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
"required": [
"unpaired"
],
"properties": {
"unpaired": {
"type": "integer",
"format": "int32",
"description": "Clients removed from the trust store — 0 when nothing was paired.",
"example": 3,
"minimum": 0
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
@@ -816,7 +816,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
if (micWanted) {
val sessionId =
NativeBridge.nativeStartMic(handle, initialSettings.echoCancel)
@@ -302,12 +302,17 @@ object NativeBridge {
external fun nativeSetVideoStatsEnabled(handle: Long, enabled: Boolean)
/**
* Start hostclient audio: Opus decode jitter ring AAudio (LowLatency), all in Rust.
* Start hostclient audio: Opus decode jitter ring AAudio, all in Rust.
* [lowLatencyMode] (the experimental toggle) additionally tags the stream usage=Game for the
* HAL's game-audio routing. No-op if already started. Best-effort a failure leaves video
* streaming.
*
* [isTv] steers the AAudio open ladder: a TV box starts at Shared rather than betting the
* audio plane on an Exclusive/MMAP path whose routing we cannot verify from inside the
* process. Passed from `FEATURE_LEANBACK` (same source as [nativeStartVideo]) because the
* native side's own `ro.build.characteristics` check is not answered by every TV device.
*/
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean)
external fun nativeStartAudio(handle: Long, lowLatencyMode: Boolean, isTv: Boolean)
/** Stop + join the audio thread and close AAudio, without closing the session. No-op on `0`. */
external fun nativeStopAudio(handle: Long)
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -357,10 +357,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetVideoSta
})
}
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode)` — start the Opus→AAudio playback thread.
/// `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's game-audio
/// routing. No-op if already started or on a `0` handle. Best-effort: a failure leaves video
/// streaming.
/// `NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)` — start the Opus→AAudio playback
/// supervisor. `lowLatencyMode` (the experimental toggle) tags the stream usage=Game for the HAL's
/// game-audio routing; `isTv` steers the AAudio open ladder (see `crate::audio::open_ladder`).
/// No-op if already started or on a `0` handle. Best-effort: a failure leaves video streaming.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
@@ -368,6 +368,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
_this: JObject,
handle: jlong,
low_latency_mode: jboolean,
is_tv: jboolean,
) {
if handle == 0 {
return;
@@ -378,7 +379,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio(
if guard.is_some() {
return; // already playing
}
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode) {
match crate::audio::AudioPlayback::start(h.client.clone(), low_latency_mode, is_tv) {
Some(p) => *guard = Some(p),
None => log::error!("nativeStartAudio: playback init failed (video unaffected)"),
}
@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "about-icon@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "about-icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -83,14 +83,6 @@ struct ContentView: View {
/// 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. 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(iOS)
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
/// the disc must LEAVE the hierarchy so nothing composites over the metal layer).
@@ -347,9 +339,6 @@ struct ContentView: View {
.onChange(of: model.phase) { _, phase in
switch phase {
case .streaming:
#if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start
#endif
#if os(iOS)
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
#endif
@@ -453,6 +442,10 @@ struct ContentView: View {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
// The stack draws the title, and it sits outside LibraryView's own ink see the tvOS
// cover. Gated, because this sheet is BOTH modes' library on macOS and the touch
// grid's title belongs to the system background.
.gamepadPaletteInk(gamepadUIActive)
}
#else
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
@@ -822,6 +815,7 @@ struct ContentView: View {
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
wakeOnly: { wakeOnly($0) },
promptActive: consolePromptShowing)
} else {
HomeView(
@@ -841,6 +835,7 @@ struct ContentView: View {
onPaired: handlePaired, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
wakeOnly: { wakeOnly($0) },
promptActive: consolePromptShowing)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
@@ -851,12 +846,29 @@ struct ContentView: View {
.fullScreenCover(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
// A tvOS cover draws NO background of its own, and this one is attached
// outside the launcher's `gamepadPaletteInk` so the pairing screen used
// to render the system's dark chrome directly over the launcher showing
// through it, which under a pale palette is white text on a bright field
// (the PIN prompt was all but invisible). Give it the console's own field
// and the palette's ink, like every other screen the launcher opens. Only
// this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which
// sits on the system background and has no palette.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { GamepadFormBackground() }
.gamepadPaletteInk()
}
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
// On the STACK, not just inside LibraryView: the navigation title is drawn by
// the stack, which wraps that view from outside its own `gamepadPaletteInk`
// so the shelf's name stayed white over a pale field while the content below
// it had already gone dark. Unconditional here because this cover only exists
// in the launcher's branch, where the console UI is by definition drawing.
.gamepadPaletteInk()
}
#endif
} else {
@@ -973,9 +985,15 @@ struct ContentView: View {
model?.disconnect() // the captured-state D combo
},
onFrame: { [meter = model.meter, latency = model.latency,
split = model.latencySplit, queue = model.clientQueue,
offset = conn.clockOffsetNs] au in
split = model.latencySplit, queue = model.clientQueue] au in
meter.note(byteCount: au.data.count)
// Read the offset PER AU (an atomic load), never in the capture list: a
// capture-list `offset =` froze the connect-time estimate for the whole
// session, and on a host whose wall clock steps (VM + NTP) that frozen
// value shifted hostnet/e2e by ~15 ms between sessions while the meter's
// impossible-sample guard hid the damage (field 2026-08-13). See
// `PunktfunkConnection.clockOffsetNs`.
let offset = conn.clockOffsetNs
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
// The same receipt, keyed by pts, awaiting its 0xCF host timing (the
// host/network split drained by the 1 s stats tick). receivedNs is
@@ -1048,31 +1066,15 @@ struct ContentView: View {
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
#endif
#if os(macOS) || os(tvOS)
// The start-of-stream shortcut banner (Windows-client parity): the
// The start-of-stream shortcut banner used to sit here (macOS/tvOS): the
// platform's reserved controls on a glass pill 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). 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.
if captureEnabled && showShortcutHint {
Text(shortcutHintText)
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.glassBackground(Capsule())
.transition(.opacity)
.task {
try? await Task.sleep(for: .seconds(6))
withAnimation(.easeOut(duration: 0.6)) {
showShortcutHint = false
}
}
}
#endif
// every session. It is now a page you can OPEN About Shortcuts, on
// both the touch and the controller surface (ShortcutsCatalog) because
// a message that shows once, over the stream you have just connected to,
// is unavailable at the moment the question is actually asked. It also
// put a composited overlay above the stream for those 6 seconds, which on
// this path costs a refresh of display latency (see the iOS exit disc's
// note below); the reference page costs nothing during a session.
}
.padding(.bottom, 24)
.animation(.easeOut(duration: 0.2), value: model.micMuted)
@@ -1139,23 +1141,10 @@ struct ContentView: View {
}
#endif
#if os(macOS)
/// The reserved combos, told once per session. The mute segment appears only when the session
/// actually sends a microphone teaching a shortcut for a mic that isn't on would be a lie.
private var shortcutHintText: String {
let base =
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
}
private static let shortcutHintFont: CGFloat = 12
#elseif os(tvOS)
private var shortcutHintText: String {
"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"
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
}
private static let shortcutHintFont: CGFloat = 22 // read from the couch
#endif
// The two `shortcutHintText` strings that used to live here one per platform, told once per
// session by the banner above are now `ShortcutsCatalog.groups`, which both About pages
// render. The mic line is still conditional there for the same reason it was here: teaching a
// shortcut for a microphone that isn't on would be a lie.
// MARK: - Connect
@@ -12,7 +12,10 @@ import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@@ -25,6 +28,18 @@ struct GamepadAddHostView: View {
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadSettingsView's twin).
var controllerActive = true
/// Non-nil this screen is EDITING that saved host rather than registering a new one: the
/// fields start on its values and `onAdd` receives it back with only name/address/port
/// changed, so the fingerprint, pins, binding and MACs it carries survive the edit. A
/// re-typed address is the whole point of the screen (a host that moved), so nothing here
/// re-derives identity from it that is the trust store's job, not this form's.
///
/// Declared after the closures for the same trailing-closure reason as `close`, and it is a
/// plain value besides, so it can never capture one.
var editingHost: StoredHost?
/// One-shot seed guard: `@State` cannot be initialised from a property without a custom init,
/// and a custom init would break every existing trailing-closure call site.
@State private var seeded = false
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@@ -57,12 +72,15 @@ struct GamepadAddHostView: View {
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Add Host")
Text(editingHost == nil ? "Add Host" : "Edit Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
Text(editingHost == nil
? "Hosts on this network appear automatically — add one by address "
+ "for everything else."
: "Rename this host, or point it at a new address — its pairing and "
+ "pinned cards are kept.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
@@ -98,6 +116,17 @@ struct GamepadAddHostView: View {
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
}
// Seed the fields from the host being edited, exactly once: re-seeding on a later appear
// (the shell re-mounts a layer when the app returns from the background) would silently
// throw away whatever had been typed.
.onAppear {
guard !seeded else { return }
seeded = true
guard let host = editingHost else { return }
name = host.name
address = host.address
port = String(host.port)
}
#if !os(tvOS)
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
@@ -202,7 +231,9 @@ struct GamepadAddHostView: View {
Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"),
Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"),
Row(id: "port", label: "Port", value: port, placeholder: "9777"),
Row(id: "add", label: "Add Host", isAction: true),
Row(
id: "add", label: editingHost == nil ? "Add Host" : "Save Changes",
isAction: true),
]
}
@@ -261,10 +292,21 @@ struct GamepadAddHostView: View {
openKeyboard("address")
return
}
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(port) ?? 9777))
let typedName = name.trimmingCharacters(in: .whitespaces)
let typedAddress = address.trimmingCharacters(in: .whitespaces)
let typedPort = UInt16(port) ?? 9777
if var host = editingHost {
// Mutate a COPY of the stored record rather than building a fresh one: everything
// this form does not show the pinned fingerprint, WoL MACs, pinned profile
// cards, the default binding, `addedAt` has to survive a rename.
host.name = typedName
host.address = typedAddress
host.port = typedPort
onAdd(host)
} else {
onAdd(StoredHost(
name: typedName, address: typedAddress, port: typedPort))
}
performClose()
default:
openKeyboard(id)
@@ -48,6 +48,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var onTertiary: (() -> Void)?
/// B back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
var onBack: (() -> Void)?
/// UP the focused item's own menu (the launcher's host options). Wiring it takes the whole
/// VERTICAL axis away from scrolling: up opens the menu and down goes inert, rather than up
/// meaning "menu" while down still stepped the strip. A horizontal carousel has no vertical
/// travel to spend, and the desktop and Android consoles both read the axis this way one
/// meaning per direction is what makes the gesture learnable across the three of them.
/// nil leaves up/down as a second way to step (what every carousel without a menu still does).
var onUp: (() -> Void)?
/// L1/R1 jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
@@ -301,6 +308,17 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
input.onSecondary = onSecondary
input.onTertiary = onTertiary
// UP is the one direction the poll may also read here, and ONLY to open the menu it
// never calls `step`, so it cannot double-move against the focus engine. Routing it
// through `.onMoveCommand` instead was the obvious alternative and the wrong one: that
// stream is 4-way and its interception is input-source-dependent on real hardware (see
// GamepadMenuList's tvOS note), so claiming up there risks left/right focus with it.
// Nothing sits above the strip for the engine to move to, so this direction is free.
if let onUp {
input.onMove = { direction in
if direction == .up { onUp() }
}
}
#else
input.onMove = { move($0) }
input.onConfirm = { activate() }
@@ -312,6 +330,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
}
private func move(_ direction: GamepadMenuInput.Direction) {
// With a menu wired, vertical is the menu's axis, not a second scroll axis see `onUp`.
if let onUp {
switch direction {
case .up: return onUp()
case .down: return
case .left, .right: break
}
}
let forward = direction == .right || direction == .down
step(by: forward ? 1 : -1, clampAtEnds: false)
}
@@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle {
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's
/// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the
/// environment here is the screen's parent's the dark default under a cover or a sheet. It
/// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward
/// white instead of settling onto its own ink. (see `GamepadInk.stored`)
private var ink: GamepadInk { .stored(paletteID) }
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
/// it during a push/pop the console does the same with its `bg_mix` and every
@@ -64,7 +64,10 @@ private struct HomeTile: Identifiable {
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
@@ -91,6 +94,10 @@ struct GamepadHomeView: View {
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (LibraryTarget, String) -> Void
/// Wake a host WITHOUT connecting (ContentView's `wakeOnly`) the host menu's Wake row. The
/// tile's own A already wakes-and-connects; this is the other half, for bringing a machine up
/// to look at it rather than to stream from it right now.
let wakeOnly: (StoredHost) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -119,6 +126,11 @@ struct GamepadHomeView: View {
@State private var selection: GamepadHomeTarget?
@State private var showSettings = false
@State private var showAddHost = false
/// The card whose options menu is up (UP on a saved tile) see GamepadHostOptionsView.
@State private var hostOptionsTarget: HostOptionsTarget?
/// The host being edited. Set from the options menu, which closes itself as it opens this so
/// the two are never stacked depth stays 1, which is what `GamepadScreen` assumes.
@State private var editTarget: StoredHost?
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
/// the controller a double-tapped A can't push two screens, and the held button that
/// caused the change is long released before the next poller starts (whose own
@@ -201,19 +213,37 @@ struct GamepadHomeView: View {
// shell's layers above ARE the presentation.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
.frame(width: 660, height: 620)
}
// Shorter than the forms above: a menu is five rows, and a sheet sized for a settings
// screen would be mostly empty field under them.
.sheet(item: $hostOptionsTarget) { target in
hostOptionsView(target, active: true)
.frame(width: 620, height: 460)
}
.sheet(item: $editTarget) { host in
editHostView(host, active: true)
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#elseif os(tvOS)
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showSettings) {
GamepadSettingsView(store: store, micAvailable: model.micAvailable)
}
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
.fullScreenCover(item: $hostOptionsTarget) { target in
hostOptionsView(target, active: true)
}
.fullScreenCover(item: $editTarget) { host in
editHostView(host, active: true)
}
#endif
}
@@ -261,6 +291,10 @@ struct GamepadHomeView: View {
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
// it has to win. Backing out of it reveals whatever it interrupted.
if let host = pairingTarget { return .pair(host) }
// Editing leads the menu that raised it: the menu clears itself on the way, so the two are
// never both set, and if they somehow were, the screen the user asked for last should win.
if let host = editTarget { return .editHost(host) }
if let target = hostOptionsTarget { return .hostOptions(target) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let shelf = libraryTarget { return .library(shelf) }
@@ -277,12 +311,17 @@ struct GamepadHomeView: View {
GamepadSettingsView(
store: store,
close: { if !transitioning { showSettings = false } },
controllerActive: active)
controllerActive: active,
micAvailable: model.micAvailable)
case .addHost:
GamepadAddHostView(
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .hostOptions(let target):
hostOptionsView(target, active: active)
case .editHost(let host):
editHostView(host, active: active)
case .pair(let host):
GamepadPairView(
host: host,
@@ -414,6 +453,7 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
onUp: { openOptionsForSelected() },
isActive: homeOwnsController
) { tile, entrance in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
@@ -469,6 +509,14 @@ struct GamepadHomeView: View {
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
action: { openLibraryForSelected() }))
}
// Only a saved card has a menu, so the cell appears only where the press does something
// the same honesty rule the Library cell above follows. A direction, not a button, so it
// is a plain arrow rather than a `buttonGlyph` (see the settings screen's "Adjust").
if case .saved = selected?.id {
hints.append(.init(
glyph: "arrow.up", text: "Options",
action: { openOptionsForSelected() }))
}
hints.append(.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
action: { showSettings = true }))
@@ -543,6 +591,60 @@ struct GamepadHomeView: View {
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
/// The host menu, built once for all three presentations (the iOS shell layer, the macOS
/// sheet, the tvOS cover) so the actions can't drift between them.
///
/// Edit REPLACES this menu rather than stacking on it `hostOptionsTarget` is cleared as
/// `editTarget` is set which is the desktop console's `Nav::Replace` and what keeps the
/// shell's "depth 1 by construction" claim true.
@ViewBuilder
private func hostOptionsView(_ target: HostOptionsTarget, active: Bool) -> some View {
let host = target.host
GamepadHostOptionsView(
host: host,
pinnedProfile: target.profile,
isOnline: discovery.advertises(host) || store.probedOnline.contains(host.id),
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !host.wakeMacs.isEmpty,
onEdit: {
guard !transitioning else { return }
hostOptionsTarget = nil
editTarget = host
},
onWake: { wakeOnly(host) },
onForgetPairing: { store.forgetIdentity(host) },
onRemove: { store.remove(host) },
onUnpin: {
guard let profile = target.profile else { return }
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
/// The add-host form in edit mode. `store.update` writes the record back by id, so the
/// fingerprint, MACs, pins and binding the form never shows are preserved.
@ViewBuilder
private func editHostView(_ host: StoredHost, active: Bool) -> some View {
GamepadAddHostView(
onAdd: { store.update($0) },
close: { if !transitioning { editTarget = nil } },
controllerActive: active,
editingHost: host)
}
/// UP on a saved tile opens that card's menu. Only SAVED hosts have one: a discovered-but-
/// unsaved host is not ours to rename or remove, and the two action tiles have nothing to
/// offer the same `HostOptionsScreen::available` gate the desktop console applies.
private func openOptionsForSelected() {
guard case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
hostOptionsTarget = HostOptionsTarget(
host: host,
profile: profileID.flatMap { pid in profiles.profiles.first { $0.id == pid } })
}
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
@@ -657,6 +759,12 @@ private struct GamepadHostTile: View {
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
// What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent`
// the colour picked by the accent's own luminance exactly as the settings screen's
// selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the
// two disagree at both ends of the set: a pale palette put near-black on a deep accent, and
// Graphite (accent luma 0.80) put white on a light grey.
let glyph = tile.filled ? ink.onAccent : ink.accent
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
@@ -664,7 +772,7 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
ProgressView().tint(glyph)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
@@ -676,12 +784,12 @@ private struct GamepadHostTile: View {
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.foregroundStyle(glyph)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
@@ -0,0 +1,321 @@
// A saved host's own actions Wake, Copy link, Edit, Forget pairing, Remove reached with UP on
// its carousel tile. The console's answer to the overflow menu the touch grid hangs off every host
// card (HostCardView's context menu), and the Apple port of `pf-console-ui`'s HostOptionsScreen.
//
// Until now the gamepad UI could add a host and connect to one, and that was all: a renamed machine
// or a host typed in with a fat-fingered address stayed wrong forever, because the only surface
// that could edit or remove one was the touch UI. The tile is where a host is, so the tile is where
// its actions belong.
//
// UP is the gesture because the carousel is horizontal left/right are spoken for and up is free
// and because the desktop console and the Android console already do exactly this, so the three are
// learned once. A pinned profile card offers only Unpin: it is a shortcut, not a second host, and
// offering to remove the host from it would blur precisely the distinction a pin exists to draw.
//
// Vocabulary note: this screen says "Forget pairing" and "Remove host" where the desktop console
// says one word, "Forget". The console has only the one action; Apple has both (HostCardView calls
// them `onForget` = drop the pinned fingerprint and `onRemove` = delete the record), and two
// different actions cannot share a name on the surface that offers both. The touch card's words
// win over the other consoles' here a user meets both Apple surfaces, and only one of them is
// cross-platform.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
/// Which card the menu was opened on. Carries the host BY VALUE for the same reason the screen
/// does the carousel is rebuilt on every discovery pass, and a target that re-resolved itself
/// could hand "Remove" a different host than the one the user was looking at.
struct HostOptionsTarget: Identifiable {
let host: StoredHost
/// Non-nil a pinned profile card rather than the host's own tile.
var profile: StreamProfile?
/// Keyed on the CARD, not the host: a host and each of its pinned cards open different menus,
/// and sharing an id would let one stand in for another mid-transition (the same rule
/// `GamepadScreen.library` follows).
var id: String { "\(host.id.uuidString)-\(profile?.id ?? "")" }
}
struct GamepadHostOptionsView: View {
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.gamepadHostedInShell) private var hostedInShell
@Environment(\.dismiss) private var dismiss
/// The host this menu was opened on, BY VALUE. Discovery rewrites the carousel on every
/// service pass; holding an index or a live lookup would let the menu retarget itself onto
/// whichever host slid into that slot, and "Remove" must never be able to do that.
let host: StoredHost
/// Non-nil opened on a pinned profile card rather than the host's own tile.
var pinnedProfile: StreamProfile?
/// Whether the host is reachable right now decides whether Wake is worth offering.
var isOnline = false
/// Whether waking is possible at all (the setting is on, WoL is available, a MAC is known).
var canWake = false
let onEdit: () -> Void
let onWake: () -> Void
/// Drop the pinned fingerprint the host stays saved, and the next connect re-pairs.
let onForgetPairing: () -> Void
/// Delete the saved record outright.
let onRemove: () -> Void
let onUnpin: () -> Void
var close: (() -> Void)?
var controllerActive = true
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
/// Removing is the one action here with no undo, so its row ARMS on the first press and only
/// fires on the second. The touch grid removes behind a system confirmation dialog; a console
/// is driven by a thumbstick from across a room, which is a good reason to be at least as
/// strict as it is, and none at all to be looser.
@State private var armed = false
@State private var copied = false
@State private var focusID: String?
private enum Action: String {
case wake
case copyLink
case edit
case forgetPairing
case remove
case unpin
case cancel
}
var body: some View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { run($0.action) },
onBack: { performClose() },
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
if !compact {
Text("\(host.address):\(String(host.port))")
.font(.geistFixed(metrics.detailFont, .medium))
.foregroundStyle(ink.fg(0.55))
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(detail)
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
GamepadHintBar(hints: hints)
}
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .bottom) }
}
.background {
if !hostedInShell { GamepadFormBackground() }
}
.gamepadPaletteInk()
// Moving the focus off the armed Remove row disarms it: an arming that outlives the row it
// was made on is a trap, and the thumb that wandered away is exactly the hesitation the
// two-press rule exists to catch.
.onChange(of: focusID) { _, id in
if id != Action.remove.rawValue { armed = false }
}
#if !os(tvOS)
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
#endif
}
private var title: String {
pinnedProfile.map { "\(host.displayName) · \($0.name)" } ?? host.displayName
}
// MARK: - Rows
private struct Row: Identifiable {
let action: Action
let label: String
var icon: String
var isDestructive = false
var id: String { action.rawValue }
}
private var rows: [Row] {
// A pinned card is a shortcut, not a host: everything host-level is deliberately absent.
if pinnedProfile != nil {
return [
Row(action: .unpin, label: "Unpin card", icon: "pin.slash"),
Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"),
Row(action: .cancel, label: "Cancel", icon: "xmark"),
]
}
var list: [Row] = []
// Waking a host that is already answering would just sit there counting seconds.
if canWake, !isOnline {
list.append(Row(action: .wake, label: "Wake host", icon: "power"))
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
// Only a paired host has a pairing to drop.
if host.pinnedSHA256 != nil {
list.append(Row(
action: .forgetPairing, label: "Forget pairing", icon: "lock.open"))
}
list.append(Row(
action: .remove,
label: armed ? "Remove host \u{2014} press again" : "Remove host",
icon: "trash", isDestructive: true))
list.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
return list
}
/// The explainer under the list the same band the settings screen uses, and the only place a
/// destructive action can say what it will actually do before it is pressed.
private var detail: String {
switch rows.first(where: { $0.id == focusID })?.action {
case .wake:
return "Send a Wake-on-LAN packet and wait for this host to answer."
case .copyLink:
return "Copy a punktfunk:// link to this host — paste it anywhere to connect."
case .edit:
return "Rename this host or change its address. Pairing and pinned cards are kept."
case .forgetPairing:
return "Drop the stored fingerprint. The host stays saved and the next connect "
+ "pairs again."
case .remove:
return armed
? "Press again to remove — this cannot be undone."
: "Delete this host, its pairing and its pinned cards from this device."
case .unpin:
return "Remove this profile's card. The profile itself and the host are untouched."
case .cancel, .none:
return ""
}
}
private var hints: [GamepadHint] {
[
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let id = focusID, let row = rows.first(where: { $0.id == id }) {
run(row.action)
} }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { performClose() }),
]
}
// MARK: - Actions
private func run(_ action: Action) {
switch action {
case .wake:
onWake()
performClose()
case .copyLink:
LinkClipboard.copy(
DeepLink.forHost(host, profile: pinnedProfile?.id).urlString)
// No toast machinery on this surface the row says so itself, which is the same
// acknowledgement in the place the user is already looking.
withAnimation(.smooth(duration: 0.2)) { copied = true }
case .edit:
onEdit()
case .forgetPairing:
onForgetPairing()
performClose()
case .remove:
guard armed else {
withAnimation(.smooth(duration: 0.2)) { armed = true }
return
}
onRemove()
performClose()
case .unpin:
onUnpin()
performClose()
case .cancel:
performClose()
}
}
private func performClose() {
if let close { close() } else { dismiss() }
}
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
// The destructive row wears the warning colour only once ARMED: red on a row that still
// needs a second press reads as "this already happened".
let danger = row.isDestructive && armed
return HStack(spacing: 14) {
Image(systemName: row.icon)
.font(.system(size: m.iconFont))
.foregroundStyle(
danger ? GamepadInk.warningRed : (focused ? ink.accent : ink.fg(0.55)))
.frame(width: m.iconWidth)
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(danger ? GamepadInk.warningRed : ink.fg)
.lineLimit(1)
Spacer(minLength: 12)
}
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? (danger ? GamepadInk.warningRed.opacity(0.3) : ink.accent(0.30)) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
danger ? GamepadInk.warningRed.opacity(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
.animation(.smooth(duration: 0.18), value: armed)
}
}
#endif
@@ -67,9 +67,32 @@ struct GamepadInk: Equatable, Sendable {
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
/// The ink for a stored `ui_palette` id, resolved WITHOUT the environment.
///
/// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's
/// `@Environment` resolves against its PARENT the modifier a screen applies to its own body
/// covers its descendants, never the body's own `ink.` references so such a screen reads
/// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's
/// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is
/// nothing above it and it gets the bare dark default. That is precisely how a pale palette
/// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while
/// the child views in the same screen the hint bar, the host tiles, the glass were
/// correctly dark-on-pale.
///
/// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the
/// screen when the setting changes (`GamepadInkModifier` reads the same key).
static func stored(_ paletteID: String) -> GamepadInk {
.of(GamepadPalette.named(paletteID))
}
/// The online pip deliberately NOT palette-derived: a status colour must not change
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
/// An armed destructive action (the host menu's Remove). Palette-independent for exactly the
/// same reason as the pip above, and the more strongly so: the one colour on this UI that
/// means "this does not come back" cannot be allowed to drift toward the wallpaper on a warm
/// palette, or read as a highlight on a red one.
static let warningRed = Color(red: 0.94, green: 0.28, blue: 0.26)
}
private struct GamepadInkKey: EnvironmentKey {
@@ -10,7 +10,10 @@ import SwiftUI
#if os(iOS)
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@ObservedObject var store: HostStore
let target: LibraryTarget
let onLaunch: (String) -> Void
@@ -21,6 +21,8 @@ import SwiftUI
enum GamepadScreen: Identifiable {
case settings
case addHost
case hostOptions(HostOptionsTarget)
case editHost(StoredHost)
case pair(StoredHost)
case library(LibraryTarget)
@@ -28,6 +30,10 @@ enum GamepadScreen: Identifiable {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
// Keyed on the CARD (host + pinned profile), for the same reason the library is keyed on
// the shelf see `HostOptionsTarget.id`.
case .hostOptions(let target): return "hostOptions-\(target.id)"
case .editHost(let host): return "editHost-\(host.id.uuidString)"
case .pair(let host): return "pair-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
@@ -39,7 +45,7 @@ enum GamepadScreen: Identifiable {
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
var isForm: Bool {
switch self {
case .settings, .addHost, .pair: return true
case .settings, .addHost, .hostOptions, .editHost, .pair: return true
case .library: return false
}
}
@@ -19,7 +19,10 @@ import SwiftUI
import GameController
struct LibraryCoverflowView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
var onLaunch: ((String) -> Void)?
@@ -94,6 +94,9 @@ struct LibraryView: View {
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
mode: gamepadUIMode)
}
/// True when the iOS shell already draws one persistent field behind its layers mounting a
/// second would double the mesh (the same rule the coverflow and the settings screen follow).
@Environment(\.gamepadHostedInShell) private var hostedInShell
#endif
var body: some View {
@@ -150,12 +153,13 @@ struct LibraryView: View {
@ViewBuilder private var content: some View {
if loading && games.isEmpty {
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
consoleField(
ProgressView("Loading library…")
.frame(maxWidth: .infinity, maxHeight: .infinity))
} else if let errorText, games.isEmpty {
errorState(errorText)
consoleField(errorState(errorText))
} else if games.isEmpty {
emptyState
consoleField(emptyState)
} else {
if gamepadUIActive {
LibraryCoverflowView(
@@ -168,6 +172,24 @@ struct LibraryView: View {
}
}
/// The console field behind the three states that are NOT the coverflow loading, error,
/// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view
/// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its
/// label sat on the launcher's own aurora with the host tiles still showing through. The same
/// field as the coverflow's (not the calmed form one), so nothing shifts under the content when
/// the titles land and the coverflow takes over.
///
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
@ViewBuilder private func consoleField(_ view: some View) -> some View {
#if os(iOS) || os(macOS) || os(tvOS)
view.background {
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
}
#else
view
#endif
}
private var grid: some View {
// Design D4: launcher entries get their own section above the titles, never interleaved.
// Both headers appear only when both groups exist, so a library without launcher entries
@@ -244,7 +244,8 @@ private struct ShotGamepadHome: View {
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
wakeOnly: { _ in })
}
}
@@ -303,7 +304,8 @@ private struct ShotConnect: View {
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in },
wakeOnly: { _ in })
} else {
ShotHome()
}
@@ -20,6 +20,18 @@ import SwiftUI
/// 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")
/// Mirror the 1 Hz vitals line to STDOUT as well as the unified log.
///
/// Exists for **tvOS, where the unified log is unreachable**: `log stream --device` is gone from
/// modern macOS, `log collect --device-name` needs root and then fails "Device not configured"
/// (an Apple TV has no USB to fall back to), and libimobiledevice pairs against a different
/// database than Xcode. Stdout, however, IS bridged `xcrun devicectl device process launch
/// --console -e '{"PUNKTFUNK_STATS_STDOUT":"1"}' io.unom.punktfunk` streams these lines straight
/// to the Mac. That is the only way to read a session's numbers with the **stats overlay OFF**,
/// which matters because the overlay is itself a composited layer over the Metal one i.e. a
/// plausible cause of the very present-floor inflation the overlay is used to measure.
/// Env-gated: no cost, and no stdout noise, unless someone is deliberately measuring.
private let statsToStdout = ProcessInfo.processInfo.environment["PUNKTFUNK_STATS_STDOUT"] == "1"
/// 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.
@@ -137,6 +149,25 @@ final class SessionModel: ObservableObject {
/// and under stage-1.
@Published var osFloorP50Ms = 0.0
@Published var osFloorValid = false
/// The deadline link's `preferredFrameLatency` ASK beside its property READBACK (see
/// `PresentLinkInfo` it exists because tvOS has no reachable log). The readback is NOT
/// a grant: it is a plain float property, so it echoes whatever was stored unless the
/// system clamps the setter. readback ask a visible clamp (the one signal the API can
/// give); readback == ask proves nothing `osFloorP50Ms` (the measured vend lead) is the
/// truth-teller (field 2026-08-13: readback 1.00 beside a 32.5 ms floor).
@Published var linkLatencyAskFrames: Float = 0
@Published var linkLatencyFrames: Float = 0
@Published var linkRangeMinHz: Float = 0
@Published var linkRangeMaxHz: Float = 0
@Published var linkDrawables = 0
@Published var linkInfoValid = false
/// Impossible samples the HOST-ANCHORED meters (host+network, end-to-end) refused this
/// second (`LatencyMeter.drainTrimmed`). Nonzero means the clock offset is lying and every
/// host-anchored p50/p95 this window is a TRUNCATED distribution the HUD marks the window
/// suspect instead of letting a trimmed tail pose as a healthy small number (the field
/// "e2e 03 ms" reading, 2026-08-13). Client-local stages can't go negative, so they carry
/// no such term.
@Published var skewTrimPerS = 0
/// The AUDIO plane's latency, from the playback ring (`SessionAudio.Stats`): how much decoded
/// audio is queued ahead of the speaker, and where that PUTS it relative to the picture
/// (positive = audio behind). `audioValid` is false until playback runs.
@@ -683,6 +714,10 @@ final class SessionModel: ObservableObject {
displayValid = false
clientQueueValid = false
osFloorValid = false
linkInfoValid = false
// Drop the previous session's grant too the shared box outlives the session, and a new
// link may never come up (a non-deadline rung has none at all).
PresentLinkInfo.shared.clear()
audioValid = false
lostFrames = 0
lostPct = 0
@@ -904,6 +939,10 @@ final class SessionModel: ObservableObject {
} else {
self.endToEndValid = false
}
// Drained even when the stats drains came back empty with a badly wrong offset
// an entire window is refused and only this counter still tells the story.
self.skewTrimPerS =
self.latency.drainTrimmed() + self.endToEnd.drainTrimmed()
if let d = self.decodeStage.drain() {
self.decodeP50Ms = d.p50Ms
self.decodeValid = true
@@ -923,6 +962,18 @@ final class SessionModel: ObservableObject {
} else {
self.osFloorValid = false
}
// The display link's latency ask + property readback (deadline rung only) a
// LEVEL, not a window, so it is read rather than drained.
if let l = PresentLinkInfo.shared.snapshot() {
self.linkLatencyAskFrames = l.ask
self.linkLatencyFrames = l.latency
self.linkRangeMinHz = l.rangeMin
self.linkRangeMaxHz = l.rangeMax
self.linkDrawables = l.drawables
self.linkInfoValid = true
} else {
self.linkInfoValid = false
}
if let q = self.clientQueue.drain() {
self.clientQueueP50Ms = q.p50Ms
self.clientQueueValid = true
@@ -951,6 +1002,14 @@ final class SessionModel: ObservableObject {
// Swift Int is 64-bit %lld, NOT %d (which is a 32-bit C int); macOS 26's
// strict String(format:) validator rejects the %d/Int mismatch and drops
// the whole line (a cascade error that also mis-blames the float args).
//
// Every invalid-field fallback below MUST be a typed `-1.0` (or a
// `Double(...)`-wrapped value), never a bare `-1`: in this variadic
// `CVarArg` context the ternary does NOT unify to Double the untyped
// literal goes in as Int, and `%f` then reads Int64(-1)'s all-ones bit
// pattern, which IS a quiet NaN. Field 2026-08-13 (tvOS, stage-1, the
// first session ever to have invalid fields while frames flowed): every
// fallback printed `nan`. Latent since the line was added.
format: "fps=%lld presents=%lld e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
+ "decode_p50=%.1f display_p50=%.1f lost=%lld "
+ "floor_p50=%.1f display_adj=%.1f e2e_adj=%.1f queue_p50=%.1f "
@@ -958,22 +1017,35 @@ final class SessionModel: ObservableObject {
// In the log as well as on the HUD because the overlay is only up when
// someone thought to turn it on, and the reports that need these
// numbers arrive after the fact.
+ "audio_buffer=%lld audio_av_offset=%lld",
+ "audio_buffer=%lld audio_av_offset=%lld "
// The deadline link's latency ask + property readback (both -1 on
// non-deadline rungs) appended so the PUNKTFUNK_FRAME_LATENCY
// ladder is readable over the stdout channel with the HUD off,
// which is the only honest way to run it on a tvOS device.
+ "link_ask=%.2f link_readback=%.2f "
// Impossible samples the host-anchored meters refused this window:
// nonzero the clock offset is lying and e2e/hostnet above are
// truncated distributions disregard their p50/p95.
+ "skew_trim=%lld",
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,
self.endToEndValid ? self.endToEndP50Ms : -1.0,
self.endToEndValid ? self.endToEndP95Ms : -1.0,
self.hostNetworkValid ? self.hostNetworkP50Ms : -1.0,
self.decodeValid ? self.decodeP50Ms : -1.0,
self.displayValid ? self.displayP50Ms : -1.0,
lost,
self.osFloorValid ? self.osFloorP50Ms : -1,
self.displayValid ? self.displayAdjP50Ms : -1,
self.endToEndValid ? self.endToEndAdjP50Ms : -1,
self.clientQueueValid ? self.clientQueueP50Ms : -1,
self.osFloorValid ? self.osFloorP50Ms : -1.0,
self.displayValid ? self.displayAdjP50Ms : -1.0,
self.endToEndValid ? self.endToEndAdjP50Ms : -1.0,
self.clientQueueValid ? self.clientQueueP50Ms : -1.0,
self.audioValid ? self.audioBufferMs : -1,
self.audioValid ? self.audioAvOffsetMs : 0)
self.audioValid ? self.audioAvOffsetMs : 0,
self.linkInfoValid ? Double(self.linkLatencyAskFrames) : -1.0,
self.linkInfoValid ? Double(self.linkLatencyFrames) : -1.0,
self.skewTrimPerS)
statsLog.info("\(line, privacy: .public)")
if statsToStdout { print("pf.stats \(line)") }
}
}
}
@@ -128,6 +128,29 @@ struct StreamHUDView: View {
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The deadline link's frame-latency ASK beside its property READBACK. The
// readback is NOT a grant the property echoes whatever we stored (field
// 2026-08-13: 1.00 beside a 32.5 ms `os present` floor). The line earns its
// place because a readback that DIFFERS from the ask is the one clamp signal
// the API can give, and on tvOS the screen is the only place to read either
// (no log is reachable on an Apple TV; see PresentLinkInfo).
if model.linkInfoValid {
Text("link latency ask \(model.linkLatencyAskFrames, specifier: "%.2f") readback \(model.linkLatencyFrames, specifier: "%.2f") · range \(model.linkRangeMinHz, specifier: "%.0f")-\(model.linkRangeMaxHz, specifier: "%.0f") Hz · drawables \(model.linkDrawables)")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.tertiary)
}
// The clock-offset tripwire: host-anchored meters refused samples as
// impossible ( 0 after offset correction) this second. When this shows,
// e2e and host+network above are TRUNCATED distributions a wrong skew
// offset shifted them and the impossible half was trimmed so their
// p50/p95 flatter the stream (the field "e2e 03 ms" reading). Orange on
// purpose: every other stat here stays legible-quiet, but a number that
// has stopped meaning anything must not.
if model.skewTrimPerS > 0 {
Text("clock offset suspect — \(model.skewTrimPerS)/s impossible samples trimmed; e2e & host+network unreliable")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.orange)
}
// Client-queue wait (reassembly receipt decode pull, ABI v9 split): ~0 on
// a healthy stream and hidden as noise; shown from 2 ms a persistent value
// is a client-side standing backlog that pre-split builds displayed as
@@ -28,6 +28,10 @@ struct AboutView: View {
#if !os(tvOS)
@State private var showAcknowledgements = false
/// The in-session controls. They used to announce themselves in a 6-second banner at the start
/// of every stream; that banner is gone, so this page is where they live now including for
/// touch users on a Mac, who saw it too.
@State private var showShortcuts = false
#endif
var body: some View {
@@ -44,6 +48,9 @@ struct AboutView: View {
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
Section {
shortcutsRow
}
Section {
linkRow("Documentation", systemImage: "book", url: Destination.docs)
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
@@ -63,6 +70,21 @@ struct AboutView: View {
// A SHEET, not a push on iPad the settings detail column is deliberately not a
// NavigationStack (an inner one doubles the title bar), so a NavigationLink from here
// pushed into a context with no back button and stranded the licenses on screen.
// A sheet for the same reason Acknowledgements is one see that modifier's note on the
// iPad detail column not being a NavigationStack.
.sheet(isPresented: $showShortcuts) {
NavigationStack {
ShortcutsView(micAvailable: ShortcutsCatalog.micPlausible)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showShortcuts = false }
}
}
}
#if os(macOS)
.frame(width: 560, height: 460)
#endif
}
.sheet(isPresented: $showAcknowledgements) {
NavigationStack {
AcknowledgementsView()
@@ -135,6 +157,24 @@ struct AboutView: View {
.foregroundStyle(.primary)
}
private var shortcutsRow: some View {
Button {
showShortcuts = true
} label: {
HStack {
Label("Shortcuts", systemImage: "command")
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
}
private var acknowledgementsRow: some View {
Button {
showAcknowledgements = true
@@ -168,6 +208,11 @@ struct AboutView: View {
tvAddress("Community", Destination.community)
tvAddress("Source code", Destination.source)
}
// Both push here: this page really is inside a navigation stack on tvOS, which is
// the case the sheets above exist to work around elsewhere.
NavigationLink("Shortcuts") {
ShortcutsView(micAvailable: false) // tvOS has no app-accessible mic
}
NavigationLink("Acknowledgements") { AcknowledgementsView() }
Text("Punktfunk's source is open under MIT or Apache-2.0.")
.font(.geist(20, relativeTo: .caption))
@@ -219,21 +264,39 @@ struct AppIconView: View {
var body: some View {
Group {
if let icon = Self.bundleIcon {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
// iOS ships the icon UNMASKED the springboard applies the rounded shape at
// draw time, so used raw it is a hard-cornered square. macOS bakes its own
// shape (and margins) into the image, and clipping that would cut into it.
.clipShape(RoundedRectangle(
cornerRadius: icon.needsMask ? side * Self.iOSCornerRatio : 0,
style: .continuous))
// The mask is applied ONLY where it is wanted. A `cornerRadius: 0` RoundedRectangle
// is not a no-op it still clips to the layout frame, which crops any art whose
// aspect ratio isn't the frame's (the TV's 400x240 icon lost its ends to it).
// iOS ships the icon UNMASKED the springboard applies the rounded shape at draw
// time, so used raw it is a hard-cornered square. macOS bakes its own shape (and
// margins) into the image, and clipping that would cut into it.
if icon.needsMask {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(
cornerRadius: side * Self.iOSCornerRatio, style: .continuous))
} else {
icon.image
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
}
} else {
monogram
}
}
// tvOS's icon is a 400×240 rectangle, not a squircle framing it square would letterbox
// it inside a box two thirds empty. `side` means HEIGHT there, and the width follows the
// real 5:3 art. A MAX frame rather than a fixed one: with a fixed width the image cannot
// shrink when its row is tight, so it overflows and is clipped by whatever is above it
// instead `.fit` inside a max frame gives back the whole icon, just smaller.
#if os(tvOS)
.frame(maxWidth: side * (400.0 / 240.0), maxHeight: side)
#else
.frame(width: side, height: side)
#endif
.accessibilityHidden(true) // the app's name is the next line
}
@@ -267,7 +330,14 @@ struct AppIconView: View {
else { return nil }
return (Image(uiImage: image), true)
#else
return nil // tvOS: layered icons have no single image to load
// tvOS ships the icon as a parallax image STACK (Back/Circle1/Circle2/Front), which has
// no single image to load which is why this used to return nil and every About page on
// the TV drew the "P" monogram instead of the app's own mark. `AboutAppIcon` is those
// four layers flattened into one asset, generated from the SAME art the stack uses so it
// cannot drift into being a second, subtly different icon. Already masked and composited,
// so it needs no rounding of ours.
guard let image = UIImage(named: "AboutAppIcon") else { return nil }
return (Image(uiImage: image), false)
#endif
}
}
@@ -42,14 +42,22 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
case controller = "Controller"
case interface = "Interface"
case profiles = "Profiles"
/// Trailing, like Profiles: both are built from something other than the settings store, and
/// About is where the strip ends because it is the one section that changes nothing.
case about = "About"
}
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` this screen publishes that value
/// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment
/// here is what left the title, the tab pills and every row label white-on-pale on tvOS.
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The About section's link rows (never used on tvOS, which has no browser).
@Environment(\.openURL) private var openURL
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@@ -61,6 +69,9 @@ struct GamepadSettingsView: View {
/// console's input drop) and while the connect takeover is up; a system presentation never
/// needs the gate and keeps the default.
var controllerActive = true
/// Whether this device has a microphone at all passed through to the About page's shortcuts
/// reference, which must not list a mute key on a device that can't mute anything.
var micAvailable = true
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -132,6 +143,14 @@ struct GamepadSettingsView: View {
/// 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
/// A reading surface opened from the About tab, replacing the row list the way the pin picker
/// does. Depth is 1: neither page opens anything further.
private enum AboutPage: Equatable {
case shortcuts
case licenses
}
@State private var aboutPage: AboutPage?
var body: some View {
GamepadMenuList(
@@ -157,9 +176,9 @@ struct GamepadSettingsView: View {
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
// The picker is one layer deeper its rows aren't sections of anything, so the
// strip would be a control that does nothing while it's up.
if pinTarget == nil { tabStrip }
// The picker and the About reading pages are one layer deeper their rows aren't
// sections of anything, so the strip would be a control that does nothing.
if pinTarget == nil, aboutPage == nil { tabStrip }
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
@@ -326,16 +345,62 @@ struct GamepadSettingsView: View {
if let close { close() } else { dismiss() }
}
/// Where the product actually lives kept together so the three can be checked against the
/// README in one glance (the touch `AboutView` holds the same three).
private enum Destination {
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
}
/// "Version 0.29.0 (100000)" the build number only when it says something the version does
/// not. Mirrors `AboutView.versionLine`; a bug report is worth more with it.
private static var versionLine: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? ""
let build = info?["CFBundleVersion"] as? String
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
return "Version \(short) (\(build))"
}
/// "Settings", or "Pin Work" while the pin picker is up the title is what says which
/// layer the row list currently is.
private var title: String {
pinTarget.map { "Pin “\($0.name)" } ?? "Settings"
if let profile = pinTarget { return "Pin “\(profile.name)" }
switch aboutPage {
case .shortcuts: return "Shortcuts"
case .licenses: return "Acknowledgements"
case nil: return "Settings"
}
}
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
/// picker where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
private var hints: [GamepadHint] {
// A reading page is scrolled, not operated: offering A would be the same lie a dimmed row
// used to tell. Only Back remains.
if aboutPage != nil {
return [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() })]
}
// The About rows open things rather than change them, so A reads "Open" and there is no
// Adjust cell left/right genuinely does nothing there.
if pinTarget == nil, tab == .about {
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section", action: { step(tabBy: 1) })]
: []
return sections + [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() }),
]
}
guard pinTarget != nil else {
// The shoulders change section, so that cell leads where it fits and where the
// shoulders exist at all (see `showsSectionHint`).
@@ -383,6 +448,9 @@ struct GamepadSettingsView: View {
if let profile = pinTarget {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else if let page = aboutPage {
aboutPage = nil
focusID = page == .shortcuts ? "shortcuts" : "licenses"
} else {
performClose()
}
@@ -390,7 +458,53 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
@ViewBuilder
private func rowView(_ row: Row, focused: Bool) -> some View {
switch row.kind {
case .control: controlRow(row, focused: focused)
case .footer:
Text(row.label)
.font(.geist(metrics.detailFont, .medium, relativeTo: .caption))
.monospacedDigit()
.foregroundStyle(ink.fg(focused ? 0.7 : 0.45))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 18)
.animation(.smooth(duration: 0.18), value: focused)
case .heading:
Text(row.label)
.font(.geist(metrics.labelFont, .bold, relativeTo: .headline))
.foregroundStyle(ink.fg(0.75))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, metrics.rowHPad)
.padding(.top, 14)
.padding(.bottom, 2)
case .prose:
// Focus here means "this is the part you are scrolled to", not "press A" so it is a
// quiet wash rather than the control rows' full glass.
VStack(alignment: .leading, spacing: 4) {
Text(row.label)
.font(.geistFixed(metrics.valueFont, .medium))
.foregroundStyle(ink.fg(0.95))
.fixedSize(horizontal: false, vertical: true)
if !row.value.isEmpty {
Text(row.value)
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, metrics.rowHPad)
.padding(.vertical, metrics.rowVPad * 0.7)
.background {
RoundedRectangle(cornerRadius: metrics.rowCorner, style: .continuous)
.fill(ink.fg(focused ? 0.08 : 0))
}
.animation(.smooth(duration: 0.18), value: focused)
}
}
private func controlRow(_ row: Row, focused: Bool) -> some View {
let m = metrics
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
@@ -502,10 +616,23 @@ struct GamepadSettingsView: View {
/// `activate(id:)`, not per closure, so no row builder can forget it.
/// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.)
var enabled = true
/// How this row DRAWS. Every tab but About is `.control` the glass row with a label and
/// a value. About is a reading surface as much as a menu, so it also has a heading and a
/// block of prose, which are rows only so the focus list can scroll them (the same trick
/// `Licenses.chunked` plays for tvOS focus).
var kind: Kind = .control
/// Left/right step; returns whether the value actually changed (false boundary thud).
let adjust: (Int) -> Bool
/// A cycle forward (wrapping) / flip.
let activate: () -> Void
enum Kind {
case control
case heading
case prose
/// Quiet, centred trailing text the About tab's version line.
case footer
}
}
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
@@ -527,9 +654,133 @@ struct GamepadSettingsView: View {
/// controller wiring and the tvOS focus engine carry over as is).
private var rows: [Row] {
if let profile = pinTarget { return pinRows(for: profile) }
if let page = aboutPage {
switch page {
case .shortcuts: return shortcutRows
case .licenses: return licenseRows
}
}
if tab == .about { return aboutRows }
return allRows.filter { $0.tab == tab }
}
// MARK: - About
/// The About section: the ways out, plus the two reading surfaces. The identity itself (icon,
/// name, version, tagline) is the HEADER while this tab is up see `aboutIdentity` not a
/// row, so the list holds no focus stop that does nothing when pressed.
private var aboutRows: [Row] {
var list: [Row] = [
aboutAction(
id: "shortcuts", icon: "command", label: "Shortcuts", value: "While streaming",
detail: "What to press during a session on this device — and on a controller.",
open: .shortcuts),
aboutAction(
id: "licenses", icon: "text.document", label: "Acknowledgements",
value: "MIT or Apache-2.0",
detail: "Punktfunk's own licence and the third-party components it uses.",
open: .licenses),
]
list.append(contentsOf: [
aboutLink(id: "docs", icon: "book", label: "Documentation", url: Destination.docs),
aboutLink(
id: "community", icon: "bubble.left.and.bubble.right", label: "Community",
url: Destination.community),
aboutLink(
id: "source", icon: "chevron.left.forwardslash.chevron.right",
label: "Source code", url: Destination.source),
])
// The version sits UNDER the rows rather than in a header card above them. The card that
// used to head this tab carried the app icon, and on tvOS that icon is a 400x240
// rectangle that would not survive contact with a layout built for square art three
// attempts at framing it were still cropping it on the real TV. A version string answers
// the only question anyone actually opens About to ask, and has no aspect ratio to get
// wrong. `.footer` draws it quiet and centred, so it reads as a footer and not a row you
// failed to press.
list.append(Row(
id: "version", tab: .about, icon: "", label: Self.versionLine, value: "",
detail: "", adjustable: false, enabled: true, kind: .footer,
adjust: { _ in false }, activate: {}))
return list
}
private func aboutAction(
id: String, icon: String, label: String, value: String, detail: String, open: AboutPage
) -> Row {
Row(
id: id, tab: .about, icon: icon, label: label, value: value, detail: detail,
adjustable: false,
adjust: { _ in false },
activate: {
// Focus lands on the page's first row the focus list's reconcile follows this
// id when the row set swaps underneath it (the pin picker's pattern).
focusID = open == .shortcuts ? shortcutRows.first?.id : licenseRows.first?.id
aboutPage = open
})
}
/// tvOS has no browser and no `openURL`, so an address there is text to read off the screen
/// rather than a link to nowhere the same call the touch About page makes.
private func aboutLink(id: String, icon: String, label: String, url: URL) -> Row {
let shown = url.absoluteString.replacingOccurrences(of: "https://", with: "")
#if os(tvOS)
return Row(
id: id, tab: .about, icon: icon, label: label, value: shown,
detail: "Open this address on a phone or computer.",
adjustable: false, adjust: { _ in false }, activate: {})
#else
return Row(
id: id, tab: .about, icon: icon, label: label, value: shown,
detail: "Opens in your browser.",
adjustable: false, adjust: { _ in false }, activate: { openURL(url) })
#endif
}
/// The shortcuts reference the same `ShortcutsCatalog` the touch About page renders, so the
/// two can never drift.
private var shortcutRows: [Row] {
ShortcutsCatalog.groups(micAvailable: micAvailable).flatMap { group -> [Row] in
[aboutText(id: "group-\(group.title)", label: group.title, kind: .heading)]
+ group.items.map { item in
aboutText(
id: "sc-\(group.title)-\(item.keys)", label: item.keys, value: item.text,
kind: .prose)
}
}
}
/// The licence wall, one row per pre-chunked page (`Licenses.chunked`, which exists so tvOS
/// can page it by focus steps) so it scrolls with the stick and needs no machinery here.
private var licenseRows: [Row] {
var list: [Row] = [
aboutText(id: "lic-heading", label: "Punktfunk", kind: .heading),
aboutText(
id: "lic-summary",
label: "Punktfunk's source is open under MIT or Apache-2.0. It ships the Geist "
+ "typeface under the SIL Open Font License 1.1, and uses the third-party "
+ "components below, each under its own license.",
kind: .prose),
]
for (i, chunk) in Licenses.chunked(Licenses.appLicense).enumerated() {
list.append(aboutText(id: "lic-app-\(i)", label: chunk, kind: .prose))
}
list.append(aboutText(
id: "lic-third-heading", label: "Third-party software", kind: .heading))
for (i, chunk) in Licenses.thirdPartyNoticesChunks.enumerated() {
list.append(aboutText(id: "lic-third-\(i)", label: chunk, kind: .prose))
}
return list
}
private func aboutText(
id: String, label: String, value: String = "", kind: Row.Kind
) -> Row {
Row(
id: id, tab: .about, icon: "", label: label, value: value, detail: "",
adjustable: false, enabled: true, kind: kind,
adjust: { _ in false }, activate: {})
}
/// Every row on the screen, tagged with its section. Built as one list (not per tab) so the
/// platform-conditional insertions below can still place a row RELATIVE to another by id.
private var allRows: [Row] {
@@ -0,0 +1,181 @@
// The in-session controls, written down once and read by every surface that shows them.
//
// This replaced the start-of-stream banner (ContentView's `showShortcutHint`): a 6-second pill
// that told you the controls exactly once, while you were busy looking at the thing you had just
// connected to, and then never again. A reference you can OPEN answers the question at the moment
// it is actually asked which is the second session, not the first.
//
// The catalog is data rather than a view so both About pages render the same words: the touch
// `AboutView` (a Form) and the controller-first `GamepadAboutView` (a console list). The banner
// was macOS/tvOS-only, so deleting it would have cost Mac TOUCH users the one place those keys
// were written down hence the touch surface gets this too, not just the gamepad UI.
//
// Per-platform by `#if`, because the honest answer really is different: tvOS has no keyboard and
// no menu bar, iOS has a touch gesture nothing else has, and macOS is the only one that has to
// explain mouse capture. A controller's chords are the one section common to all three they are
// the same buttons on every client (`GamepadCapture.escapeChord` / `.statsChord`), which is the
// whole point of a cross-client chord.
import AVFoundation
import PunktfunkKit
import SwiftUI
/// One line of the reference: what you press, and what it does.
struct ShortcutItem: Identifiable {
/// Stable within its group the keys are unique per group by construction.
var id: String { keys }
/// The chord itself, rendered monospaced so -style runs stay legible.
let keys: String
let text: String
}
struct ShortcutGroup: Identifiable {
var id: String { title }
let title: String
let items: [ShortcutItem]
}
enum ShortcutsCatalog {
/// Whether a mute key is worth listing when no session is running, for the About page reached
/// from settings. `SessionModel.micAvailable` is the authority DURING a session it also
/// consults the profile the session actually resolved but a reference page opened between
/// sessions has no session to ask, so it answers the device-level half of the same question:
/// a platform with an app-accessible input, the mic setting on, and the OS not refusing.
/// `.notDetermined` counts, exactly as it does there: the prompt is simply still pending.
static var micPlausible: Bool {
#if os(tvOS)
return false // no app-accessible microphone
#else
guard UserDefaults.standard.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true
else { return false }
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized, .notDetermined: return true
default: return false
}
#endif
}
/// `micAvailable` gates the mute row a device with no microphone would otherwise be told
/// about a key that does nothing, which is the failure the old banner already avoided.
static func groups(micAvailable: Bool) -> [ShortcutGroup] {
var groups: [ShortcutGroup] = []
#if os(macOS)
var keyboard: [ShortcutItem] = [
.init(keys: "Click", text: "Capture the mouse and keyboard for the stream"),
.init(keys: "⌃⌥⇧Q", text: "Release the mouse and keyboard back to this Mac"),
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
]
if micAvailable {
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
}
groups.append(.init(title: "Keyboard", items: keyboard))
#elseif os(iOS)
// iPad with a hardware keyboard gets the same cross-client set as the Mac (StreamCommands
// publishes it either way); a phone simply never sees a keyboard to press it on.
var keyboard: [ShortcutItem] = [
.init(keys: "⌃⌥⇧Q", text: "Release the pointer back to this device"),
.init(keys: "⌃⌥⇧D", text: "Disconnect"),
.init(keys: "⌃⌥⇧S", text: "Cycle the statistics overlay"),
]
if micAvailable {
keyboard.append(.init(keys: "⌃⌥⇧A", text: "Mute or unmute the microphone"))
}
groups.append(.init(title: "Hardware keyboard", items: keyboard))
groups.append(.init(title: "Touch", items: [
.init(keys: "Three-finger tap", text: "Cycle the statistics overlay"),
]))
#elseif os(tvOS)
// The remote section leads on tvOS: it carries the ONLY exits. Menu/B is swallowed during
// a session (ContentView's `.onExitCommand {}`), so a user who does not know the hold
// gesture is genuinely stuck which is why this was the one banner that could not simply
// be deleted without putting the words somewhere findable first.
groups.append(.init(title: "Siri Remote", items: [
.init(keys: "Hold Back", text: "Disconnect"),
.init(keys: "Touch surface", text: "Move the pointer"),
.init(keys: "Press", text: "Click"),
.init(keys: "Play/Pause", text: "Right-click"),
.init(keys: "Hold Play/Pause", text: "Cycle the statistics overlay"),
]))
#endif
// Every client's controller speaks these two chords see GamepadCapture.escapeChord and
// .statsChord, which a test pins against their GameController element lists.
groups.append(.init(title: "Controller", items: [
.init(keys: "L1 + R1 + Start + Select", text: "Hold to disconnect"),
.init(keys: "Select + X", text: "Cycle the statistics overlay"),
.init(keys: "Hold Select", text: "Press the host's guide button"),
]))
return groups
}
}
/// The standard-interface reference a sheet from `AboutView` on iOS/macOS, a pushed page on
/// tvOS so the keys the start-of-stream banner used to carry are still one press away.
/// (The controller-first surface renders the same catalog itself; see `GamepadAboutView`.)
struct ShortcutsView: View {
let micAvailable: Bool
var body: some View {
#if os(tvOS)
// No `Form`/`.formStyle(.grouped)` worth using at 10 feet, and the rows are read, not
// operated a plain scrolling column at TV sizes says the same thing with less chrome.
ScrollView {
VStack(alignment: .leading, spacing: 30) {
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
VStack(alignment: .leading, spacing: 12) {
Text(group.title)
.font(.geist(28, .semibold, relativeTo: .headline))
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 20) {
Text(item.keys)
.font(.geistFixed(22, .medium))
.frame(minWidth: 300, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Text(item.text)
.font(.geist(22, relativeTo: .caption))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
.frame(maxWidth: 1000, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(60)
}
.navigationTitle("Shortcuts")
#else
form
#endif
}
#if !os(tvOS)
private var form: some View {
Form {
ForEach(ShortcutsCatalog.groups(micAvailable: micAvailable)) { group in
Section(group.title) {
ForEach(group.items) { item in
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.keys)
.font(.geistFixed(13, .medium))
.foregroundStyle(.primary)
// A fixed column keeps the descriptions aligned; the chords vary
// from "Click" to "L1 + R1 + Start + Select".
.frame(minWidth: 132, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Text(item.text)
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 2)
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Shortcuts")
}
#endif
}
@@ -18,7 +18,10 @@ import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadPairView: View {
@Environment(\.gamepadInk) private var ink
/// Resolved from the stored palette, NOT from `\.gamepadInk` this screen publishes that
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@@ -382,12 +382,29 @@ public final class PunktfunkConnection {
/// the client draws its own (a visible system cursor over the stream).
public private(set) var resolvedCompositor: Compositor = .auto
/// Host clock minus client clock (nanoseconds), from the connect-time wall-clock skew handshake
/// (`punktfunk_connection_clock_offset_ns`). Add it to a local `CLOCK_REALTIME` instant to
/// express that instant in the host's capture clock the clock each `AccessUnit.ptsNs` is
/// stamped in so a glass-to-glass latency (present/enqueue time minus `ptsNs`) is valid across
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
public private(set) var clockOffsetNs: Int64 = 0
/// Host clock minus client clock (nanoseconds) LIVE: the connect-time skew handshake's
/// estimate, kept fresh by the core's mid-stream re-syncs (every 60 s plus immediately on a
/// suspected wall-clock step; `punktfunk_connection_clock_offset_now_ns`, ABI v10). Add it to
/// a local `CLOCK_REALTIME` instant to express that instant in the host's capture clock the
/// clock each `AccessUnit.ptsNs` is stamped in so a glass-to-glass latency (present/enqueue
/// time minus `ptsNs`) is valid across machines. `0` = no correction (an older host that
/// didn't answer, synchronized clocks, or a closed connection).
///
/// LIVE means DO NOT CACHE. Until 2026-08-13 this was a connect-time snapshot, and the
/// core's own doc names the failure: "after an NTP step or slow drift the connect-time value
/// silently corrupts every capture-clock comparison." The field evidence was stark two
/// sessions minutes apart against the same wired host read hostnet 1721 ms, then a
/// physically impossible 4.4 ms (the host is a VM; VM wall clocks step), and LatencyMeter's
/// impossible-sample guard silently trimmed the shifted-negative half, so the HUD showed a
/// plausible small number instead of an alarm. Read this property at each use it is an
/// atomic load behind the FFI and never park it in a `let` or a closure capture list.
/// Cross-thread reads follow the `framesDropped()` precedent.
public var clockOffsetNs: Int64 {
guard let handle else { return 0 }
var offset: Int64 = 0
_ = punktfunk_connection_clock_offset_now_ns(handle, &offset)
return offset
}
/// The video encoder bitrate (kbps) the host actually configured the requested
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
@@ -635,9 +652,6 @@ public final class PunktfunkConnection {
var comp: UInt32 = 0
_ = punktfunk_connection_compositor(handle, &comp)
resolvedCompositor = Compositor(rawValue: comp) ?? .auto
var offset: Int64 = 0
_ = punktfunk_connection_clock_offset_ns(handle, &offset)
clockOffsetNs = offset
var br: UInt32 = 0
_ = punktfunk_connection_bitrate(handle, &br)
resolvedBitrateKbps = br
@@ -15,8 +15,9 @@ import Foundation
/// `record(ptsNs:atNs:offsetNs:)` at present.
///
/// For the host-anchored intervals (capture) the sample is `end + offset - pts_ns`, where
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the connect-time **clock-skew
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client) makes the difference valid
/// `pts_ns` is the host's capture wall clock (the AU's pts) and the LIVE **clock-skew
/// offset** (`PunktfunkConnection.clockOffsetNs`, host minus client, mid-stream re-synced
/// read it per record, never cached) makes the difference valid
/// across machines. `offsetNs == 0` means an old host that didn't answer the skew handshake (or
/// genuinely synced clocks) the number is then only meaningful same-host, and the HUD tags the
/// end-to-end line `(same-host clock)`.
@@ -24,6 +25,8 @@ public final class LatencyMeter: @unchecked Sendable {
private let lock = NSLock()
private var samplesUs: [Int64] = []
private var skewCorrected = false
/// Samples `record` refused as impossible since the last `drainTrimmed` (see the guard).
private var trimmed = 0
/// The most recent sample and the instant it ended, for `latestSample(asOfNs:maxAgeMs:)`
/// a LEVEL, not a window, so `drain` deliberately leaves both alone.
private var latestNs: Int64 = 0
@@ -49,8 +52,19 @@ public final class LatencyMeter: @unchecked Sendable {
public func record(ptsNs: UInt64, atNs: Int64, offsetNs: Int64) {
let latNs = atNs &+ offsetNs &- Int64(bitPattern: ptsNs)
// Drop absurd values (a clock step, a wildly wrong offset, garbage pts, or a stage whose
// start stamp is missing/after its end) samples are clamped to (0, 10 s).
guard latNs > 0, latNs < 10_000_000_000 else { return }
// start stamp is missing/after its end) samples are clamped to (0, 10 s). COUNTED, not
// silent: a cluster of non-positive samples is the signature of a wrong clock offset
// (client-local stages can't go negative), and a meter that quietly trims the impossible
// half of a shifted distribution presents the surviving tail as a plausible small number
// field 2026-08-13: "e2e 03 ms p50 / 23 ms p95" on a session whose true hostnet was
// ~18 ms. `drainTrimmed` surfaces the count so the window can be MARKED suspect instead
// of looking healthy.
guard latNs > 0, latNs < 10_000_000_000 else {
lock.lock()
trimmed += 1
lock.unlock()
return
}
lock.lock()
samplesUs.append(latNs / 1000)
latestNs = latNs
@@ -99,6 +113,18 @@ public final class LatencyMeter: @unchecked Sendable {
public let skewCorrected: Bool
}
/// Take-and-reset the count of impossible samples `record` refused (see its guard). Drained
/// SEPARATELY from `drain()` on purpose: with a badly wrong offset EVERY sample of a window
/// can be non-positive, `drain()` then returns `nil` and a count folded into `Stats` would
/// vanish with it, hiding the very windows that scream loudest. This survives an empty window.
public func drainTrimmed() -> Int {
lock.lock()
defer { lock.unlock() }
let n = trimmed
trimmed = 0
return n
}
/// Percentiles over the samples accumulated since the last drain, then reset the window. `nil`
/// when no samples arrived in the interval.
public func drain() -> Stats? {
@@ -56,17 +56,27 @@ enum WindowedPresentMode: String, Sendable {
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
/// render "way too bright" (no `edrMetadata` no reference-white anchoring); a LARGER value renders
/// dimmer. Matches the host's standard PQ reference white.
/// dimmer.
///
/// This is one half of a pair: the host has to map SDR content into the PQ container at the SAME
/// luminance, and pins it to 203 in `pf-vdisplay`'s `SDR_REFERENCE_WHITE_NITS`. When they disagree
/// every pixel is off by the ratio a gamescope host left on gamescope's own 400-nit default put
/// the stream nearly a stop bright, which read as a glaring, over-saturated Steam UI and washed-out
/// HDR game content at the same time. Change one end without the other and that gap re-opens.
private let hdrReferenceWhiteNits: Float = 203.0
/// 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 {
/// The SDR layer's colour tag. `colorspace = nil` means NO colour matching: the BT.709-encoded
/// stream is handed to the compositor untagged and drawn in the display's native space. That is
/// mild oversaturation on a P3 Mac or iPad, and on a tvOS display composited for HDR it also lifts
/// the black floor the 2026-08-13 field report of greys where blacks should be, which arrived
/// with the client's own HDR switch already OFF, so no other stage had tagged those pixels either.
/// Tagging lets CoreAnimation colour-match into whatever the output actually is, which is the
/// strictly correct rendering, so it is now the default.
///
/// `PUNKTFUNK_SDR_COLORSPACE=none` restores the old untagged look the A/B lever if a panel
/// regresses, or for anyone who preferred the more vivid rendition.
private let sdrColorspace: CGColorSpace? = {
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] != "none" else {
return nil
}
return CGColorSpace(name: CGColorSpace.sRGB)
@@ -425,6 +435,14 @@ public final class MetalVideoPresenter {
/// 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
/// Has `configureColor` run even once? `hdrActive` starts `false`, so a session that is SDR from
/// the first frame matches the initial state and used to fall straight through `configure`'s
/// guard the layer then kept `make()`'s bare config, which never assigns a colour space, and
/// the SDR stream presented untagged for the whole session. That also made
/// `PUNKTFUNK_SDR_COLORSPACE` dead code on exactly the sessions it was meant to fix, so an
/// operator A/B-ing it in the field saw nothing change. Same-state calls after the first are
/// still no-ops, which is what the guard is for.
private var didConfigureColor = 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`.
@@ -549,6 +567,11 @@ public final class MetalVideoPresenter {
layer.contentsGravity = .resizeAspect
// Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link /
// MAIN thread) has to block waiting for one to free.
// This is the STAGE-2/3 depth. Stage-4 (deadline pacing, the iOS/tvOS default) never
// calls `nextDrawable()` the link vends every drawable so the third slot only gives
// the compositor room to queue a second present ahead of scanout, i.e. the two-refresh
// present floor. `Stage2Pipeline.startDeadlinePresenter` clamps it to 2 for that pacing;
// keep the two in step if this number ever changes.
layer.maximumDrawableCount = 3
return MetalVideoPresenter(
@@ -592,13 +615,16 @@ public final class MetalVideoPresenter {
stagingLock.lock()
let passthrough = stagedDisplayHeadroom > 1.0
stagingLock.unlock()
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
guard !didConfigureColor || hdr != hdrActive
|| (hdr && passthrough != hdrPassthroughActive)
else { return }
hdrActive = hdr
hdrPassthroughActive = passthrough
#else
guard hdr != hdrActive else { return }
guard !didConfigureColor || hdr != hdrActive else { return }
hdrActive = hdr
#endif
didConfigureColor = true
configureColor(hdr: hdr)
}
@@ -628,9 +654,10 @@ public final class MetalVideoPresenter {
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.
// pf_frag_hdr_tv shader tone-maps to SDR instead. Its output is BT.709, so it
// carries the same SDR tag as a genuinely SDR session.
layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil
layer.colorspace = sdrColorspace
}
#else
layer.pixelFormat = .rgba16Float
@@ -641,12 +668,11 @@ public final class MetalVideoPresenter {
layer.edrMetadata = makeEDR(lastHdrMeta)
#endif
} else {
// 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).
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable, tagged so CoreAnimation
// colour-matches it into the output rather than drawing it in the panel's native
// space (see sdrColorspace; PUNKTFUNK_SDR_COLORSPACE=none restores untagged).
layer.pixelFormat = .bgra8Unorm
layer.colorspace = sdrColorspaceOverride
layer.colorspace = sdrColorspace
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil
@@ -260,13 +260,22 @@ final class SessionPresenter {
// value is deliberately ignored). The user-facing choice is the INTENT
// (PresentPriority): latency (newest-wins zero-queue store) vs smoothness (a FIFO jitter
// buffer; on macOS it additionally paces presents onto the vsync grid so the buffer
// drains on display cadence). Stage-1 is reachable only via env in DEBUG; release maps
// it back to the default (the stage-1 pump below stays the automatic Metal-missing
// fallback).
// drains on display cadence). Stage-1 resolves from the persisted picker only in DEBUG;
// in release the ENV alone reaches it (the stage-1 pump below stays the automatic
// Metal-missing fallback either way).
#if DEBUG
let allowStage1 = true
#else
let allowStage1 = false
// The gate exists so a LEFTOVER value can't revive the freeze-prone fallback but the
// persisted picker is no longer read at all (setting: nil below), so the only channel
// left is the env, and an env var is never leftover: it takes a devicectl/Xcode launch
// to exist. It must stay openable on Release because Release is the only build that
// measures presentation honestly, and stage-1 is the one rung that presents on the
// hardware video plane instead of through the GPU compositor the A/B for the tvOS
// two-refresh present floor (field 2026-08-13: PUNKTFUNK_PRESENTER=stage1 on a Release
// build silently ran stage-4, which would have false-negatived that A/B).
let allowStage1 =
ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"] == "stage1"
#endif
let explicit = PresenterChoice.explicit(
setting: nil, // the legacy DefaultsKey.presenter picker value is no longer read
@@ -336,7 +345,7 @@ final class SessionPresenter {
} else {
let pump = StreamPump()
pump.start(
connection: connection, layer: baseLayer,
connection: connection, layer: baseLayer, endToEndMeter: endToEndMeter,
onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump
}
@@ -271,6 +271,64 @@ final class LatestBox<T>: @unchecked Sendable {
}
}
/// The deadline link's frame-latency ASK and property READBACK, published for the HUD to render.
///
/// A readback is NOT a grant. `preferredFrameLatency` is a plain read-write float
/// (CAMetalDisplayLink.h carries no doc contract), so reading it returns whatever we last
/// stored unless the system actively clamps the setter and the 2026-08-13 field run proved
/// how misleading that is: it read 1.00 while the measured vend lead sat at 1.95 refresh
/// periods. The number that tells the truth about scheduling is the vend lead (the HUD's
/// `os present` floor), never this property. The line still earns its place twice over: a
/// readback that DIFFERS from the ask is the one clamp signal the API can give, and the ask
/// must be visible on screen because **on tvOS no log is reachable** `log stream --device`
/// is gone from modern macOS, `log collect --device-name` needs root and then fails "Device
/// not configured" because an Apple TV has no USB to fall back to, and the libimobiledevice
/// pairing is a different database from Xcode's. Console.app is a GUI.
///
/// A process-global rather than a sixth parameter threaded through SessionModel StreamView
/// controller SessionPresenter Stage2Pipeline delegate: it is write-once-per-session
/// diagnostics, and this file already keeps `presentDebug`/`presentLog` at file scope. Reset by
/// `clear()` at session start so a stale session's answer can never be read as this one's.
public final class PresentLinkInfo: @unchecked Sendable {
public static let shared = PresentLinkInfo()
private let lock = NSLock()
private var ask: Float = 0
private var latency: Float = 0
private var rangeMin: Float = 0
private var rangeMax: Float = 0
private var drawables: Int = 0
private var present = false
private init() {}
func publish(ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int) {
lock.lock()
self.ask = ask
self.latency = latency
self.rangeMin = rangeMin
self.rangeMax = rangeMax
self.drawables = drawables
present = true
lock.unlock()
}
/// Session start a link that never comes up must not leave the previous one's answer up.
public func clear() {
lock.lock()
present = false
lock.unlock()
}
/// `nil` until the link's first update (or on a non-deadline rung, which has no link).
public func snapshot()
-> (ask: Float, latency: Float, rangeMin: Float, rangeMax: Float, drawables: Int)?
{
lock.lock()
defer { lock.unlock() }
return present ? (ask, latency, rangeMin, rangeMax, drawables) : nil
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
@@ -312,9 +370,24 @@ private final class FrameRateHint: @unchecked Sendable {
return p
}
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
#if os(tvOS)
// A TV is a FIXED-rate display: there is no ProMotion panel to lift and no Pencil to
// sample for, so the `max(hz, 120)` ceiling below asks a 60 Hz Apple TV to accept
// anything up to 120. A range is a promise about how variable our cadence may be, and a
// scheduler handed 60120 on a fixed 60 Hz display has every reason to keep a frame of
// slack in hand which is what a two-refresh `targetPresentationTimestamp` IS. Pin all
// three bounds to the stream rate so the deadline has nothing to hedge against.
// (Field 2026-08-13, Apple TV 4K / tvOS 27: `os present` stuck at ~2 × 16.67 with
// `preferredFrameLatency = 1` asked for and re-asserted every update; shrinking the
// drawable pool to 2 moved it not at all.) `boosted` is deliberately ignored it exists
// for pen proximity, which tvOS does not have.
_ = boosted
return CAFrameRateRange(minimum: hz, maximum: hz, preferred: hz)
#else
let cap = max(hz, 120)
let preferred = boosted ? cap : hz
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
#endif
}
}
@@ -435,18 +508,28 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let phase: PhaseReporter?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
/// would read ~1 under direct-to-display, tracks VRR rate changes.
/// shown display/e2e numbers. Self-adapting: ~1 refresh period is the goal, ~2 means the
/// compositor is running a frame ahead of us (what a 3-slot drawable pool bought it before
/// `startDeadlinePresenter` clamped stage-4 to 2). Tracks VRR rate changes.
private let floorMeter: LatencyMeter?
/// One-shot: log the link's EFFECTIVE preferredFrameLatency after the first re-assert
/// reads 1 while vendLeadMs sits at ~2 periods the scheduler ignores the request while
/// the layer is composited (the promotion hunt); reads 2 the system clamped it outright.
/// The pool depth this session vends from (`startDeadlinePresenter` sets it on the layer).
/// Carried only so the one-shot line below reports the two halves of the depth question
/// together a `preferredFrameLatency` of 1 against a 3-slot pool is the configuration that
/// measured a two-refresh floor in the field, and reading either number alone hides that.
private let drawableCount: Int
/// The `preferredFrameLatency` this session asks for 1 by default, PUNKTFUNK_FRAME_LATENCY
/// for the on-device ladder (see `startDeadlinePresenter` for the ladder's design).
private let latencyAsk: Float
/// One-shot: log the link's preferredFrameLatency READBACK after the first re-assert. A
/// readback differing from the ask the system clamps the property (the one clamp signal
/// it can give); a readback EQUAL to the ask proves nothing only vendLeadMs does (see
/// PresentLinkInfo's doc for the field lesson).
private var loggedEffective = false
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
phase: PhaseReporter?
phase: PhaseReporter?, drawableCount: Int, latencyAsk: Float
) {
self.stash = stash
self.renderSignal = renderSignal
@@ -454,23 +537,34 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
self.stats = stats
self.floorMeter = floorMeter
self.phase = phase
self.drawableCount = drawableCount
self.latencyAsk = latencyAsk
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range
}
// Re-assert the minimum-latency request every update (cheap compare): it was set once
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// Re-assert the latency ask every update (cheap compare): it was set once before
// add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// thing the vendLeadMs stat exists to catch belt and braces.
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
if link.preferredFrameLatency != latencyAsk { link.preferredFrameLatency = latencyAsk }
// Publish every update, not just the first: the range is re-applied from the staged hint
// above (mode switch / rate change), and `preferredFrameLatency` is re-asserted right
// here so the readback can change mid-session, and a write-once snapshot would keep
// showing the answer to a question we have since asked again. Cheap: five stores under
// an uncontended lock, once per refresh.
let range = link.preferredFrameRateRange
PresentLinkInfo.shared.publish(
ask: latencyAsk, latency: link.preferredFrameLatency, rangeMin: range.minimum,
rangeMax: range.maximum, drawables: drawableCount)
if !loggedEffective {
loggedEffective = true
let range = link.preferredFrameRateRange
let msg = String(
format: "deadline link up: effective preferredFrameLatency=%.2f "
+ "range=%.0f-%.0f preferred=%.0f",
link.preferredFrameLatency, range.minimum, range.maximum, range.preferred ?? 0)
format: "deadline link up: preferredFrameLatency ask=%.2f readback=%.2f "
+ "maxDrawables=%d range=%.0f-%.0f preferred=%.0f",
latencyAsk, link.preferredFrameLatency, drawableCount,
range.minimum, range.maximum, range.preferred ?? 0)
presentLog.info("\(msg, privacy: .public)")
}
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
@@ -729,7 +823,13 @@ public final class Stage2Pipeline {
/// (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
/// LIVE hostclient clock offset, read AT EACH RECORD never cached per session. Until
/// 2026-08-13 this was a `let` snapshot of the connect-time handshake, and on a host whose
/// wall clock steps (a VM under NTP) the frozen value silently shifted every host-anchored
/// stat field evidence: hostnet 1721 ms one session, a physically impossible 4.4 ms the
/// next, same wired host. The core re-syncs the estimate mid-stream (60 s + step detection);
/// each call is an atomic load behind the FFI.
private var clockOffset: () -> Int64 = { 0 }
/// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()`
/// otherwise a pump iteration already past its `token.isStopped` check can rebuild a decode session
/// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by
@@ -831,7 +931,7 @@ public final class Stage2Pipeline {
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) {
offsetNs = connection.clockOffsetNs
clockOffset = { connection.clockOffsetNs } // live (re-synced) see the field doc
recovery.bind(connection) // arm host-keyframe recovery for this session
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
@@ -1001,7 +1101,7 @@ public final class Stage2Pipeline {
let ring = ring
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let clockOffset = clockOffset
let renderSignal = renderSignal
let renderStopped = renderStopped
// Present policy the user's V-Sync setting (default OFF = immediate, the long-proven
@@ -1075,7 +1175,7 @@ public final class Stage2Pipeline {
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
// End-to-end = captureon-glass, measured directly (skew-corrected via the
// connect-time clock offset) the HUD headline.
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
@@ -1134,11 +1234,52 @@ public final class Stage2Pipeline {
let presenter = presenter
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let clockOffset = clockOffset
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
// Shrink the drawable pool to 2 for THIS pacing the measured fix for a present floor
// stuck at two refreshes (field 2026-08-13, Apple TV 4K / tvOS 27: `os present +32.5` at
// 60 Hz = 1.95 × 16.67, i.e. the system running a whole frame ahead of us).
//
// `maximumDrawableCount` is 3 from MetalVideoPresenter.make(), and its rationale there
// "more in-flight drawables before nextDrawable() has to block" is a STAGE-2 concern.
// Stage-4 never calls nextDrawable(): every drawable is vended by the link
// (`update.drawable` stash `render(into:)`), so the third slot buys this path nothing
// and costs it a refresh a pool of 3 is exactly the room the compositor needs to keep
// two presents queued ahead of scanout, which is what `preferredFrameLatency = 1` is
// asking it not to do. Two slots is the shallowest pool that still double-buffers: one
// vended (stashed or being rendered), one being scanned out.
//
// Set HERE, not on the link thread: this runs before either the render thread or the link
// thread exists, so the layer still has a single writer (the render thread owns
// drawableSize/format afterwards see MetalVideoPresenter's threading notes).
// PUNKTFUNK_DRAWABLE_COUNT=3 restores the old depth for an on-glass A/B without a
// rebuild; values outside 2...3 are ignored (CAMetalLayer's own accepted range).
let drawableCount =
ProcessInfo.processInfo.environment["PUNKTFUNK_DRAWABLE_COUNT"]
.flatMap(Int.init)
.flatMap { (2...3).contains($0) ? $0 : nil } ?? 2
layer.maximumDrawableCount = drawableCount
// The frame-latency ASK (default 1 wake as late as fits: latch the NEXT refresh).
// PUNKTFUNK_FRAME_LATENCY overrides it for the on-device ladder. The property is a
// FLOAT, so sub-frame asks (0.5) are expressible; whether the scheduler honours them
// or reacts to the property at all is exactly what the ladder measures. Field
// 2026-08-13 (Apple TV 4K, tvOS 27): ask 1 vend lead 1.95 refresh periods, and the
// readback echoed the ask throughout (it is a plain property see PresentLinkInfo).
// The discriminating runs, watching `os present` (the vend lead), are:
// ask=2 lead grows to ~3 the property WORKS and the tvOS floor is ~ask+1;
// lead stays ~2 the property is INERT here stop pulling this lever.
// ask=0.5 any lead below ~1.9 a real in-regime win to then tune.
// Clamped to 0...4: negatives/NaN are meaningless, and beyond 4 asked-for frames of
// latency nothing is being measured.
let latencyAsk =
ProcessInfo.processInfo.environment["PUNKTFUNK_FRAME_LATENCY"]
.flatMap(Float.init)
.flatMap { $0.isFinite ? min(max($0, 0), 4) : nil } ?? 1
let floorMeter = presentFloorMeter
let phaseReporter = phaseReporter
// The link starts LAZILY the render thread triggers this after the FIRST decoded
@@ -1151,9 +1292,10 @@ public final class Stage2Pipeline {
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter, phase: phaseReporter)
floorMeter: floorMeter, phase: phaseReporter,
drawableCount: drawableCount, latencyAsk: latencyAsk)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
link.preferredFrameLatency = latencyAsk // see the ladder note above
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
link.add(to: RunLoop.current, forMode: .default)
@@ -1223,7 +1365,7 @@ public final class Stage2Pipeline {
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: clockOffset())
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
@@ -17,9 +17,18 @@ final class StreamPump {
/// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format
/// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread.
///
/// `endToEndMeter` is stage-1's ONLY latency instrument, and it measures captureENQUEUE
/// not captureglass like the Metal rungs: the layer decodes AND presents after our hand-off,
/// and AVSampleBufferDisplayLayer has no presented callback, so the tail past enqueue (its
/// internal decode + the video-plane flip) is unmeasurable from the app. Cross-rung
/// comparisons must read this as e2e MINUS decode+display and settle the remainder on
/// camera. It is still worth wiring: matching pre-tail halves between rungs pins any felt
/// difference on the present tail the video-plane-vs-compositor question itself.
func start(
connection: PunktfunkConnection,
layer: AVSampleBufferDisplayLayer,
endToEndMeter: LatencyMeter? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
@@ -158,7 +167,14 @@ final class StreamPump {
// 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) {
if gate.onDecoded(flags: au.flags) {
// Captureenqueue (see start's doc). Only frames that will DISPLAY:
// a withheld frame never reaches glass, so its enqueue instant would
// dilute the population the Metal rungs are compared against. The
// offset is read PER ENQUEUE it is live (mid-stream re-synced) and
// caching it rebuilds the stale-offset corruption (see clockOffsetNs).
endToEndMeter?.record(ptsNs: au.ptsNs, offsetNs: connection.clockOffsetNs)
} else {
StreamPump.setDoNotDisplay(sample)
}
layer.enqueue(sample)
@@ -758,10 +758,18 @@ public final class StreamViewController: StreamViewControllerBase {
/// 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.
///
/// Gated on the STREAM being HDR (`connection.isHDR`), not just on the user's HDR setting.
/// The criteria below hardcode BT.2020 + ST.2084 PQ, so without that check an ordinary SDR
/// session drove an HDR-capable TV into PQ output which is a standard way to raise the black
/// floor, since the Apple TV switches HDMI to limited-range levels in its HDR modes and a set
/// configured for full-range then renders code 16 as grey. Layout re-runs this, so a session
/// that flips to HDR mid-stream still picks the mode up on the next pass.
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
SessionSettings.current.hdrEnabled
SessionSettings.current.hdrEnabled,
connection.isHDR
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
+29
View File
@@ -180,6 +180,23 @@ pub struct Stats {
/// decoder's submission returning in ~0.1 ms is not "decoded"); software measures
/// the synchronous CPU decode.
pub decode_ms: f32,
/// Whether `decode_ms` OVERLAPS the presenter's `display` stage instead of tiling
/// with it — true on the asynchronous native-Vulkan rung, false everywhere else.
///
/// The other stages are a per-frame partition of `e2e`: `pts →(host+net)→ received
/// →(decode)→ decoded →(display)→ displayed`. That holds while `decoded` is a
/// COMPLETION stamp, which it is on the synchronous rungs. On the native-Vulkan rung
/// `receive_frame` returns at SUBMISSION (~0.1 ms) and the stamp shipped to the
/// presenter is taken there, so the GPU decode happens INSIDE the `display` stage —
/// `host+net` and `display` already tile `e2e` between them, and `decode` (measured
/// received → fence-complete) re-counts the GPU work that `display` contains.
///
/// A 2026-08-13 field report read the row as a breakdown and asked why the parts did
/// not add up: `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1`. They
/// do add up — without `decode` (5.4 + 0.3 + 1.4 ≈ 8.1). The figure is a true reading
/// of a real quantity sitting in a row that reads like a partition, so the OSD renders
/// it off that line rather than beside stages it does not tile with.
pub decode_overlaps_display: bool,
/// Unrecoverable network frame drops this window, and their share of
/// received+lost (%). The OSD renders the counter line only when nonzero.
pub lost: u32,
@@ -770,6 +787,10 @@ fn pump(
// corrected), `decode` = received→decoded (client-local). p50 per 1 s window.
let mut hostnet_us: Vec<u64> = Vec::with_capacity(256);
let mut decode_us: Vec<u64> = Vec::with_capacity(256);
// Whether this window's decode samples came from the async (submission-stamped) rung, so
// the OSD keeps them off the partition line. Latches per window alongside the samples,
// rather than being read off the rung name — a demote mid-window changes both together.
let mut decode_overlaps = false;
// Adaptive bitrate: report the decode stage back to the core controller only when it's armed
// (Automatic, non-PyroWave). Constant for the session — resolve once, gate the per-frame call.
let wants_decode = connector.wants_decode_latency();
@@ -1118,6 +1139,12 @@ fn pump(
// `decode` stage: received→decode COMPLETE, single clock.
match hw_fence {
Some((sem, value)) => {
// A fence means `decoded_ns` above was stamped at SUBMISSION, so
// the GPU decode lands inside the presenter's `display` stage and
// this figure re-counts it: it does NOT tile with the others.
// Recorded so the OSD can render it off the partition line
// (`Stats::decode_overlaps_display`).
decode_overlaps = true;
if decode_us.is_empty()
&& decoder.wait_hw_decoded(sem, value, 50_000_000)
{
@@ -1433,6 +1460,7 @@ fn pump(
host_pace_ms: pace_p50 as f32 / 1000.0,
staged,
decode_ms: dec_p50 as f32 / 1000.0,
decode_overlaps_display: decode_overlaps,
lost,
lost_pct: if lost > 0 {
lost as f32 * 100.0 / (frames_n + lost) as f32
@@ -1461,6 +1489,7 @@ fn pump(
bytes_n = 0;
hostnet_us.clear();
decode_us.clear();
decode_overlaps = false;
host_us_win.clear();
net_us_win.clear();
queue_us_win.clear();
+16 -5
View File
@@ -245,11 +245,22 @@ impl Overlay for SkiaOverlay {
shared.queue_family_index as usize,
),
&get_proc,
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, so it caps entry-point
// validation at whatever `vkEnumerateInstanceVersion()` reports — byte-for-byte what
// the (now removed) `BackendContext::new` did. The presenter owns the instance and its
// `VkApplicationInfo`, so pinning a version here would just duplicate its choice.
None,
// 🛑 MUST be the presenter's declared version, never `None`.
//
// `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, which makes Skia fall
// back to `vkEnumerateInstanceVersion()` — the LOADER's ceiling, not ours. Those are
// not the same number: the presenter asks for 1.3, while a current Mesa loader answers
// 1.4 (1.4.321 on SteamOS 3.7). Skia then validates a 1.4 function table against an
// instance that only ever promised 1.3, `vkGetDeviceProcAddr` returns null for the
// entry points above 1.3, validation fails, and `make_vulkan` hands back `None` — so
// the console UI refuses to start and `--browse` dies with it.
//
// ⚠ The `0` sentinel was harmless at skia-safe 0.87 (that Skia knew nothing of 1.4, so
// clamping to the loader was a no-op) and the 0.99 migration preserved it as
// "byte-for-byte what `BackendContext::new` did" — true of the VALUE, false of the
// BEHAVIOUR. It shipped in 0.28.0 and took the Deck's launcher out. 0.99's own doc for
// this parameter says it should match `VkApplicationInfo::apiVersion`; this is that.
Some(skvk::Version::from(shared.api_version)),
);
// SAFETY: the instance/physical-device/device handles come from `shared`, which owns them
// and outlives this backend context, and `get_proc` above resolves through those same
+4 -2
View File
@@ -262,10 +262,12 @@ pub struct HostConfig {
/// spawn flags included.
pub gamescope_hdr: bool,
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`).
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
/// `None` = 203 nits, BT.2408 reference white, which is what our clients decode against —
/// NOT gamescope's own default of 400, which sits nearly a stop above it. See `pf-vdisplay`'s
/// `SDR_REFERENCE_WHITE_NITS` for why the host pins this rather than letting it float.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
+11
View File
@@ -26,6 +26,17 @@ pub struct SharedDevice {
/// with [`pf_client_core::video::QueueLock::guard`], whose RAII form is what every
/// Rust caller wants.
pub queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
/// The Vulkan version an overlay renderer may size its function table to — the lower of
/// [`crate::vk::INSTANCE_API_VERSION`] (what `VkApplicationInfo::apiVersion` declared for
/// `instance`) and what the loader provides.
///
/// **Cap yourself here; do not ask the loader yourself.** Entry points above this version
/// were never promised to us — `vkGetDeviceProcAddr` returns null for them — so a renderer
/// that probes `vkEnumerateInstanceVersion` instead (a current Mesa answers 1.4 where we
/// asked for 1.3) validates a function table it can never fill and refuses to start. That
/// is exactly how the Skia console UI died in 0.28.0; see the note in `pf-console-ui`'s
/// `SkiaOverlay::init`.
pub api_version: u32,
}
/// What the overlay may draw this frame — composed by the run loop from session state.
+96 -4
View File
@@ -2847,10 +2847,21 @@ fn stats_text(
} else {
text.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
}
text.push_str(&format!(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
// `decode` joins the partition line ONLY where it is one. The stages tile `e2e`
// per frame — pts →(host+net)→ received →(decode)→ decoded →(display)→ displayed —
// and that holds while `decoded` is a completion stamp. On the async native-Vulkan
// rung it is a SUBMISSION stamp, so the GPU decode sits inside `display` and this
// figure re-counts it; printing the two side by side invited exactly the reading a
// 2026-08-13 field report made ("decode 6.6 next to display 1.4 and e2e 8.1 — the
// parts don't add up"). They add up without it. See `Stats::decode_overlaps_display`.
if s.decode_overlaps_display {
text.push_str(&format!(" · display {:.1} ms", p.display_ms));
} else {
text.push_str(&format!(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
}
// The display split (WP4). Only with true on-glass stamps — without them the
// two halves are not separable and the unsplit figure stands alone rather than
// implying a zero latch.
@@ -2860,6 +2871,19 @@ fn stats_text(
p.pace_ms, p.latch_ms
));
}
// …and gets its own line there, qualified. Two things a reader has to know before
// the number means anything: it is ONE frame per window on this rung (a per-frame
// fence wait would serialise the decode pipeline — see the sampling comment in
// `pf_client_core::session`), so it is a single sample rather than the p50 every
// other figure here is; and it is already inside `display`, so adding it double-
// counts. Suppressed at 0, which is the "every fence wait timed out" case rather
// than a real zero.
if s.decode_overlaps_display && s.decode_ms > 0.0 {
text.push_str(&format!(
"\ndecode {:.1} ms (1 sample, inside display — not additive)",
s.decode_ms
));
}
// Extended 0xCF host-stage split (T0.1): its own line so the per-stage attribution
// (queue → encode → seal/xfer → pace) reads as the host pipeline in order.
if s.staged {
@@ -3275,6 +3299,10 @@ mod tests {
host_pace_ms: 0.3,
staged: true,
decode_ms: 1.8,
// The fixture is the SYNCHRONOUS shape, so `decode` stays on the partition
// line and the existing assertions keep their meaning; the async rung's
// split-out rendering is exercised separately below.
decode_overlaps_display: false,
lost: 3,
lost_pct: 0.4,
mic_sent: 0,
@@ -3414,6 +3442,70 @@ mod tests {
assert!(!normal.contains("present:") && !normal.contains("pace"));
}
/// The stage line must stay a PARTITION of `e2e`. On the synchronous rungs `decode` is
/// one of its terms; on the asynchronous native-Vulkan rung the shipped `decoded` stamp
/// is taken at submission, so the GPU decode is inside `display` and `decode` re-counts
/// it. A 2026-08-13 field report read `host 5.4 · net 0.3 · decode 6.6 · display 1.4`
/// against `e2e 8.1` as a breakdown and asked why it did not add up — it adds up without
/// `decode`. So the figure leaves that line and says what it is instead of sitting beside
/// stages it does not tile with.
#[test]
fn an_overlapping_decode_figure_leaves_the_stage_line_and_says_so() {
let (mut s, p) = sample();
// Synchronous: unchanged, and specifically still INLINE on the stage line.
assert!(!s.decode_overlaps_display, "the fixture is the sync shape");
let sync = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(sync.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
assert!(!sync.contains("not additive"));
// Asynchronous: off the stage line, which still reads as a partition…
s.decode_overlaps_display = true;
let async_ = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(
async_.contains("host 1.2 · net 0.9 · display 1.1 ms"),
"the stage line keeps only terms that tile e2e: {async_}"
);
// …and the number survives, qualified by BOTH caveats a reader needs.
assert!(async_.contains("\ndecode 1.8 ms (1 sample, inside display — not additive)"));
// A window whose every fence wait timed out reports 0, which is an absence of
// measurement rather than an instant decode — it must not render as either.
s.decode_ms = 0.0;
let none = stats_text(
StatsVerbosity::Detailed,
"m",
&s,
&p,
false,
false,
false,
None,
);
assert!(
!none.contains("decode"),
"a 0 sample renders nothing: {none}"
);
}
/// The decode-integrity line (M4) — the whole point of which is that it can tell
/// three states apart that all look identical as "no complaints today":
///
+79
View File
@@ -43,6 +43,24 @@ mod setup;
pub use setup::{list_adapters, probe_decode, AdapterDecode, PresentPref};
/// The Vulkan version every instance this crate creates declares in
/// `VkApplicationInfo::apiVersion`.
///
/// 1.3 because Vulkan Video decode and PyroWave's compute kernels both need a 1.3 device.
/// It is deliberately a CEILING as well as a floor: the loader is routinely newer (Mesa 26
/// answers `vkEnumerateInstanceVersion` with 1.4), but we only ever promised 1.3, so the
/// entry points above it are not ours to call. Anything that must know how far the device
/// side reaches — notably an overlay renderer sizing its own function table — reads this
/// through [`crate::overlay::SharedDevice::api_version`] rather than asking the loader.
pub const INSTANCE_API_VERSION: u32 = vk::API_VERSION_1_3;
/// The clamp behind [`Presenter::overlay_api_version`], split out so the decision is provable
/// without a device: the answer is the lower of what we declared and what the loader reports,
/// and a loader too old to answer at all (`None`) can only be a 1.0 one.
fn overlay_api_version_of(declared: u32, loader: Option<u32>) -> u32 {
declared.min(loader.unwrap_or(vk::API_VERSION_1_0))
}
/// The video-format probe behind [`AdapterDecode::formats`], re-exported so a caller
/// that prints the report does not need its own `pf-vkdecode` dependency (and cannot
/// end up printing a DIFFERENT crate version's idea of the flag names).
@@ -387,8 +405,30 @@ impl Presenter {
queue: self.queue,
queue_family_index: self.qfi,
queue_lock: self.queue_lock.clone(),
api_version: self.overlay_api_version(),
}
}
/// The Vulkan version an overlay renderer may size its function table to: the LOWER of
/// what our instance declared ([`INSTANCE_API_VERSION`]) and what the loader actually
/// provides.
///
/// Both halves are load-bearing, in opposite directions. Taking only the loader's number
/// is the bug that killed the console UI in 0.28.0 — Mesa answers 1.4 where we asked for
/// 1.3, and the entry points in between resolve to null. Taking only ours would break the
/// mirror case: a loader older than 1.3 still accepts our 1.3 instance (1.1+ loaders treat
/// `apiVersion` as intent, not a contract), and claiming 1.3 to a renderer there promises
/// functions the loader has never heard of. The minimum is the only number that is true on
/// both sides.
fn overlay_api_version(&self) -> u32 {
// SAFETY: per the Vulkan contract above - `vkEnumerateInstanceVersion` is a global
// command taking no handles, resolved through the loaded entry that owns it; it writes
// one `u32` local. Absent (a 1.0 loader) it reports `None` rather than failing.
let loader = unsafe { self.entry.try_enumerate_instance_version() }
.ok()
.flatten();
overlay_api_version_of(INSTANCE_API_VERSION, loader)
}
}
impl Drop for Presenter {
@@ -449,3 +489,42 @@ impl Drop for Presenter {
let _ = &self.entry;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The 0.28.0 regression, as an assertion: a loader NEWER than the version we declared
/// must not raise the cap. Skia sized its function table to the loader's 1.4 here, then
/// could not resolve the entry points our 1.3 instance never exposed, and the console UI
/// refused to start (Steam Deck, Mesa loader 1.4.321).
#[test]
fn a_newer_loader_never_raises_the_cap() {
let loader = vk::make_api_version(0, 1, 4, 321);
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
INSTANCE_API_VERSION
);
}
/// The mirror case, which is why this is a `min` and not "just use ours": a 1.1+ loader
/// accepts our 1.3 `apiVersion` as intent even when it cannot deliver 1.3, so promising
/// 1.3 to the overlay there would name functions the loader has never heard of.
#[test]
fn an_older_loader_lowers_the_cap() {
let loader = vk::make_api_version(0, 1, 2, 198);
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, Some(loader)),
loader
);
}
/// No `vkEnumerateInstanceVersion` at all is the one thing it can mean: a 1.0 loader.
#[test]
fn a_loader_that_cannot_answer_is_1_0() {
assert_eq!(
overlay_api_version_of(INSTANCE_API_VERSION, None),
vk::API_VERSION_1_0
);
}
}
+5 -3
View File
@@ -155,9 +155,11 @@ impl Presenter {
// 1.3: Vulkan Video decode and PyroWave's compute kernels both need a 1.3
// device, and the instance version caps what the device can report (any current
// loader accepts 1.3 regardless of device support; device-level gating is below).
// `SharedDevice::api_version` republishes this constant to the overlay — keep the
// two the same by construction rather than by two spellings of `API_VERSION_1_3`.
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// HDR10 presentation needs the extended colorspaces at the INSTANCE level.
let mut instance_extensions: Vec<String> = instance_extensions.to_vec();
let inst_available =
@@ -749,7 +751,7 @@ pub fn probe_decode() -> Result<Vec<AdapterDecode>> {
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
// builder structs that are locals outliving the call; the handle it returns is owned by the
// value being built here.
@@ -902,7 +904,7 @@ pub fn list_adapters() -> Result<Vec<String>> {
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_3);
.api_version(super::INSTANCE_API_VERSION);
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this type
// and live for the call, and every builder struct is a local that outlives it.
let instance = unsafe {
@@ -1237,7 +1237,7 @@ fn write_session_plus_dropin(
wrapper: &std::path::Path,
mode: Mode,
hdr: bool,
wsi_ok: bool,
wsi: WsiPlan,
) -> Result<bool> {
let Some(bind) = arm_session_bind(wrapper) else {
remove_session_plus_dropin();
@@ -1260,11 +1260,7 @@ fn write_session_plus_dropin(
.chain(cursor_args())
.collect::<Vec<_>>()
.join(" "),
wsi = if wsi_ok {
String::new()
} else {
wsi_off_unit_lines()
},
wsi = wsi.unit_lines(),
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
Ok(true)
@@ -1579,7 +1575,7 @@ fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
// Same two fixes the transient path gets, but this unit is the BOX's own — they have to arrive
// as a drop-in, and `daemon-reload` before the restart or systemd runs the old unit.
let mut bound = match write_gamescope_bin_wrapper()
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope()))
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, WsiPlan::resolve()))
{
Ok(true) => {
// Record it BEFORE the restart, and persist it: from this instant the box's OWN
@@ -4215,25 +4211,103 @@ const WSI_OFF_ENV: [(&str, &str); 2] = [
("ENABLE_GAMESCOPE_WSI", "0"),
];
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
fn wsi_off_setenv_args() -> Vec<String> {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
/// Our own WSI layer's implicit-layer manifest, laid down beside the compositor by
/// `packaging/gamescope/build-punktfunk-gamescope.sh`.
///
/// It is built from the SAME source tree at the SAME rev as `punktfunk-gamescope`, so the layer and
/// the compositor cannot disagree about `gamescope_swapchain` — which is what makes every "is the
/// distro's layer close enough to ours?" guess unnecessary. It carries its own layer name and its
/// own `enable_environment`, so it coexists with the distro's rather than replacing it.
const OUR_WSI_LAYER_DIR_DEFAULT: &str = "/usr/lib/punktfunk/vulkan/implicit_layer.d";
const OUR_WSI_LAYER_MANIFEST_NAME: &str = "punktfunk_gamescope_wsi.json";
/// Where our layer's manifest directory is. FHS by default, because that is where every distro
/// package puts it; `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` overrides for a store with no `/usr` to
/// speak of — on NixOS the layer lives inside the gamescope derivation and the module points this
/// at it, the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
fn our_wsi_layer_dir() -> String {
std::env::var("PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| OUR_WSI_LAYER_DIR_DEFAULT.to_string())
}
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn wsi_off_unit_lines() -> String {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
/// Which Vulkan WSI layer a session we spawn should run with. Three states, decided ONCE per
/// launch because [`WsiPlan::resolve`] can spawn `--version` probes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum WsiPlan {
/// Our own matching layer is installed: enable it, suppress the distro's. Games get HDR.
Ours,
/// No layer of ours, and the distro's version triple matches the gamescope we run, so it is
/// probably built against the same protocol. Leave the box exactly as it is.
DistroKept,
/// No layer of ours, and the distro's cannot be trusted. Disable it — a mismatched layer kills
/// every Vulkan client — and accept that no game in this session can get an HDR10 swapchain.
DistroDisabled,
}
impl WsiPlan {
/// ⚠️ Spawns up to two `gamescope --version` probes in the fallback arms, so resolve once and
/// pass the result around rather than calling this per use site.
fn resolve() -> Self {
let manifest = std::path::Path::new(&our_wsi_layer_dir()).join(OUR_WSI_LAYER_MANIFEST_NAME);
if manifest.is_file() {
Self::Ours
} else if wsi_layer_matches_our_gamescope() {
Self::DistroKept
} else {
Self::DistroDisabled
}
}
/// The environment this plan needs, as `(name, value)` pairs.
fn env(self) -> Vec<(&'static str, String)> {
match self {
// `VK_ADD_IMPLICIT_LAYER_PATH` ADDS to the loader's implicit-layer search (loader
// 1.3.234+), so the box's own layer directories keep working; the distro's gamescope
// layer is then switched off by name through its own variables, leaving exactly one
// gamescope WSI layer live — ours.
Self::Ours => vec![
("VK_ADD_IMPLICIT_LAYER_PATH", our_wsi_layer_dir()),
("PUNKTFUNK_GAMESCOPE_WSI", "1".to_string()),
("DISABLE_GAMESCOPE_WSI", "1".to_string()),
("ENABLE_GAMESCOPE_WSI", "0".to_string()),
],
Self::DistroKept => Vec::new(),
Self::DistroDisabled => WSI_OFF_ENV
.iter()
.map(|(name, value)| (*name, (*value).to_string()))
.collect(),
}
}
/// As `systemd-run` arguments, for the transient unit.
fn setenv_args(self) -> Vec<String> {
self.env()
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// As unit-file lines, for the box-session drop-in. Trailing newline included, so whatever the
/// body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn unit_lines(self) -> String {
self.env()
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// ⚠️ **Fallback only** — reached from [`WsiPlan::resolve`] just when our own layer is absent (a
/// `punktfunk-gamescope` package older than the one that started shipping it). It is a guess, and a
/// guess in BOTH directions: a distro at the same upstream tag that patched the protocol compares
/// EQUAL and keeps a layer that will kill every Vulkan client, while a distro at a different tag
/// with a byte-identical protocol compares unequal and loses HDR for nothing. Do not build anything
/// new on it; ship the layer instead, which is what [`WsiPlan::Ours`] does.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
/// run our own build. When the two disagree the compositor rejects the client's
/// `swapchain_feedback` ("message too short") and **kills every Vulkan client** — Steam never
@@ -4244,8 +4318,13 @@ fn wsi_off_unit_lines() -> String {
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
/// metadata) — far cheaper than a client that cannot start.
/// Disabling it costs the layer's extras (XWayland bypass, present-mode control) and, on an HDR
/// session, **HDR for games**. The layer is the ONLY route to an HDR10 swapchain under gamescope:
/// gamescope advertises no runtime colour-management protocol for a Mesa/NVIDIA WSI to negotiate
/// through, so with the layer inactive a client never gets a Surface-state block and lands on
/// `hdr10_format=None` (measured on a Deck OLED — see the Gamescope WSI notes in the flatpak
/// manifest). Still cheaper than a client that cannot start at all, but not free, which is why
/// [`launch_session`] says so out loud when it fires on an HDR session.
///
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
fn wsi_layer_matches_our_gamescope() -> bool {
@@ -4304,16 +4383,30 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
let wsi = WsiPlan::resolve();
if wsi == WsiPlan::DistroDisabled {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
Steam's own UI is not one, so what you see is a game that runs with sound and input \
on a black screen, with no other symptom."
than the one we run, and no punktfunk layer is installed to use instead disabling \
it for this session (DISABLE_GAMESCOPE_WSI=1, which the session script cannot clobber \
the way it clobbers ENABLE_GAMESCOPE_WSI). Left enabled it rejects the client's \
swapchain_feedback and every Vulkan client dies; Steam's own UI is not one, so what \
you see is a game that runs with sound and input on a black screen, with no other \
symptom. Upgrading the punktfunk-gamescope package fixes this properly it ships a \
layer built from the same tree as the compositor."
);
// The HDR decisions are made independently — `hdr_args` never consults the layer plan — so
// without this an HDR session launches advertising HDR while having made game HDR
// unreachable in the same breath, and nothing anywhere says so.
if hdr {
tracing::warn!(
"gamescope: this session negotiated HDR, but with the WSI layer disabled no game \
in it can get an HDR10 swapchain that layer is the only route to one. The \
stream itself stays HDR (the capture really is PQ/BT.2020, and Steam's UI and the \
desktop ride the same container), so what breaks is GAME HDR specifically: a \
title told to render HDR renders it into an SDR swapchain and looks washed out."
);
}
}
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
let mut cmd = Command::new("systemd-run");
@@ -4321,10 +4414,8 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
for arg in bind.map(SessionBind::run_args).unwrap_or_default() {
cmd.arg(arg);
}
if !wsi_ok {
for arg in wsi_off_setenv_args() {
cmd.arg(arg);
}
for arg in wsi.setenv_args() {
cmd.arg(arg);
}
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
@@ -4559,23 +4650,38 @@ fn add_bare_gamescope_args(
/// fine meanwhile.)
/// * `--hdr-sdr-content-nits` maps SDR content into the PQ container. Everything that is not an
/// HDR game — the desktop, the Steam overlay, an SDR title — rides through it, so it decides
/// how bright "white" lands on the client's panel. Only passed when the operator set the knob;
/// otherwise gamescope's own default (400) applies.
/// how bright "white" lands on the client's panel. We always pass it, because the two ends have
/// to agree on where diffuse white sits and gamescope's own default does not match ours — see
/// [`SDR_REFERENCE_WHITE_NITS`].
fn hdr_args(hdr: bool) -> Vec<String> {
if !hdr {
return Vec::new();
}
let mut args = vec![
let nits = pf_host_config::config()
.gamescope_sdr_nits
.unwrap_or(SDR_REFERENCE_WHITE_NITS);
vec![
"--hdr-enabled".to_string(),
"--hdr-debug-force-support".to_string(),
];
if let Some(nits) = pf_host_config::config().gamescope_sdr_nits {
args.push("--hdr-sdr-content-nits".to_string());
args.push(nits.to_string());
}
args
"--hdr-sdr-content-nits".to_string(),
nits.to_string(),
]
}
/// Where diffuse white sits, in nits, for SDR content carried inside an HDR session's PQ container.
///
/// 203 is BT.2408 "HDR Reference White", and it is the value every first-party client anchors to:
/// the Apple presenter hands exactly 203 to `CAEDRMetadata.hdr10(opticalOutputScale:)`. gamescope's
/// own default is 400, so leaving the flag off put the host nearly a stop (400/203 ≈ 1.97×) above
/// what the client decodes against — the 2026-08-13 field report where Steam's Big Picture UI read
/// as glaring and over-saturated on an iPad while HDR game content came out washed out. Both are
/// the same error: the UI lands above SDR white, and the client's tone-mapper then works from a
/// reference point the host never used, flattening the content around it.
///
/// This is the anchor, not a taste knob — `PUNKTFUNK_GAMESCOPE_SDR_NITS` is still there for an
/// operator who wants a brighter or dimmer desktop, and moving it away from 203 re-opens the gap.
const SDR_REFERENCE_WHITE_NITS: u32 = 203;
/// `--pipewire-composite-cursor` when the resolved gamescope has it (patch level 2+). Paired with
/// [`crate::gamescope_composites_cursor`], which is what tells the host to STOP compositing the
/// pointer itself — the two must agree, so both read the same probe.
@@ -4778,12 +4884,12 @@ mod tests {
any_output_size_is, cgroup_is_punktfunk_owned, cgroup_under_user_manager,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
dm_survives_masked_unit, game_hz, gamescope_output_size, hdr_args, is_steam_launch,
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, our_wsi_layer_dir,
plan_bind, release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
wsi_off_setenv_args, wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan,
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, AUTOLOGIN_MASKED,
DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN,
WSI_OFF_ENV, X11_SOCKET_DIR,
};
fn argv(s: &str) -> Vec<String> {
@@ -5611,8 +5717,8 @@ mod tests {
);
// Both spellings reach both launch paths, and neither may lose the other.
let args = wsi_off_setenv_args();
let lines = wsi_off_unit_lines();
let args = WsiPlan::DistroDisabled.setenv_args();
let lines = WsiPlan::DistroDisabled.unit_lines();
for (name, value) in WSI_OFF_ENV {
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
assert!(
@@ -5625,4 +5731,31 @@ mod tests {
// lines above it rely on the same contract and the order has changed before.
assert!(lines.ends_with('\n'));
}
/// The whole point of shipping our own layer is that BOTH halves happen in one session: ours is
/// switched on AND the distro's is forced off. Enabling ours while leaving theirs live would
/// put two gamescope WSI layers in the loader's implicit set, and dropping ours while forcing
/// theirs off is just the old no-game-HDR behaviour wearing a new name — so assert the pair,
/// not either half.
#[test]
fn our_own_layer_is_enabled_and_the_distro_one_forced_off_together() {
let env = WsiPlan::Ours.env();
let get = |k: &str| {
env.iter()
.find(|(name, _)| *name == k)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("{k} missing from the Ours plan"))
};
assert_eq!(get("VK_ADD_IMPLICIT_LAYER_PATH"), our_wsi_layer_dir());
assert_eq!(get("PUNKTFUNK_GAMESCOPE_WSI"), "1");
// The clobber-proof one, for exactly the reason the test above states.
assert_eq!(get("DISABLE_GAMESCOPE_WSI"), "1");
assert_eq!(get("ENABLE_GAMESCOPE_WSI"), "0");
// `DistroKept` must stay genuinely inert: it is the arm that runs on a box we decided not
// to touch, so a stray variable there would change behaviour we promised not to change.
assert!(WsiPlan::DistroKept.env().is_empty());
assert!(WsiPlan::DistroKept.unit_lines().is_empty());
}
}
+6 -2
View File
@@ -185,8 +185,12 @@ pub enum MaxLevelIdc {
H265(hh::StdVideoH265LevelIdc),
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel`. Unlike the other two this code
/// space is the BITSTREAM's own: `StdVideoAV1Level` is index-coded exactly like
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23), so the decoder's gate
/// compares the sequence header's value against it directly.
/// AV1's `seq_level_idx` (2.0 = 0, 2.1 = 1, … 7.3 = 23).
///
/// ⚠ Only over 0…23. `seq_level_idx` is 5 bits, and 31 is Annex A's "maximum
/// parameters" sentinel — no level constraint — which outranks even a device
/// reporting the enum's top value. The AV1 gate therefore treats a stream above
/// this ceiling as advisory instead of refusing it (`VkAv1Decoder::ensure_state`).
Av1(hh::StdVideoAV1Level),
}
+10 -3
View File
@@ -211,9 +211,16 @@ pub struct RawAv1Caps {
pub max_coded_extent: vk::Extent2D,
pub max_dpb_slots: u32,
pub max_active_reference_pictures: u32,
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the
/// SAME numbering as the bitstream's `seq_level_idx`, which is what makes the
/// decoder's level gate a plain comparison).
/// `VkVideoDecodeAV1CapabilitiesKHR::maxLevel` (index-coded Std level — the same
/// numbering as the bitstream's `seq_level_idx` OVER 0…23, which is the whole
/// range `StdVideoAV1Level` enumerates).
///
/// ⚠ That correspondence does not extend to the rest of the bitstream field.
/// `seq_level_idx` is 5 bits: 24…30 are reserved and 31 is Annex A's "maximum
/// parameters" sentinel — "not constrained to a level" — which has no Std code
/// point and is NOT an ordering above 7.3. The decoder's gate therefore treats
/// a stream above this ceiling as advisory rather than comparing it as a level
/// (`VkAv1Decoder::ensure_state`).
pub max_level: hh::StdVideoAV1Level,
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
pub std_header_version: vk::ExtensionProperties,
+81 -15
View File
@@ -100,6 +100,7 @@ use pf_bitstream::av1::NUM_REF_SLOTS;
use pf_bitstream::h264::DisplayCrop;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -688,6 +689,10 @@ pub struct VkAv1Decoder {
/// through a temporal unit, which is why the skip is per FRAME while the error
/// is per ACCESS UNIT.
awaiting_key: bool,
/// One-shot latch for the over-declared-level warning, so a stream whose
/// sequence header sits above the device ceiling says so once per decoder
/// rather than once per access unit (`ensure_state` runs per AU).
level_advisory_warned: bool,
}
impl VkAv1Decoder {
@@ -728,6 +733,7 @@ impl VkAv1Decoder {
device_lost: false,
recovery: RecoveryLatch::default(),
awaiting_key: false,
level_advisory_warned: false,
})
}
@@ -745,8 +751,10 @@ impl VkAv1Decoder {
///
/// The negotiated facts are a HINT (the in-band sequence header is
/// authoritative), so this is deliberately not a promise that decode will
/// succeed: the level ceiling and a sequence header that disagrees with the
/// Welcome still surface at the first AU.
/// succeed: a coded extent outside the caps, a DPB deeper than the device
/// allows, and a sequence header that disagrees with the Welcome all still
/// surface at the first AU. The declared LEVEL is not among them — it is
/// advisory, and `ensure_state` only warns on it.
pub fn probe_stream_support(
&self,
chroma_format_idc: u8,
@@ -1478,8 +1486,9 @@ impl VkAv1Decoder {
self.flush();
}
/// Session/caps for THIS plan exist and match its extent + profile, and the
/// stream sits inside the device's level ceiling.
/// Session/caps for THIS plan exist and match its extent + profile. A declared
/// level above the device ceiling warns once and proceeds — see the gate below
/// for why an AV1 `seq_level_idx` is advisory and 31 is not even a level.
fn ensure_state(&mut self, plan: &AuPlan) -> Result<(), VkDecodeError> {
let key = profile_key_for(plan)?;
if self.caps.as_ref().map(|(k, _)| *k) != Some(key) {
@@ -1491,17 +1500,39 @@ impl VkAv1Decoder {
unsafe { query_av1_caps(&self.dev, key) }.map_err(|r| caps_query_error(r, key))?;
self.caps = Some((key, derive_caps_av1(&raw, wanted)?));
}
// The level gate. AV1's `StdVideoAV1Level` is index-coded exactly like the
// bitstream's `seq_level_idx` (2.0 = 0 … 7.3 = 23) and ascends with the
// level, so this is a plain comparison — of AV1 code points against an AV1
// ceiling, the pairing `MaxLevelIdc`'s tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above `maxLevel`
// is NOT a refusal, for the reason `VkH265Decoder::ensure_state` spells out —
// the level is a CLAIM, and the stream's real demands are enforced where they
// are physical facts (coded extent and DPB depth, checked in `rebuild_state`).
//
// AV1 makes the point sharper than H.265 did. `seq_level_idx` is a 5-bit
// field; Annex A defines 0…23 (levels 2.0…7.3) and reserves 24…30, but **31 is
// the "maximum parameters" level — the spec's own way of saying the bitstream
// is not constrained to any level at all**. `StdVideoAV1Level` has no code
// point for it (it stops at 7.3 = 23), so the index-coded comparison that
// holds across 0…23 is meaningless against 31: the sentinel is not a level
// and 31 > 23 is not "too demanding". Real-time encoders emit it as a matter
// of course — a 2026-08-13 field report (RTX 5060 client, 4K120) had EVERY
// AV1 session demote to D3D11VA on "stream level (seq_level_idx 31) above the
// device's maxLevel (AV1 Std level 23)" while the same hardware decoded the
// stream trivially. We never write an AV1 level on any host encode path, so
// whatever the vendor defaults to is what the client must accept.
//
// Unlike H.265 there is nothing to clamp: `StdVideoAV1SequenceHeader` carries
// no level field (see `params_av1`), so the declaration never reaches the
// driver and cannot be invalid usage. Warn once, proceed.
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = u32::from(stream_level_idx(plan));
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (seq_level_idx {stream_level}) above the device's \
maxLevel ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_advisory_warned {
self.level_advisory_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an AV1 level above the device ceiling — the declared \
level is advisory (seq_level_idx 31 means \"maximum parameters\", and \
encoders over-declare); proceeding, since the level never reaches the \
driver"
);
}
let coded = coded_extent(plan);
match &self.state {
@@ -2907,10 +2938,45 @@ mod tests {
assert_eq!(key.output_format(), Some(crate::caps::NV12));
assert!(!key.film_grain);
// The level gate reads operating point 0 and stays inside the Std range.
// The level gate reads operating point 0. This vector declares a real level,
// inside the Std range — the sentinel case is pinned separately below.
assert!(stream_level_idx(&plan) <= 23);
}
/// `seq_level_idx` 31 is Annex A's "maximum parameters" — "not constrained to a
/// level" — not a level above 7.3, and `StdVideoAV1Level` has no code point for
/// it. Comparing it as an ordinary level is what demoted every AV1 session on a
/// 2026-08-13 field report (RTX 5060, 4K120): `maxLevel` came back 23 (7.3, the
/// device's own maximum) and 31 > 23 refused a stream the hardware decodes fine.
///
/// This pins the ARITHMETIC that made the refusal look reasonable, so nobody
/// restores the gate by reading `31 > 23` as "too demanding":
#[test]
fn the_av1_max_parameters_sentinel_is_not_a_level_above_the_ceiling() {
// The ceiling as the gate reads it, on a device that decodes everything the
// Std enum can name — 7.3, the top code point there is.
let ceiling = crate::caps::MaxLevelIdc::Av1(hh::StdVideoAV1Level_STD_VIDEO_AV1_LEVEL_7_3);
assert_eq!(ceiling.code_point(), 23, "the Std enum's top code point");
// Every `seq_level_idx` the Std enum names compares sanely against it…
for idx in 0..=ceiling.code_point() {
assert!(idx <= ceiling.code_point());
}
// …and everything above is OUTSIDE that code space, not above the ceiling:
// 24…30 are reserved and 31 is "maximum parameters". A maxed-out device
// cannot satisfy the comparison, which is why it is not a capability test.
for idx in (ceiling.code_point() + 1)..=31 {
assert!(
idx > ceiling.code_point(),
"seq_level_idx {idx} is outside the Std range, not a more demanding level"
);
}
// The field report's exact pairing, kept legible: 31 against a ceiling of 23.
assert!(31 > ceiling.code_point());
assert_eq!(format!("{ceiling}"), "AV1 Std level 23");
}
#[test]
fn only_a_decoded_key_frame_ends_the_wait_for_one() {
let mut planner = Av1Planner::new();
@@ -2951,7 +3017,7 @@ mod tests {
/// `PlanError::AwaitingIdr`, and the reason [`VkAv1Decoder::awaiting_key`]'s
/// docs carry: a clean `Ok(None)` resets the consumer's demotion streak once
/// per frame, so a rung whose every key frame fails (film grain on a device
/// without the grain profile; a level above `maxLevelIdc`; a sequence header
/// without the grain profile; a coded extent outside the caps; a sequence header
/// disagreeing with the negotiation) would never demote and the session would
/// hold a frozen screen with a clean bill of health.
///
@@ -47,7 +47,15 @@ pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
/// warning instead of a continuous flush/keyframe storm.
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
///
/// **Public because the HOST needs it to read its own logs.** Each jump-to-live sends a keyframe
/// request, so a client that cannot sustain the rate asks for one at exactly this spacing,
/// forever — and the host's recovery-cadence detector saw that perfect periodicity and blamed a
/// periodic *display* disturbance (2026-08-13 field log: `period_s=2.0`, three subsystems named,
/// none of them the cause). Perfect periodicity is the signature of a fixed software cooldown,
/// not of a physical disturbance. The host compares against this constant rather than a copy of
/// the number, so the two can never drift apart.
pub const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
+1
View File
@@ -42,6 +42,7 @@ mod recovery;
mod rumble;
mod worker;
pub use self::frame_channel::FLUSH_COOLDOWN;
pub use self::planes::AudioPacket;
pub use self::probe::ProbeOutcome;
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
+47 -6
View File
@@ -62,6 +62,17 @@ pub struct PwAudioCapturer {
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
/// [`idle`](AudioCapturer::idle)/Drop (release).
claimed: bool,
/// Whether a session is currently CONSUMING this capturer, shared with the PipeWire
/// thread so the drop counter can tell "the encode thread fell behind" from "nobody is
/// reading". The capturer is host-lifetime and merely PARKED between sessions
/// ([`idle`](AudioCapturer::idle)), so without this the producer keeps filling the bounded
/// hand-off channel, every `try_send` fails once it is full, and the plane reports a 100 %
/// drop rate — warning that "the stream will click" when there is no stream. A 2026-08-13
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
/// exists when the stream sink is enabled at all.
active: Arc<AtomicBool>,
}
impl PwAudioCapturer {
@@ -90,10 +101,21 @@ impl PwAudioCapturer {
// mode the sink node must exist before we claim the default to its name.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let thread_sink_name = sink_name.clone();
// Opens at session start (see the routing claim below), so the consumer is live from
// the first chunk.
let active = Arc::new(AtomicBool::new(true));
let thread_active = Arc::clone(&active);
thread::Builder::new()
.name("punktfunk-pw-audio".into())
.spawn(move || {
if let Err(e) = pw_thread(tx, quit_rx, channels, thread_sink_name, ready_tx) {
if let Err(e) = pw_thread(
tx,
quit_rx,
channels,
thread_sink_name,
ready_tx,
thread_active,
) {
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
}
})
@@ -118,12 +140,16 @@ impl PwAudioCapturer {
quit: quit_tx,
sink_name,
claimed,
active,
})
}
}
impl Drop for PwAudioCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the producer still pushes is unwanted by
// definition, and it must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -157,9 +183,15 @@ impl AudioCapturer for PwAudioCapturer {
stream_sink::claim(name);
self.claimed = true;
}
// Ordered AFTER the backlog drain, so the producer never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See `PwAudioCapturer::active`.
self.active.store(false, Ordering::Relaxed);
if self.claimed {
self.claimed = false;
stream_sink::release();
@@ -644,6 +676,7 @@ fn pw_thread(
channels: u32,
sink_name: Option<String>,
ready: std::sync::mpsc::SyncSender<Result<()>>,
active: Arc<AtomicBool>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
@@ -735,6 +768,9 @@ fn pw_thread(
/// never again — the one number that identifies a clamped quantum, invisible on every
/// subsequent open (including every reopen after a device change).
reported_quantum: bool,
/// Shared with the capturer — see [`PwAudioCapturer::active`]. Read on every
/// failed hand-off to keep parked-capturer backpressure out of the drop count.
active: Arc<AtomicBool>,
}
let ud = CapUd {
tx,
@@ -742,6 +778,7 @@ fn pw_thread(
stats: Default::default(),
last_stats: std::time::Instant::now(),
reported_quantum: false,
active,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
@@ -844,11 +881,15 @@ fn pw_thread(
samples.push(f32::from_le_bytes(b));
}
ud.stats.observe(&samples, ud.channels);
// Non-blocking and lossy, as before — but COUNTED. A full channel means the
// encode thread is not keeping up, and because the encoder simply
// concatenates across the hole every dropped chunk is a click AND a
// permanent shift of everything after it.
if ud.tx.try_send(samples).is_err() {
// Non-blocking and lossy, as before — but COUNTED, and only while a session
// is actually reading. A full channel under a LIVE consumer means the encode
// thread is not keeping up, and because the encoder simply concatenates
// across the hole every dropped chunk is a click AND a permanent shift of
// everything after it. A full channel under a PARKED capturer means nothing
// at all: the capturer is host-lifetime, so between sessions the channel
// fills once and then refuses everything, which counted as a 100 % drop rate
// and warned about a stream that did not exist (`PwAudioCapturer::active`).
if ud.tx.try_send(samples).is_err() && ud.active.load(Ordering::Relaxed) {
ud.stats.dropped_chunks += 1;
}
if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY {
@@ -43,6 +43,15 @@ pub struct WasapiLoopbackCapturer {
channels: u32,
stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
/// Whether a session is currently CONSUMING this capturer, shared with the capture thread
/// so the drop counter can tell "the encode thread fell behind" from "nobody is reading".
/// The native/gamestream planes park a capturer between sessions
/// ([`idle`](AudioCapturer::idle)) instead of dropping it, and the hand-off channel is
/// bounded — so without this the thread fills it once, then counts every subsequent chunk
/// as a drop and warns that "the stream will click" with no stream to click. Proven on the
/// Linux twin by a 2026-08-13 field log (100 % drop rate across session gaps); the parking
/// call sites are platform-independent, so this half had the same defect.
active: Arc<AtomicBool>,
}
impl WasapiLoopbackCapturer {
@@ -58,10 +67,13 @@ impl WasapiLoopbackCapturer {
// rather than a silent dead thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let stop_t = stop.clone();
// Opens at session start, so the consumer is live from the first chunk.
let active = Arc::new(AtomicBool::new(true));
let active_t = active.clone();
let join = thread::Builder::new()
.name("punktfunk-wasapi-audio".into())
.spawn(move || {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels) {
if let Err(e) = capture_thread(tx, stop_t, ready_tx, channels, active_t) {
tracing::error!(error = %format!("{e:#}"), "wasapi loopback thread failed");
}
})
@@ -76,6 +88,7 @@ impl WasapiLoopbackCapturer {
channels,
stop,
join: Some(join),
active,
})
}
Ok(Err(e)) => Err(e),
@@ -92,6 +105,9 @@ impl WasapiLoopbackCapturer {
impl Drop for WasapiLoopbackCapturer {
fn drop(&mut self) {
// The receiver dies with us; anything the thread still pushes is unwanted by
// definition, and must not be reported as the encode thread falling behind.
self.active.store(false, Ordering::Relaxed);
self.stop.store(true, Ordering::SeqCst);
if let Some(j) = self.join.take() {
let _ = j.join();
@@ -114,6 +130,14 @@ impl AudioCapturer for WasapiLoopbackCapturer {
}
fn drain(&mut self) {
while self.chunks.try_recv().is_ok() {}
// Ordered AFTER the backlog drain, so the capture thread never counts a drop against a
// channel this call is still emptying.
self.active.store(true, Ordering::Relaxed);
}
fn idle(&mut self) {
// Parked: from here the channel fills and stays full, and those drops are nobody's
// fault. See [`WasapiLoopbackCapturer::active`].
self.active.store(false, Ordering::Relaxed);
}
}
@@ -167,6 +191,7 @@ fn capture_thread(
stop: Arc<AtomicBool>,
ready: SyncSender<Result<()>>,
channels: u32,
active: Arc<AtomicBool>,
) -> Result<()> {
// COM must be initialized on THIS thread (MTA), before any device call.
if let Err(e) = wasapi::initialize_mta()
@@ -192,7 +217,7 @@ fn capture_thread(
// is said once per topology — the field log drowned in 256+ copies of the same line.
let mut unsat_logged: Option<u64> = None;
while !stop.load(Ordering::Relaxed) {
match capture_once(&tx, &stop, &mut ready, channels, mode) {
match capture_once(&tx, &stop, &mut ready, channels, mode, &active) {
Ok(Next::Stopped) => break,
Ok(Next::Reopen(m)) => {
mode = m;
@@ -357,6 +382,7 @@ fn capture_once(
ready: &mut Option<SyncSender<Result<()>>>,
channels: u32,
mode: TargetMode,
active: &AtomicBool,
) -> Result<Next> {
// Interleaved f32: channels * 4 bytes per frame.
let block_align = channels as usize * 4;
@@ -611,10 +637,14 @@ fn capture_once(
samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]]));
}
stats.observe(&samples, channels);
// Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel
// means the encode thread is not keeping up, and every dropped chunk is a click plus
// a permanent shift of everything after it.
if tx.try_send(samples).is_err() {
// Non-blocking, lossy — same discipline as PipeWire. COUNTED, and only while a
// session is actually reading: a full channel under a LIVE consumer means the encode
// thread is not keeping up, and every dropped chunk is a click plus a permanent
// shift of everything after it. A full channel under a PARKED capturer means nothing
// — the planes park capturers between sessions rather than dropping them, so the
// channel fills once and then refuses everything
// ([`WasapiLoopbackCapturer::active`]).
if tx.try_send(samples).is_err() && active.load(Ordering::Relaxed) {
stats.dropped_chunks += 1;
}
}
+11 -2
View File
@@ -214,7 +214,12 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
))
.routes(routes!(host::get_status))
.routes(routes!(host::get_local_summary))
.routes(routes!(clients::list_paired_clients))
// GET and DELETE share the `/clients` path, so they must be ONE `routes!` — utoipa-axum
// merges the methods of a single call into one route; two calls collide on the path.
.routes(routes!(
clients::list_paired_clients,
clients::unpair_all_clients
))
.routes(routes!(clients::unpair_client));
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
// build's API (and its OpenAPI document) simply has no such endpoints.
@@ -226,7 +231,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(native::get_native_pairing))
.routes(routes!(native::arm_native_pairing))
.routes(routes!(native::disarm_native_pairing))
.routes(routes!(native::list_native_clients))
// Same-path pair as `/clients` above — one `routes!` for both methods.
.routes(routes!(
native::list_native_clients,
native::unpair_all_native_clients
))
.routes(routes!(native::unpair_native_client))
.routes(routes!(native::list_pending_devices))
.routes(routes!(native::approve_pending_device))
+56
View File
@@ -153,6 +153,62 @@ pub(crate) async fn unpair_client(
}
}
/// Unpair every client
///
/// The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,
/// carrying the same revocation guarantees across the whole set. A LIVE GameStream session is
/// ended (its owning certificate is necessarily one of those just removed), and the ENet control
/// port (UDP 47999) closes, because no pairing is left to hold it open.
///
/// Idempotent, and so a 200 rather than the single unpair's 204/404 pair: "unpair everything" is
/// satisfied by an already-empty store, and the operator still wants to know whether that meant
/// three devices or none.
#[utoipa::path(
delete,
path = "/clients",
tag = "clients",
operation_id = "unpairAllClients",
responses(
(status = OK, description = "Every client unpaired (possibly none)", body = UnpairAllResult),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Response {
let mut paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
if paired.is_empty() {
// Nothing to persist, no port to sync — an empty store is already the requested state.
return Json(UnpairAllResult { unpaired: 0 }).into_response();
}
let removed: Vec<[u8; 32]> = paired
.iter()
.map(|der| Sha256::digest(der).into())
.collect();
paired.clear();
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
// silently re-open the control port.
crate::gamestream::save_paired(&paired);
drop(paired);
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
// launch — the cert was unreadable at /launch — cannot be attributed, and is left to the port
// teardown below, which here always fires: no pairing remains.)
let live_owner = st
.app
.launch
.lock()
.unwrap_or_else(|e| e.into_inner())
.and_then(|l| l.owner_fp);
if live_owner.is_some_and(|fp| removed.contains(&fp)) {
st.app.quit_session("client unpaired");
}
if let Err(e) = crate::gamestream::sync_control(&st.app) {
tracing::warn!(error = %format!("{e:#}"), "control port sync after unpair-all failed");
}
let unpaired = removed.len() as u32;
tracing::info!(unpaired, "management API: all clients unpaired");
Json(UnpairAllResult { unpaired }).into_response()
}
/// Pairing-flow status
///
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
+46
View File
@@ -256,6 +256,52 @@ pub(crate) async fn unpair_native_client(
}
}
/// Unpair every native client
///
/// The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE
/// persisted write (not a loop of them — a failure partway would leave a half-emptied store), and
/// ends every live native session the removed clients own.
///
/// Idempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store
/// satisfies the request, and the count still tells the operator what it meant.
#[utoipa::path(
delete,
path = "/native/clients",
tag = "native",
operation_id = "unpairAllNativeClients",
responses(
(status = OK, description = "Every native client unpaired (possibly none)", body = UnpairAllResult),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
)
)]
pub(crate) async fn unpair_all_native_clients(State(st): State<Arc<MgmtState>>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.remove_all() {
Ok(removed) => {
// Revocation reaches LIVE sessions too — the same guarantee the single unpair gives,
// applied across the set.
let stopped: usize = removed
.iter()
.map(|fp| crate::session_status::stop_by_fingerprint(&fp.to_ascii_lowercase()))
.sum();
if stopped > 0 {
tracing::info!(stopped, "unpair-all: live native session(s) stopped");
}
let unpaired = removed.len() as u32;
tracing::info!(unpaired, "management API: all native clients unpaired");
Json(UnpairAllResult { unpaired }).into_response()
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
+12
View File
@@ -21,6 +21,18 @@ pub(crate) struct ApiError {
error: String,
}
/// What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and
/// `/native/clients`) so the console sees one schema across both pairing planes.
///
/// A count rather than 204: "unpair everything" is idempotent, so an empty store is a success, and
/// the operator still wants to be told whether that meant three devices or none.
#[derive(Serialize, ToSchema)]
pub(crate) struct UnpairAllResult {
/// Clients removed from the trust store — 0 when nothing was paired.
#[schema(example = 3)]
pub(crate) unpaired: u32,
}
pub(crate) fn api_error(status: StatusCode, message: &str) -> Response {
(
status,
+122 -1
View File
@@ -819,7 +819,8 @@ async fn paired_clients_list_and_unpair() {
{
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der);
// Cloned, not moved: the unpair-all section at the end of this test re-seeds it.
p.push(der.clone());
}
let (status, body) = send(&app, get_req("/api/v1/clients")).await;
@@ -888,6 +889,71 @@ async fn paired_clients_list_and_unpair() {
serde_json::from_slice::<Vec<Vec<u8>>>(&disk).unwrap(),
Vec::<Vec<u8>>::new()
);
// ---- the COLLECTION delete: unpair everything at once -----------------------------------
//
// Re-seed two clients (the store was just emptied) and clear the teardown flags, so what the
// bulk delete does to a live session is attributable to IT and not left over from above.
let second = crate::identity::ephemeral().unwrap();
let (_, second_pem) = x509_parser::pem::parse_x509_pem(second.cert_pem.as_bytes()).unwrap();
let second_der = second_pem.contents.clone();
let second_fp = hex::encode(Sha256::digest(&second_der));
{
use std::sync::atomic::Ordering;
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der.clone());
p.push(second_der);
state.quit.store(false, Ordering::SeqCst);
state.streaming.store(true, Ordering::SeqCst);
// A live session owned by the SECOND client — the bulk delete must end whichever of the
// removed certs owns it, not just the first one it happens to walk past.
let mut owner = [0u8; 32];
owner.copy_from_slice(&hex::decode(&second_fp).unwrap());
*state.launch.lock().unwrap() = Some(LaunchSession {
gcm_key: [0; 16],
rikeyid: 0,
width: 1920,
height: 1080,
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: Some(owner),
});
}
let del_all = || {
axum::http::Request::delete("/api/v1/clients")
.body(Body::empty())
.unwrap()
};
let (status, body) = send(&app, del_all()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["unpaired"], 2, "both clients must be reported removed");
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body, serde_json::json!([]));
{
use std::sync::atomic::Ordering;
assert!(
state.launch.lock().unwrap().is_none(),
"unpair-all must end the live session of any client it revokes"
);
assert!(state.quit.load(Ordering::SeqCst));
}
// Persisted, for the same reason the single unpair is: a resurrected pairing would re-open
// the control port on the next boot.
let disk = std::fs::read(tmp.path().join("paired.json")).unwrap();
assert_eq!(
serde_json::from_slice::<Vec<Vec<u8>>>(&disk).unwrap(),
Vec::<Vec<u8>>::new()
);
// Idempotent: emptying an empty store is a 200 with a zero count, NOT the single delete's 404.
// ("unpair everything" is already satisfied — there is no missing resource to report.)
let (status, body) = send(&app, del_all()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["unpaired"], 0);
}
#[cfg(feature = "gamestream")]
@@ -1248,8 +1314,13 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
// The bulk form is the same authority as the single one — and, sharing its path with a
// plugin-readable GET, worth an explicit row: both gates match on (method, path), so the
// roster's read permission must never carry over to emptying it.
("DELETE", "/api/v1/clients", false, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
("DELETE", "/api/v1/native/clients", false, false),
(
"DELETE",
"/api/v1/native/clients/{fingerprint}",
@@ -1667,6 +1738,56 @@ async fn native_pairing_arm_show_and_unpair() {
assert_eq!(b["armed"], false);
}
/// The collection delete on the native plane: one call empties the trust store, and repeating it
/// is a zero-count success rather than an error.
#[tokio::test]
async fn native_unpair_all_empties_the_trust_store() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-np-all-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
np.add("Living room TV", "aa11").unwrap();
np.add("Studio Deck", "bb22").unwrap();
assert_eq!(np.list().len(), 2);
let del_all = || {
axum::http::Request::delete("/api/v1/native/clients")
.body(Body::empty())
.unwrap()
};
let (status, body) = send(&app, del_all()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["unpaired"], 2);
// Gone from both the API and the store behind it (one persisted write, not two).
let (_, body) = send(&app, get_req("/api/v1/native/clients")).await;
assert_eq!(body, serde_json::json!([]));
assert!(np.list().is_empty());
assert!(!np.is_paired("aa11") && !np.is_paired("bb22"));
// Idempotent — unlike the single delete, which 404s on a fingerprint it cannot find.
let (status, body) = send(&app, del_all()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["unpaired"], 0);
}
/// Without a native plane there is no trust store to empty — 503, matching every other
/// `/native/*` route (and NOT a silent 200 that would tell the console it had unpaired something).
#[tokio::test]
async fn native_unpair_all_without_a_native_host_is_unavailable() {
let app = test_app(test_state(), None);
let req = axum::http::Request::delete("/api/v1/native/clients")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, req).await.0, StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn pending_devices_approve_and_deny() {
let np = Arc::new(
+66 -8
View File
@@ -2722,14 +2722,35 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
if let Some(period) = recovery_cadence.note(now) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network loss; \
correlate with 'slow display-descriptor poll' / 'display descriptor \
changed' / 'IDD-push capture stall' lines"
);
// A period that lands on the CLIENT's jump-to-live cooldown is not evidence
// of a periodic disturbance here at all — it is the client shedding a
// standing receive queue, which it is rate-limited to do exactly this often
// (`punktfunk_core::client::FLUSH_COOLDOWN`), so the cadence is a property of
// our own backpressure code rather than of anything physical. Naming display
// churn there sent a 2026-08-13 field investigation at three innocent
// subsystems while the real chain was: client refused the codec → demoted to
// a slower decode rung → could not sustain the rate → standing queue.
// Perfect periodicity argues FOR a software cooldown, not against it.
if matches_client_flush_cadence(period) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries match the client's jump-to-live cooldown \
the CLIENT cannot sustain the stream and is shedding a standing \
receive queue (check its log for 'receive backlog stopped draining' \
with queue_depth, and for a decode rung that demoted); a slower \
decode path or a link below the bitrate does this, and it is NOT a \
host display disturbance"
);
} else {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),
"client keyframe recoveries are METRONOMIC — a periodic host/display \
disturbance (display-topology churn, display-poller software, \
virtual-display timing) is the likely cause, not random network \
loss; correlate with 'slow display-descriptor poll' / 'display \
descriptor changed' / 'IDD-push capture stall' lines"
);
}
}
}
}
@@ -3785,6 +3806,23 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
Ok(())
}
/// Whether a measured keyframe-recovery period is the CLIENT's jump-to-live cooldown rather
/// than anything happening on this host.
///
/// Every jump-to-live sends a keyframe request and is rate-limited to one per
/// [`punktfunk_core::client::FLUSH_COOLDOWN`], so a client that simply cannot sustain the
/// stream asks at exactly that spacing for as long as it stays behind. The recovery-cadence
/// detector reads perfect periodicity as evidence of a periodic *disturbance*, which is
/// backwards here: a fixed software cooldown is the most periodic thing in the system.
///
/// ±10 % — wide enough for scheduling jitter and the request's network trip, narrow enough that
/// it cannot swallow the disturbance cadences the other branch exists to report (display-mode
/// churn and descriptor polls run at their own, unrelated periods).
fn matches_client_flush_cadence(period: std::time::Duration) -> bool {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
period.abs_diff(flush) < flush / 10
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = (
@@ -4597,6 +4635,26 @@ fn build_pipeline(
mod tests {
use super::*;
/// The 2026-08-13 field log's exact reading — `period_s=2.0` — must be attributed to the
/// client's backlog shedding, not to a host display disturbance. The whole point of routing
/// on the shared constant is that this stays true if the cooldown is ever retuned, so the
/// test derives its cases from `FLUSH_COOLDOWN` instead of hardcoding two seconds.
#[test]
fn a_recovery_cadence_on_the_clients_cooldown_is_not_blamed_on_the_display() {
let flush = punktfunk_core::client::FLUSH_COOLDOWN;
assert!(matches_client_flush_cadence(flush), "the field reading");
// Scheduling jitter and the request's trip across the link stay inside the band.
assert!(matches_client_flush_cadence(flush + flush / 20));
assert!(matches_client_flush_cadence(flush - flush / 20));
// Cadences that are NOT the cooldown still reach the display-disturbance branch — the
// band must not be so wide that it swallows them.
assert!(!matches_client_flush_cadence(flush / 2));
assert!(!matches_client_flush_cadence(flush * 2));
assert!(!matches_client_flush_cadence(flush + flush / 5));
assert!(!matches_client_flush_cadence(std::time::Duration::ZERO));
}
#[test]
fn an_escalated_but_caught_up_encoder_stops_refusing_climbs() {
const DEGRADE: u32 = 10;
@@ -159,6 +159,12 @@ impl NativePairing {
self.store.remove(fp_hex)
}
/// Remove EVERY paired client in one persisted write. Returns the fingerprints removed, so the
/// caller can end the sessions they own. On a persist failure nothing is removed.
pub fn remove_all(&self) -> Result<Vec<String>> {
self.store.remove_all()
}
// -- Delegated approval (roadmap §8b-1) ---------------------------------
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
@@ -130,6 +130,28 @@ impl TrustStore {
Ok(removed)
}
/// Remove EVERY paired client, in ONE persisted write. Returns the fingerprints removed, so
/// the caller can tear down the live sessions they own. On a persist failure the in-memory
/// store is rolled back (it never diverges from disk), exactly like [`Self::remove`].
///
/// Not a loop over [`Self::remove`]: that would rewrite (and fsync-rename) the store once per
/// client, and a failure partway would leave the operator with a half-emptied trust store and
/// no way to tell which half.
pub(super) fn remove_all(&self) -> Result<Vec<String>> {
let mut p = self.paired.lock().unwrap();
if p.clients.clients.is_empty() {
return Ok(Vec::new());
}
// `take` leaves the empty list in place to be persisted, and hands us the snapshot that
// doubles as both the rollback value and the removed-fingerprint report.
let snapshot = std::mem::take(&mut p.clients.clients);
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
Ok(snapshot.into_iter().map(|c| c.fingerprint).collect())
}
/// The number of paired clients (for the status snapshot).
pub(super) fn count(&self) -> u32 {
self.paired.lock().unwrap().clients.clients.len() as u32
+2 -1
View File
@@ -93,8 +93,9 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default on)* | Allow HDR (10-bit BT.2020 PQ) sessions on the gamescope backend. Needs the `punktfunk-gamescope` build — see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope); without the build, sessions stream SDR. Set `0` to force SDR. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = gamescope's own default of 400. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = 203, BT.2408 reference white, which is what our clients decode against (gamescope's own default of 400 sits nearly a stop above it). |
| `PUNKTFUNK_GAMESCOPE_BIN` | path | Force a specific gamescope binary for the sessions the host spawns. Unset = prefer `punktfunk-gamescope` on `PATH`, then `gamescope`. |
| `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` | path | Directory holding our Vulkan WSI layer's manifest — the layer that lets a game nested under gamescope get an HDR10 swapchain. Unset = `/usr/lib/punktfunk/vulkan/implicit_layer.d`, where every distro package installs it. The NixOS module sets this for you, since the layer lives inside the gamescope derivation there. If no manifest is found the host leaves the system's own layer alone and games stay SDR. |
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch **mid-stream** (rebuild the backend in place, no reconnect). **On by default** on Bazzite/SteamOS; set `0` to disable. |
| `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` | `1` | Add `--force-grab-cursor` to a bare gamescope session the host spawns **to run an app or game** (never the empty keep-alive session), forcing relative-mouse capture so FPS mouselook works over the injected pointer. **Off by default** — relative mode breaks absolute-pointer titles and menus, so turn it on per host. |
| `PUNKTFUNK_GAMESCOPE_SPLASH` | `1` · `0` *(default on)* | Run the built-in splash client inside each bare gamescope session the host spawns. **Leave it on**: gamescope only produces capture buffers once something paints, and a Steam launch paints nothing for its whole bootstrap — without the splash a fresh session starves and times out. `0` is a debugging escape hatch. |
+5 -2
View File
@@ -85,8 +85,11 @@ happens. Staying on attach also leaves the stream with no cursor;
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
too bright or too dim on your TV.
`PUNKTFUNK_GAMESCOPE_SDR_NITS`, which defaults to **203 nits**. That is BT.2408 reference white, and
it is the level our clients decode against, so the two ends agree out of the box. gamescope's own
default is 400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated
Steam UI and washed-out HDR game content on the same stream. Move the knob if you want a brighter or
dimmer desktop, but be aware that moving it re-opens that gap.
### Linux + GNOME
+4
View File
@@ -69,6 +69,10 @@
# from source and would make `nix flake check` an hour long.
punktfunk-gamescope = pkgs.callPackage ./packaging/nix/gamescope.nix {
patchDir = ./packaging/gamescope/patches;
# Shared verbatim with build-punktfunk-gamescope.sh, which is the whole reason it is a
# file: the FHS packages and the Nix store must rename the WSI layer identically, or
# the host looks for a layer name that only one of them produces.
manifestRewriter = ./packaging/gamescope/rewrite-wsi-layer-manifest.py;
};
};
in
+9 -1
View File
@@ -77,7 +77,15 @@ if [ -n "$GAMESCOPE" ]; then
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
# The package carries the Vulkan WSI layer alongside the compositor and the extraction above takes
# the whole `usr`, so this is an assertion rather than a step — but a silent one is exactly how
# this went wrong before: an image with the compositor and no layer streams HDR while every game
# inside it renders SDR, and nothing anywhere says why.
for f in usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so \
usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json; do
[ -f "$STAGE/$f" ] || { echo "$GAMESCOPE has no $f — no game HDR without it" >&2; exit 1; }
done
echo "folded in $("$GS_BIN" --version 2>&1 | head -1) + its WSI layer"
fi
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
+27 -10
View File
@@ -24,15 +24,20 @@
#
# Usage:
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
# [--gamescope path/to/punktfunk-gamescope] \
# [--gamescope-stage path/to/gamescope-destdir] \
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
#
# --gamescope folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
# --gamescope-stage folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
# /usr/bin/punktfunk-gamescope, which is what lets the gamescope backend stream 10-bit BT.2020 PQ.
# It is NOT built here: it is a C++ meson build with gamescope's whole dependency set, so CI builds
# it in the same Fedora container beforehand (`bash packaging/gamescope/build-punktfunk-gamescope.sh
# --destdir stage --prefix /usr`) and passes the resulting binary in. Omit it and the image is
# exactly what it was — the host then stays SDR on that backend, by design.
# --destdir stage --prefix /usr`) and passes that DESTDIR in. Omit it and the image is exactly what
# it was — the host then stays SDR on that backend, by design.
#
# A directory rather than the binary, because the tree also carries the Vulkan WSI layer built beside
# the compositor. That layer is the only route to an HDR10 swapchain for a game nested under
# gamescope, so an image with the compositor and without it would stream HDR while every game in it
# rendered SDR.
#
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
@@ -44,7 +49,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${2:?}"; shift 2 ;;
--gamescope) GAMESCOPE="${2:?}"; shift 2 ;;
--gamescope-stage) GAMESCOPE="${2:?}"; shift 2 ;;
*) RPMS+=("$1"); shift ;;
esac
done
@@ -87,15 +92,27 @@ if [ -d "$STAGE/etc" ]; then
fi
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
# The HDR-capable gamescope, when one was built (see --gamescope in the header). Verified by its
# The HDR-capable gamescope, when one was built (see --gamescope-stage in the header). Verified by its
# banner marker rather than trusted by filename: an unpatched gamescope shipped under this name
# would make the host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that
# back mid-session.
if [ -n "$GAMESCOPE" ]; then
[ -x "$GAMESCOPE" ] || { echo "no such executable: $GAMESCOPE" >&2; exit 1; }
"$GAMESCOPE" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GAMESCOPE has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
GS_BIN="$GAMESCOPE/usr/bin/punktfunk-gamescope"
GS_LAYER_SO="$GAMESCOPE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
GS_LAYER_JSON="$GAMESCOPE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$GS_BIN" ] || { echo "no such executable: $GS_BIN" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GS_BIN has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
# Fatal for the same reason the marker check is: an image carrying the compositor without its
# layer streams HDR while every game inside it renders SDR, and says nothing about why.
for f in "$GS_LAYER_SO" "$GS_LAYER_JSON"; do
[ -f "$f" ] || { echo "$f missing — the gamescope stage has no WSI layer" >&2; exit 1; }
done
install -Dm0755 "$GS_BIN" "$STAGE/usr/bin/punktfunk-gamescope"
install -Dm0755 "$GS_LAYER_SO" \
"$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$GS_LAYER_JSON" \
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
+28 -5
View File
@@ -10,24 +10,38 @@
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
#
# `--stage` is the DESTDIR build-punktfunk-gamescope.sh wrote, not a single binary: that tree carries
# the compositor AND the Vulkan WSI layer built beside it, and a game nested under gamescope gets its
# HDR10 swapchain from that layer or from nowhere. Taking the whole tree is what stops the next file
# in the package needing a new flag in every packaging script.
#
# Usage:
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
# --stage gs-cache [--arch amd64]
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
set -euo pipefail
BINARY=""
SRC_STAGE=""
DEB_ARCH=""
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--stage) SRC_STAGE="${2:?--stage needs a path}"; shift 2 ;;
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -n "$SRC_STAGE" ] || { echo "ERROR: --stage is required" >&2; exit 2; }
# The layout build-punktfunk-gamescope.sh writes under its --destdir/--prefix.
BINARY="$SRC_STAGE/usr/bin/punktfunk-gamescope"
LAYER_SO="$SRC_STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_JSON="$SRC_STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
# Hard, not best-effort: a package carrying the compositor without its layer looks perfectly healthy
# and then silently denies every game an HDR10 swapchain.
for f in "$LAYER_SO" "$LAYER_JSON"; do
[ -f "$f" ] || { echo "ERROR: $f missing from the stage — no game HDR without it" >&2; exit 1; }
done
PKG="punktfunk-gamescope"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
@@ -60,6 +74,12 @@ trap 'rm -rf "$STAGE"' EXIT
# root-only and some tooling refuses it.
chmod 0755 "$STAGE"
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
# /usr/lib/punktfunk, not a multiarch triplet dir: the layer manifest carries that absolute path
# baked in at build time, so the two have to agree. Nothing links the .so by soname — the Vulkan
# loader dlopens it by exactly that path — so multiarch has no say here.
install -Dm0755 "$LAYER_SO" "$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$LAYER_JSON" \
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
mkdir -p "$STAGE/DEBIAN"
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
@@ -103,7 +123,10 @@ fi
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
echo " into the capture stream, so the fps/stats readout is visible remotely."
echo " ."
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
echo " Installed as /usr/bin/punktfunk-gamescope, with its matching Vulkan WSI layer under"
echo " /usr/lib/punktfunk. The layer has its own name and its own enable variable, so it sits"
echo " beside your gamescope package's rather than replacing it; your system gamescope is"
echo " untouched."
} > "$STAGE/DEBIAN/control"
mkdir -p dist
+20 -4
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr6"
pkgver="${_gsver}.pfhdr7"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -39,6 +39,12 @@ pkgver="${_gsver}.pfhdr6"
# the composite (and the stream) black while every health signal stayed green. No capability the
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
# this way from one that cannot.
#
# pfhdr7 / rel 1: patch 0009 (reported + written by luxus, punktfunk-overlay#9) moves capture-buffer
# destruction off the PipeWire thread: remove_buffer used to drop the last CVulkanTexture ref there,
# racing steamcompmgr's vulkan_screenshot on the same device — a SIGSEGV precisely in the linger
# window, so a kept display was dead and reconnect lost the game session. No capability the host
# probes for, but "reconnect lost my game" triage has to read the difference off the banner.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
@@ -98,10 +104,20 @@ build() {
}
package() {
# ONLY the compositor, under our own name: a full `meson install` would also lay down
# gamescopectl / gamescopereaper / gamescopestream and the WSI layer, colliding file-for-file
# with the distro's `gamescope` package. The host only ever execs the compositor.
# The compositor, under our own name: a full `meson install` would also lay down
# gamescopectl / gamescopereaper / gamescopestream, colliding file-for-file with the distro's
# `gamescope` package. The host only ever execs the compositor.
install -Dm755 "${srcdir}/stage/usr/bin/punktfunk-gamescope" \
"${pkgdir}/usr/bin/punktfunk-gamescope"
# ...and the WSI layer built beside it. NOT a collision: the build script renamed the layer and
# rehomed it under /usr/lib/punktfunk, so it sits alongside the distro's rather than over it, and
# the host enables whichever it means to per session. Without this file no game in a punktfunk
# gamescope session can get an HDR10 swapchain at all — that layer is the only route to one.
install -Dm755 "${srcdir}/stage/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so" \
"${pkgdir}/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm644 "${srcdir}/stage/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json" \
"${pkgdir}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
install -Dm644 gamescope/LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+16
View File
@@ -19,6 +19,7 @@ The patches here add the missing half, and nothing else. See
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
| `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier |
### Why the headless patch matters
@@ -68,6 +69,20 @@ variant of the same fault instead. Two traps when triaging it:
lands in a working-looking game mode at the wrong resolution and without any of these patches.
Read the banner in `~/.gamescope-stdout.log`, not the fact that a session exists.
### Why the teardown patch is what makes linger real
Patch 0007 keeps a session alive across renegotiations; patch 0009 keeps it alive across
*disconnects*. When the capture consumer leaves, `stream_handle_remove_buffer` used to destroy
idle buffers on the PipeWire thread — and `~CVulkanTexture` talks to the Vulkan device
(`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
in `CVulkanCmdBuffer::insertBarrier`, timed precisely at stream end — so the display the host
keeps lingering for a reconnect is already dead, and the "resumed" session silently becomes a
fresh compositor with the game lost. The journal signature: a linger line, then a coredump, then
`kept display was dead — recreating`. Found, fixed and proven live by luxus
([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)) on 4K60 HDR + composited
cursor, the heaviest paint path we ship.
## Why the marker exists
punktfunk decides a session's shape **before** the virtual display exists: the bit depth at
@@ -87,6 +102,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
| `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
+22 -5
View File
@@ -7,19 +7,24 @@
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
# a session that told every game the display was 60 Hz.
#
# The binary is NOT built here; CI builds it once per Fedora major and caches it
# Nothing is BUILT here; CI builds once per Fedora major and caches the staged tree
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
# rebuilding.
#
# `--stage` is the DESTDIR that build-punktfunk-gamescope.sh wrote, not a single binary: that tree
# carries the compositor AND the WSI layer built beside it, and a game gets an HDR10 swapchain from
# that layer or from nowhere. Taking the whole tree rather than a file per artifact is deliberate —
# it is what stops the next file added to the package needing a new flag in four packaging scripts.
#
# Usage:
# bash packaging/gamescope/build-gamescope-rpm.sh \
# --binary gs-cache/punktfunk-gamescope \
# --stage gs-cache \
# [--version 3.16.25] [--release 1] [--outdir dist]
#
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
set -euo pipefail
BINARY=""
STAGE=""
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
VERSION=""
@@ -28,7 +33,7 @@ OUTDIR="dist"
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--stage) STAGE="${2:?--stage needs a path}"; shift 2 ;;
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
@@ -36,8 +41,18 @@ while [ $# -gt 0 ]; do
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -n "$STAGE" ] || { echo "ERROR: --stage is required" >&2; exit 2; }
# The layout build-punktfunk-gamescope.sh writes under its --destdir/--prefix.
BINARY="$STAGE/usr/bin/punktfunk-gamescope"
LAYER_SO="$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_JSON="$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
# Hard, not best-effort. A package that carries the compositor without its layer looks completely
# healthy and then silently denies every game an HDR10 swapchain — the failure this whole change
# exists to end. Better to fail the packaging step than to ship that quietly again.
for f in "$LAYER_SO" "$LAYER_JSON"; do
[ -f "$f" ] || { echo "ERROR: $f missing from the stage — no game HDR without it" >&2; exit 1; }
done
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
@@ -66,6 +81,8 @@ TOP="$(mktemp -d)"
trap 'rm -rf "$TOP"' EXIT
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
install -m0755 "$LAYER_SO" "$TOP/SOURCES/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -m0644 "$LAYER_JSON" "$TOP/SOURCES/punktfunk_gamescope_wsi.json"
mkdir -p "$OUTDIR"
rpmbuild \
@@ -90,10 +90,14 @@ echo "==> configuring"
# test suite is not our job either way.
# -Denable_openvr_support the VR integration pulls the openvr submodule + its build for a
# code path a headless capture session never enters.
# -Denable_gamescope_wsi_layer the WSI layer is a SEPARATE artifact the distro's gamescope
# package already installs; ours must not collide with it.
# (The layer the nested games load is that one — it is version-
# independent of the compositor binary.)
# -Denable_gamescope_wsi_layer ON, and installed under our own name below. This used to be off,
# on the grounds that the distro's gamescope package already ships a
# layer and that the layer is "version-independent of the compositor
# binary". That second half is FALSE: the layer and the compositor
# speak `gamescope_swapchain` to each other, and when they disagree
# the compositor rejects the client's `swapchain_feedback` and every
# Vulkan client dies on a black screen. A compositor we ship needs
# the layer we built beside it.
#
# `force_fallback_for` includes **wlroots** on purpose, and it is load-bearing for a binary we
# SHIP: gamescope vendors a wlroots submodule, but meson prefers a system one when the build host
@@ -124,7 +128,7 @@ meson setup "$BUILD" "$SRCDIR" \
-Dpipewire=enabled \
-Denable_tests=false \
-Denable_openvr_support=false \
-Denable_gamescope_wsi_layer=false
-Denable_gamescope_wsi_layer=true
echo "==> building"
ninja -C "$BUILD" ${JOBS:+-j "$JOBS"}
@@ -149,6 +153,36 @@ DEST="${DESTDIR}${PREFIX}/bin/punktfunk-gamescope"
echo "==> installing $DEST"
install -Dm755 "$BIN" "$DEST"
# The WSI layer, under OUR name, at OUR path.
#
# A game nested under gamescope gets an HDR10 swapchain from this layer and from nothing else —
# gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
# through — so a compositor shipped WITHOUT a matching layer simply cannot do HDR for games. Built
# from this same tree at this same rev, so the two can never drift apart; that is the whole point,
# and it is what makes the host's old "compare version triples and hope" check unnecessary.
#
# It must not collide with the distro's layer and must be switchable independently of it, so the
# generated manifest is rewritten to carry our layer name, our library path and our own
# enable/disable variables. The Vulkan loader keys implicit layers on that NAME, so with a distinct
# one both layers can sit installed side by side and the host picks per session.
#
# python3 rather than sed because meson is itself a Python program — it is guaranteed present on any
# host that got this far — and a JSON edit belongs in a JSON parser.
LAYER_SO=$(find "$BUILD" -type f -name 'libVkLayer_*gamescope_wsi*.so' | head -1)
LAYER_SRC_JSON=$(find "$BUILD" -type f -name '*gamescope_wsi*.json' | head -1)
[ -n "$LAYER_SO" ] && [ -n "$LAYER_SRC_JSON" ] || {
echo "the WSI layer did not build (.so=${LAYER_SO:-none} .json=${LAYER_SRC_JSON:-none}) — without" >&2
echo "it no game in a punktfunk gamescope session can get an HDR10 swapchain" >&2
exit 1
}
LAYER_LIB_PATH="${PREFIX}/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_DEST_JSON="${DESTDIR}${PREFIX}/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
echo "==> installing ${DESTDIR}${LAYER_LIB_PATH}"
install -Dm755 "$LAYER_SO" "${DESTDIR}${LAYER_LIB_PATH}"
install -d "$(dirname "$LAYER_DEST_JSON")"
python3 "$(dirname "$0")/rewrite-wsi-layer-manifest.py" \
"$LAYER_SRC_JSON" "$LAYER_DEST_JSON" "$LAYER_LIB_PATH"
if [ "$SETCAP" = 1 ] && command -v setcap >/dev/null; then
# gamescope raises its own scheduling priority; without CAP_SYS_NICE it still runs, just noisier
# and with worse frame pacing. Best-effort — needs root, and a package sets it declaratively.
@@ -0,0 +1,159 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: luxus <luxus@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:15:48 +0200
Subject: [PATCH] pipewire: destroy capture textures on the compositor thread
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the capture consumer leaves, PipeWire remove_buffer destroys idle
buffers on the PW thread. CVulkanTexture's destructor talks to the Vulkan
device (DestroyImage / FreeMemory / close dmabuf fds). steamcompmgr may
still be inside vulkan_screenshot on another buffer of the same device.
That race SIGSEGVs in CVulkanCmdBuffer::insertBarrier / libnvidia-eglcore
and kills the compositor. A lingering gamescope session then cannot be
reused — the host has to spawn a new one.
Queue those corpses and delete them on the steamcompmgr vblank, including
when the stream is only paused (linger: no consumer, compositor stays up).
The same race exists on the stale-push path (dispatch_nudge destroying a
buffer whose pw_buffer vanished while it was copying), so that call is
buried too. Corpses queued when the compositor exits are reclaimed by the
kernel, same as patch 0006's deliberate leak.
Reported, written and proven live by luxus (punktfunk-overlay#9): four
coredumps on an NVIDIA host, all at stream end / linger, three in
paint_pipewire → vulkan_screenshot → insertBarrier with a concurrent
destroy_buffer → ~CVulkanTexture on the PipeWire thread; after this patch
a disconnect/reconnect reuses the lingered session.
Not addressed here: stream_handle_add_buffer's `error:` path still deletes
on the PW thread. By the later `goto error`s a texture may be attached, so
the same race is reachable in theory — but only when an add FAILS mid-
renegotiation, which none of the field coredumps show. Left as-is to stay
byte-identical with the change that was proven on-glass.
punktfunk: the banner moves to +pfhdr7. No new capability — but a build
whose linger can die of its own capture teardown is indistinguishable from
one that cannot except by this marker, and "reconnect lost my game" is
exactly the field report that needs that read (same rule as pfhdr5/6).
Upstream: yes — the race is upstream's paint_pipewire vs destroy_buffer;
our HDR/cursor patches only make the paint path heavier.
---
src/meson.build | 3 ++-
src/pipewire.cpp | 28 ++++++++++++++++++++++++++--
src/pipewire.hpp | 3 +++
src/steamcompmgr.cpp | 10 ++++++++--
4 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/src/meson.build b/src/meson.build
index acfcaea..fe854af 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -186,7 +186,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
-version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability)
+version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index c683b3a..3c727e9 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -7,6 +7,7 @@
#include <unistd.h>
#include <atomic>
+#include <mutex>
#include <thread>
#include <vector>
@@ -103,6 +104,29 @@ static void destroy_buffer(struct pipewire_buffer *buffer) {
delete buffer;
}
+// The PipeWire thread must not destroy CVulkanTextures: steamcompmgr may be
+// inside vulkan_screenshot on another buffer of the same device. Queue the
+// corpse and let steamcompmgr delete it on the next vblank.
+static std::mutex s_deadBuffersMutex;
+static std::vector<struct pipewire_buffer *> s_deadBuffers;
+
+static void bury_buffer(struct pipewire_buffer *buffer)
+{
+ std::lock_guard<std::mutex> lock(s_deadBuffersMutex);
+ s_deadBuffers.push_back(buffer);
+}
+
+void pipewire_reap_dead_buffers(void)
+{
+ std::vector<struct pipewire_buffer *> dead;
+ {
+ std::lock_guard<std::mutex> lock(s_deadBuffersMutex);
+ dead.swap(s_deadBuffers);
+ }
+ for (struct pipewire_buffer *buffer : dead)
+ destroy_buffer(buffer);
+}
+
void pipewire_destroy_buffer(struct pipewire_buffer *buffer)
{
destroy_buffer(buffer);
@@ -384,7 +408,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
pwr_log.errorf("pw_stream_queue_buffer failed");
}
} else {
- destroy_buffer(buffer);
+ bury_buffer(buffer);
}
}
}
@@ -694,7 +718,7 @@ static void stream_handle_remove_buffer(void *data, struct pw_buffer *pw_buffer)
buffer->buffer = nullptr;
if (!buffer->copying) {
- destroy_buffer(buffer);
+ bury_buffer(buffer);
}
}
diff --git a/src/pipewire.hpp b/src/pipewire.hpp
index b4d7e29..d0b510c 100644
--- a/src/pipewire.hpp
+++ b/src/pipewire.hpp
@@ -60,5 +60,8 @@ uint32_t get_pipewire_stream_node_id(void);
struct pipewire_buffer *dequeue_pipewire_buffer(void);
bool pipewire_is_streaming();
void pipewire_destroy_buffer(struct pipewire_buffer *buffer);
+// Destroy textures queued by the PipeWire thread. Must run on steamcompmgr —
+// CVulkanTexture's dtor talks to the Vulkan device.
+void pipewire_reap_dead_buffers(void);
void push_pipewire_buffer(struct pipewire_buffer *buffer);
void nudge_pipewire(void);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 14596ae..932316f 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -9321,8 +9321,14 @@ steamcompmgr_main(int argc, char **argv)
#if HAVE_PIPEWIRE
// Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it).
- if ( vblank && pipewire_is_streaming() )
- paint_pipewire();
+ // Reap even when the consumer is gone: linger keeps us alive, and textures
+ // queued by remove_buffer must die on this thread, not PipeWire's.
+ if ( vblank )
+ {
+ pipewire_reap_dead_buffers();
+ if ( pipewire_is_streaming() )
+ paint_pipewire();
+ }
#endif
update_vrr_atoms(root_ctx, false, &flush_root);
+33 -2
View File
@@ -9,7 +9,7 @@
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
#
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --stage <destdir-the-build-script-wrote>
Name: punktfunk-gamescope
Version: %{pf_version}
Release: %{pf_release}%{?dist}
@@ -19,6 +19,12 @@ Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor
License: BSD-2-Clause
URL: https://git.unom.io/unom/punktfunk
Source0: punktfunk-gamescope
# The Vulkan WSI layer built from the same tree at the same rev as the compositor above. A game
# nested under gamescope gets its HDR10 swapchain from this layer and from nothing else, and a layer
# built for a DIFFERENT gamescope kills every Vulkan client — so the two ship together or the
# package is a trap.
Source1: libVkLayer_PUNKTFUNK_gamescope_wsi.so
Source2: punktfunk_gamescope_wsi.json
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
@@ -49,7 +55,10 @@ packaging/gamescope/patches:
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
stream, so the fps/stats readout is visible to someone watching remotely.
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
Installed as /usr/bin/punktfunk-gamescope, with its matching Vulkan WSI layer under
/usr/lib/punktfunk. The layer carries its own name and its own enable variable, so it sits beside
the one your gamescope package installs rather than replacing it, and only sessions punktfunk-host
starts switch to it. Your system gamescope is untouched.
%prep
# Nothing to unpack: Source0 IS the binary.
@@ -60,6 +69,14 @@ Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
%install
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
# /usr/lib, spelled literally rather than %{_libdir}, which is /usr/lib64 here. The layer's manifest
# carries an ABSOLUTE library_path baked in at build time (/usr/lib/punktfunk/...), so this path and
# that string have to agree or the loader finds a manifest pointing at nothing. Nothing links this
# .so by soname — the Vulkan loader dlopens it by that absolute path — so there is no multilib
# question to answer, and a private vendor directory is where it belongs.
install -Dm0755 %{SOURCE1} %{buildroot}/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so
install -Dm0644 %{SOURCE2} %{buildroot}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json
%check
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
@@ -71,8 +88,22 @@ install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
exit 1
}
# The manifest's absolute library_path must name the file we actually installed. Getting this wrong
# (%{_libdir} on a multilib box, a renamed .so) produces a package that installs cleanly and then
# does nothing at all — the loader reads a manifest, finds no library, and moves on in silence.
LAYER_LIB="$(grep -o '"library_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
%{buildroot}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json \
| sed 's/.*"\(\/[^"]*\)".*/\1/')"
[ -f "%{buildroot}${LAYER_LIB}" ] || {
echo "punktfunk-gamescope: the layer manifest points at ${LAYER_LIB}, which this package does" >&2
echo " not install games would silently get no HDR swapchain" >&2
exit 1
}
%files
%{_bindir}/punktfunk-gamescope
/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so
/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json
%changelog
# Generated per build; see the git history for the patch set's own changes.
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Rewrite gamescope's generated Vulkan layer manifest so OUR copy of the layer can be installed
beside the distro's instead of colliding with it.
A game nested under gamescope gets its HDR10 swapchain from the FROG WSI layer and from nothing
else, and that layer speaks `gamescope_swapchain` to the compositor: a layer built for a DIFFERENT
gamescope makes the compositor reject the client's swapchain_feedback, and every Vulkan client dies
on a black screen with sound and input and no error. So a compositor we ship needs the layer we
built beside it which means two gamescope WSI layers on one box.
Three fields make that safe, and the loader is why:
* `name` the Vulkan loader deduplicates implicit layers by name, and with both called
VK_LAYER_FROG_gamescope_wsi which one wins is unspecified. A distinct name is what lets both sit
installed at once.
* `library_path` made absolute, so resolution never depends on where the loader found the
manifest.
* `enable_environment` / `disable_environment` our own gates, so the host can switch ours ON and
the distro's OFF in the same session. Sharing ENABLE_GAMESCOPE_WSI would make that impossible.
Everything else is passed through untouched, `functions` above all: it names the layer's entry
points, and a manifest with the wrong ones is a layer that silently never loads.
Used by build-punktfunk-gamescope.sh (FHS packaging) and packaging/nix/gamescope.nix (the Nix store),
which is the point of it being a file rather than a heredoc the two must not drift.
Usage: rewrite-wsi-layer-manifest.py <src.json> <dst.json> <installed-library-path>
"""
import json
import sys
LAYER_NAME = "VK_LAYER_PUNKTFUNK_gamescope_wsi"
ENABLE_VAR = "PUNKTFUNK_GAMESCOPE_WSI"
DISABLE_VAR = "PUNKTFUNK_GAMESCOPE_WSI_DISABLE"
def main(argv):
if len(argv) != 4:
print(__doc__, file=sys.stderr)
return 2
src, dst, lib = argv[1:4]
with open(src) as f:
manifest = json.load(f)
layer = manifest.get("layer")
if not isinstance(layer, dict):
print(f"{src}: no 'layer' object — not a Vulkan layer manifest", file=sys.stderr)
return 1
# A manifest that never named the entry points would produce a layer that loads and does
# nothing, which is indistinguishable on a running box from "this GPU has no HDR".
if not layer.get("functions") and not layer.get("library_path"):
print(f"{src}: neither 'functions' nor 'library_path' — refusing to rewrite", file=sys.stderr)
return 1
layer["name"] = LAYER_NAME
layer["library_path"] = lib
layer["enable_environment"] = {ENABLE_VAR: "1"}
layer["disable_environment"] = {DISABLE_VAR: "1"}
with open(dst, "w") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+38 -4
View File
@@ -31,7 +31,9 @@
{
lib,
gamescope,
python3,
patchDir,
manifestRewriter,
}:
let
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
@@ -81,14 +83,39 @@ unwrapped.overrideAttrs (old: {
"vcs_tag = '${old.version}'"
'';
# Ship ONLY the compositor, renamed. Everything else nixpkgs installs (gamescopectl,
# gamescopereaper, gamescopestream, the WSI layer, .desktop files) belongs to the real gamescope
# package — duplicating it here would put two of each on PATH. The host only execs the
# compositor.
# Ship the compositor, renamed, AND the WSI layer built beside it. Everything else nixpkgs
# installs (gamescopectl, gamescopereaper, gamescopestream, .desktop files) belongs to the real
# gamescope package — duplicating it here would put two of each on PATH.
#
# The layer is not dressing: a game nested under this compositor gets its HDR10 swapchain from it
# or from nowhere, and a layer built for a DIFFERENT gamescope makes the compositor reject the
# client's swapchain_feedback and kills every Vulkan client. So it travels with the binary it was
# built against. It is renamed and re-homed under $out/lib/punktfunk, with its own enable
# variable, so it sits beside the system gamescope's layer rather than shadowing it — the Vulkan
# loader deduplicates implicit layers by name, so two of the same name would be a coin toss.
#
# Staged through $TMPDIR because the prune below removes $out/lib and $out/share wholesale.
postInstall = (old.postInstall or "") + ''
layerSo=$(find $out -type f -name 'libVkLayer_*gamescope_wsi*.so' | head -1)
layerJson=$(find $out -type f -name '*gamescope_wsi*.json' | head -1)
if [ -z "$layerSo" ] || [ -z "$layerJson" ]; then
echo "punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game under the" >&2
echo " compositor could ever obtain an HDR10 swapchain" >&2
exit 1
fi
cp "$layerSo" "$TMPDIR/pf-layer.so"
${python3}/bin/python3 ${manifestRewriter} \
"$layerJson" "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
find $out -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} +
find $out/bin -mindepth 1 ! -name gamescope -delete
mv $out/bin/gamescope $out/bin/punktfunk-gamescope
install -Dm0755 "$TMPDIR/pf-layer.so" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
'';
# `gamescope --version` exits non-zero on some builds; the grep is the real assertion.
@@ -97,6 +124,13 @@ unwrapped.overrideAttrs (old: {
runHook preInstallCheck
$out/bin/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' \
|| { echo "punktfunk-gamescope: the +pfhdr marker is missing the patches did not take"; exit 1; }
# The manifest must name a library this derivation actually installed. A manifest pointing at a
# path that does not exist is the worst shape of this bug: the loader reads it, finds nothing,
# and carries on silently, so the box looks healthy and every game renders SDR.
lib=$(sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json)
[ -f "$lib" ] \
|| { echo "punktfunk-gamescope: the layer manifest points at $lib, which is not installed"; exit 1; }
runHook postInstallCheck
'';
+10
View File
@@ -507,6 +507,16 @@ in
# can still override this (or set it to `off` to force the in-process encoder) — the same
# "an operator's own override still wins" posture as PUNKTFUNK_GAMESCOPE_BIN above.
environment.PUNKTFUNK_ENCODE_WORKER = "${config.security.wrapperDir}/punktfunk-encode-worker";
# Where our Vulkan WSI layer's manifest lives. The host defaults to the FHS path every
# distro package uses, which no NixOS box has — here the layer travels inside the gamescope
# derivation, so point at it. Without this a game nested under the compositor gets no HDR10
# swapchain at all: that layer is the only route to one, and the host falls back to
# disabling the system layer, which is HDR-less by construction.
#
# Same override posture as PUNKTFUNK_GAMESCOPE_BIN: `Environment=` renders before
# `EnvironmentFile=`, so an operator's `settings` still wins.
environment.PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR = mkIf cfg.host.gamescopeHdr
"${cfg.host.gamescopePackage}/lib/punktfunk/vulkan/implicit_layer.d";
serviceConfig = {
# The store path DIRECTLY — not a capability wrapper. /proc/<pid>/exe then resolves to the
# very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is
+4
View File
@@ -118,6 +118,7 @@
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"action_unpair_all": "Alle entkoppeln",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
@@ -263,6 +264,9 @@
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln?",
"pairing_native_unpair_body": "Es muss sich erneut koppeln, um zu verbinden.",
"pairing_native_unpair_all_confirm": "Alle {count} Geräte entkoppeln?",
"pairing_native_unpair_all_body": "Jedes gekoppelte Gerät — punktfunk/1 wie Moonlight — muss sich erneut koppeln, um zu verbinden; was gerade streamt, wird getrennt.",
"pairing_native_unpair_all_failed": "Einige Geräte konnten nicht entkoppelt werden.",
"pairing_protocol": "Protokoll",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
+4
View File
@@ -118,6 +118,7 @@
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"action_unpair_all": "Unpair all",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
@@ -263,6 +264,9 @@
"pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device?",
"pairing_native_unpair_body": "It will need to pair again to connect.",
"pairing_native_unpair_all_confirm": "Unpair all {count} devices?",
"pairing_native_unpair_all_body": "Every paired device — punktfunk/1 and Moonlight alike — will need to pair again to connect, and anything streaming right now is disconnected.",
"pairing_native_unpair_all_failed": "Some devices could not be unpaired.",
"pairing_protocol": "Protocol",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
+77 -3
View File
@@ -1,14 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairAllClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import {
getListNativeClientsQueryKey,
useListNativeClients,
useUnpairAllNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
@@ -49,6 +52,8 @@ export const PairedDevicesSection: FC = () => {
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
const unpairMoonlight = useUnpairClient();
const unpairAllNative = useUnpairAllNativeClients();
const unpairAllMoonlight = useUnpairAllClients();
const rows: PairedRow[] = [
...(native.data ?? []).map(
@@ -94,6 +99,39 @@ export const PairedDevicesSection: FC = () => {
}
};
/**
* Unpair EVERY device, in one confirmation.
*
* Two calls, not one per device: each plane owns a separate trust store behind its own
* collection DELETE, and each of those empties its store in a single persisted write host-side.
* Only the planes actually holding a row are called the native endpoint answers 503 on a host
* built without it, which would otherwise report a failure for devices that were never there.
*/
const onUnpairAll = async () => {
const ok = await confirm({
title: m.pairing_native_unpair_all_confirm({ count: rows.length }),
description: m.pairing_native_unpair_all_body(),
confirmLabel: m.action_unpair_all(),
destructive: true,
});
if (!ok) return;
const calls: Promise<unknown>[] = [];
if (rows.some((r) => r.protocol === "native")) {
calls.push(unpairAllNative.mutateAsync());
}
if (rows.some((r) => r.protocol === "moonlight")) {
calls.push(unpairAllMoonlight.mutateAsync());
}
// allSettled, not all: the two planes are independent, so one failing must neither cancel
// the other nor throw past this handler.
const settled = await Promise.allSettled(calls);
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() });
if (settled.some((r) => r.status === "rejected")) {
toast.error(m.pairing_native_unpair_all_failed());
}
};
// The fingerprint of the row whose unpair is in flight (if any) — so only THAT row's button
// disables, not every row's.
const pendingFingerprint =
@@ -105,6 +143,11 @@ export const PairedDevicesSection: FC = () => {
: undefined) ??
null;
// Derived, not state: the two bulk calls are launched together and awaited together, so their
// pending flags cover the whole run without a gap in the middle to flicker through.
const isUnpairingAll =
unpairAllNative.isPending || unpairAllMoonlight.isPending;
return (
<PairedDevices
rows={rows}
@@ -115,7 +158,9 @@ export const PairedDevicesSection: FC = () => {
moonlight.refetch();
}}
onUnpair={onUnpair}
onUnpairAll={onUnpairAll}
pendingFingerprint={pendingFingerprint}
isUnpairingAll={isUnpairingAll}
/>
);
};
@@ -127,12 +172,39 @@ export const PairedDevices: FC<{
error: unknown;
refetch: () => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
/** Unpair every row, behind one confirmation. */
onUnpairAll: () => void;
/** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */
pendingFingerprint: string | null;
}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => (
/** A bulk unpair is walking the list — every control in the card disables until it finishes. */
isUnpairingAll: boolean;
}> = ({
rows,
isLoading,
error,
refetch,
onUnpair,
onUnpairAll,
pendingFingerprint,
isUnpairingAll,
}) => (
<Card>
<CardHeader>
{/* flex-row: CardHeader stacks by default, and this one carries a trailing action. */}
<CardHeader className="flex-row items-center justify-between gap-4 space-y-0">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
{/* Nothing to unpair in bulk when the list is empty (or still loading) an enabled
button there would open a confirmation reading "Unpair all 0 devices?". */}
{rows.length > 0 && (
<Button
variant="destructive"
size="sm"
disabled={isUnpairingAll}
onClick={onUnpairAll}
>
<Trash2 className="size-4" />
{m.action_unpair_all()}
</Button>
)}
</CardHeader>
<CardContent>
@@ -172,7 +244,9 @@ export const PairedDevices: FC<{
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={pendingFingerprint === r.fingerprint}
disabled={
isUnpairingAll || pendingFingerprint === r.fingerprint
}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
+2
View File
@@ -77,7 +77,9 @@ export const Armed: Story = {
error={null}
refetch={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
isUnpairingAll={false}
/>
),
},