Compare commits

...
Author SHA1 Message Date
enricobuehler 13aa59c575 fix(vdisplay): the wlr-family backends asserted a cursor mode instead of negotiating it, so the portal refused the call
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 3m27s
android / android (pull_request) Successful in 4m2s
ci / rust-arm64 (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 10m47s
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the
session had negotiated the cursor channel, and never asked the backend what it
supports. That is not a soft failure: xdg-desktop-portal's FRONTEND validates the
requested mode against the backend's `AvailableCursorModes` and fails the call
with `"Unavailable cursor mode %x"` before the backend ever sees it.

So a cursor-forward session (desktop mouse mode) died at `select_sources`,
surfacing as "pipeline build failed" and a black client, with
`unavailable cursor mode 4` in the portal log. Field report 2026-08-14.

MEASURED on .21 the same day, and it is worse than the report suggested: against
a LIVE Hyprland 0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and
xdg-desktop-portal 1.22.1 — all current — `AvailableCursorModes` reads **3**
(Hidden|Embedded) on both the backend impl interface and the frontend. xdph does
not offer the metadata cursor at all, so this broke EVERY cursor-forward session
on current Hyprland, not merely on old installs. Updating the portal would not
have helped. xdpw is the same from the other end: its screencast.c refuses
METADATA outright.

pf-capture's own portal path has always negotiated (`choose_cursor_mode`); this
restates that ladder in pf-vdisplay, which may not depend on pf-capture. The
downgrade is graceful rather than merely survivable: with the portal on Embedded
no `SPA_META_Cursor` arrives, so the host feeds the cursor channel nothing and a
cursor-forward client draws nothing of its own — one pointer, not two.

`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata` pins the preference
for a backend that advertises a mode it implements badly, which negotiation
cannot detect. It is a preference only: pins run the same ladder, so no value can
re-create the refused request.

The module is declared unconditionally so its ladder tests run on every CI leg
rather than only the one that compiles `mod hyprland` — including a Linux-only
test pinning our bit values against ashpd's enum, verified non-vacuous by
planting a wrong discriminant (ashpd answers 4 for Metadata, the number in the
report). The regression test uses 3, the bitfield measured on glass. Linux: 225
tests pass, clippy --all-targets -D warnings clean.
2026-08-14 10:27:14 +02:00
enricobuehler 0ead084838 Merge pull request 'Steam's art lives in Program Files, which was never an allowed art root' (#215) from worktree-steam-art-root-windows into main
arch / build-publish (push) Failing after 32s
ci / web (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m43s
ci / bun-nix (push) Successful in 1m48s
apple / swift (push) Successful in 2m2s
decky / build-publish (push) Failing after 41s
deb / build-publish-host (push) Failing after 2m13s
deb / build-publish-gamescope (push) Failing after 1m46s
deb / build-publish-client-arm64 (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m11s
ci / rust (push) Successful in 8m11s
android / android (push) Successful in 9m34s
docker / deploy-docs (push) Failing after 6m25s
windows-host / package (push) Successful in 13m53s
windows-host / winget-source (push) Skipped
apple / distribute (push) Successful in 12m19s
deb / build-publish (push) Failing after 14m16s
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
apple / screenshots (push) Successful in 6m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
deb / smoke-install (push) Failing after 9m33s
2026-08-14 07:51:30 +00:00
enricobuehler 0f9ccfa8b6 fix(ci): funnel the art tests' env overrides through one RAII guard
ci / bun-nix (pull_request) Successful in 24s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 5m47s
ci / docs-site (pull_request) Successful in 5m51s
ci / rust-arm64 (pull_request) Successful in 5m54s
ci / rust (pull_request) Successful in 18m16s
CI gate C (unsafe hygiene) failed on the previous commit: `library/art.rs`
went from 4 process-global-API mentions to 10, because the two new tests each
hand-rolled a set/restore pair the way the two existing ones already did.

The gate says fix the call sites rather than raise the baseline, and it is
right to here — the hand-rolled pattern was also leaking. Each test set
`PUNKTFUNK_LIBRARY_ART_ROOTS` and unset it at the end, so any assertion
firing between the two halves left the override installed for every later
test in the process, turning one real failure into a cascade.

`ArtRootsEnv` now holds the lock and the saved values and restores them on
drop, which runs on an unwind too. `write_env` is the single write point, so
the gate has exactly one pair of call sites to judge: the count drops to 2,
below the old baseline of 4, and stays flat however many tests are added.
Baseline lowered to 2 in the same commit, as the ratchet's policy requires.

⚠ The gate greps for the API names in COMMENTS as well as code, so the SAFETY
comments here deliberately describe the calls instead of naming them.

Re-verified after the refactor: .25 493/493 + clippy clean, .133 12/12 art
tests + clippy clean, `check-unsafe-hygiene.sh` clean locally.
2026-08-14 09:32:43 +02:00
enricobuehler 8d60f1cec0 Merge remote-tracking branch 'origin/main' into worktree-steam-art-root-windows
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 1m51s
android / android (pull_request) Successful in 3m57s
ci / docs-site (pull_request) Successful in 3m8s
ci / rust (pull_request) Failing after 6m35s
# Conflicts:
#	CHANGELOG.md
2026-08-14 09:12:39 +02:00
enricobuehler 6dd4add11b fix(library): Steam's art lives in Program Files, which was never an allowed art root
ci / bun-nix (pull_request) Successful in 20s
android / android (pull_request) Canceled after 1m22s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / rust (pull_request) Canceled after 1m22s
ci / web (pull_request) Canceled after 1m18s
ci / docs-site (pull_request) Canceled after 1m18s
A field report: the Steam plugin installed, the grid stayed empty, and the
only clue was one warn per sync — `art.hero: local art must be an image file
… inside an allowed art root`.

Two defects, both here.

The art roots defaulted to the users base (`C:\Users`, from `%PUBLIC%`'s
parent). That covers the launchers that install per-user, but not Steam,
which installs to `C:\Program Files (x86)\Steam` and keeps both the things
the plugin publishes there — `appcache\librarycache\<appid>\<hash>\` and each
account's `userdata\<id>\config\grid\`. So every cover was out of root. It is
a v0.28.0 regression: the built-in scanner the plugin replaced served covers
through the legacy `steam:` art-proxy branch, which never passed through the
H-2 confinement, so deleting the scanner routed that art through a gate it
had never been measured against. `art_roots()` now also carries every Steam
install it can find, from the three Program Files vars and from HKLM
`Valve\Steam\InstallPath` so a Steam on another drive counts too. POSIX needs
no equivalent — native and Flatpak Steam are both already under `$HOME`.

The confinement is not weakened. It exists to stop the host (SYSTEM) reading
what the plugin lane (LocalService) cannot reach itself; the Steam directory
is readable by LocalService already, so nothing there is reachable *because*
the host is privileged, and the extension, regular-file, magic-byte and
config-dir gates still apply on top. Tested: `config.vdf` is not servable
from an art root, nor is a non-image wearing `.png`.

Second, and the reason this cost a whole library rather than a thumbnail: the
provider reconcile validated art per entry and 400'd the WHOLE payload on the
first bad value. A path mismatch therefore deleted every game from that
store, and the plugin — which only ever sees `HostRequestError` — could not
say which. A reconcile now strips unservable local art and syncs the rest,
logging one aggregated warn with the count, an example path and the env var.
The invariant the 400 held is unchanged: no unservable path is persisted. The
operator's own single-entry writes keep the hard 400, because there the path
was typed by hand and silence would be the wrong answer.

Verified on Linux (.25: 493/493, clippy clean) and Windows (.133: 12/12 art
tests, clippy clean). The new Windows test is hermetic — it repoints
`%ProgramFiles(x86)%` at a synthetic Steam tree rather than asserting over
whatever Steam the box happens to have, since the vacuous version of that
test is what would have let this ship. Confirmed non-vacuous by disabling the
fix: it fails on "the DEFAULT art roots must include it".
2026-08-14 08:38:43 +02:00
enricobuehler b6cc76c472 Merge pull request 'The Android audio plane trusted AAudio, so a TV that opened a dead stream was silent all session' (#214) from worktree-android-aaudio-shield-silence into main
ci / bun-nix (push) Successful in 1m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 2m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m23s
ci / web (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
ci / rust-arm64 (push) Successful in 4m25s
ci / rust (push) Successful in 4m8s
android / android (push) Successful in 12m7s
2026-08-14 06:22:33 +00:00
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 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 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 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 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
37 changed files with 2505 additions and 421 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
+113
View File
@@ -14,6 +14,119 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
sync:
```
plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam
failed: art.hero: local art must be an image file (…) inside an allowed art root
```
Two independent defects, both fixed here.
**1. Steam's art was never inside an allowed root on Windows.** `art_roots()` defaulted to the users
base (`C:\Users`, from `%PUBLIC%`'s parent), which covers the launchers that install per-user —
Playnite under `%APPDATA%`, Heroic under `%APPDATA%` — but *not* Steam, which installs to
`C:\Program Files (x86)\Steam` and keeps both the art the plugin publishes there:
`appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides.
Every cover the plugin emitted was out of root. This is a v0.28.0 regression: the built-in scanner
the plugin replaced served its covers through the legacy `steam:` art-proxy branch, which never
passed through the H-2 confinement — deleting the scanner routed that art through a gate it had
never been measured against. `art_roots()` now also includes every Steam install root it can find,
from `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%` and from HKLM
`Valve\Steam\InstallPath` (so a Steam on another drive is covered too). POSIX needed no equivalent —
every Steam layout there, native and Flatpak, is already under `$HOME`.
This does not weaken the confinement. It exists to stop the host (SYSTEM) reading files the plugin
lane (LocalService) cannot reach itself; the Steam directory is readable by LocalService already, so
nothing there is reachable *because* the host is privileged. The extension, regular-file, magic-byte
and config-dir gates all still apply, so Steam's own `config.vdf` and `ssfn*` credential blobs are
not servable from it — there is a test.
**2. One unservable cover threw away the entire library.** `PUT /library/provider/{p}` validated art
per entry and returned 400 for the whole payload on the first bad value, so a path mismatch cost the
operator *every game from that store*, not a thumbnail — and the plugin, which only ever sees
`HostRequestError`, could not say which. A provider reconcile now **strips** unservable local art and
syncs the rest (`sanitize_art_paths`), logging one aggregated warn naming the count, an example path
and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The
operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and
silence would be the wrong answer.
**Operator-visible:** an art-root mismatch no longer fails a sync. If covers are blank where you
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
### 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
@@ -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)"),
}
@@ -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`.
@@ -597,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)
}
@@ -633,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
@@ -646,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
@@ -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();
+19 -2
View File
@@ -214,6 +214,15 @@ pub struct HostConfig {
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
/// otherwise). The pin is a preference, not a command: it still runs through
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
/// advertise — that closes the session rather than degrading, which is the failure this knob
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
/// negotiation has nothing to go on; `embedded` is the safe answer there.
pub portal_cursor_mode: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
@@ -262,10 +271,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
@@ -399,6 +410,12 @@ impl HostConfig {
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
// means "not set", not an unrecognised value to warn about. The spellings are parsed
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
+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":
///
+9
View File
@@ -824,6 +824,15 @@ pub mod admission;
#[path = "vdisplay/linux/portal_config.rs"]
mod portal_config;
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
/// hardcoded, because a mode the backend does not advertise closes the session outright.
///
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
/// integer work whose tests are the only place its behaviour is observable without a compositor,
/// so they should run on every platform's CI rather than only where the callers compile.
#[path = "vdisplay/linux/portal_cursor.rs"]
mod portal_cursor;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/hyprland.rs"]
mod hyprland;
@@ -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());
}
}
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
/// named headless output and spins up a portal thread owning the cast on it.
pub struct HyprlandDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had).
///
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
/// a black client.
///
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
/// remain the legs where the metadata channel is actually exercised.
hw_cursor: bool,
}
@@ -788,13 +797,7 @@ fn portal_thread(
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -818,6 +821,14 @@ fn portal_thread(
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)?;
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
let session = proxy
.create_session(Default::default())
.await
@@ -0,0 +1,376 @@
//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend
//! advertises, rather than asserted.
//!
//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from
//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call**
//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the
//! request against the backend's advertised bitfield before the backend ever sees it). Both
//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor
//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4`
//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed".
//! Field report 2026-08-14.
//!
//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on
//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**,
//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init
//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl
//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same
//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke
//! every cursor-forward session on the entire wlr family, on current software — not only on old
//! installs. (xdph 1.4.1 would itself fall back — its binary carries
//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance,
//! because the frontend fails the call first.)
//!
//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is
//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not
//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the
//! orchestrator"), so the two copies are deliberate; keep the ladders in step.
//!
//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer
//! work, and its tests are the whole point of the module — this is a decision that leaves no trace
//! anyone can check without a compositor in front of them — so they run on every platform's CI
//! rather than on the one leg that compiles `mod hyprland`.
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
/// No pointer in the cast at all.
Hidden = 1,
/// The compositor paints the pointer into the frames it hands us.
Embedded = 2,
/// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its
/// cheap hardware cursor plane, and the consumer either composites the shape itself or
/// forwards it to a client that draws its own.
Metadata = 4,
}
impl Mode {
/// The portal's bit for this mode.
pub(crate) const fn bit(self) -> u32 {
self as u32
}
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
pub(crate) const fn name(self) -> &'static str {
match self {
Mode::Hidden => "hidden",
Mode::Embedded => "embedded",
Mode::Metadata => "metadata",
}
}
/// What to ask for instead, best first, when this mode is not advertised.
const fn fallbacks(self) -> [Mode; 2] {
match self {
// The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a
// pointer on the client's screen (the compositor's, burnt in) — and because no
// `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a
// cursor-forward client draws nothing of its own, so this is one pointer, not two.
// `Hidden` is last: it streams a desktop nobody can point at.
Mode::Metadata => [Mode::Embedded, Mode::Hidden],
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path
// composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
Mode::Embedded => [Mode::Metadata, Mode::Hidden],
// A deliberate request for no pointer that the backend will not honour. Either
// remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on
// this path is set up to draw.
Mode::Hidden => [Mode::Embedded, Mode::Metadata],
}
}
}
/// The outcome of the ladder: what to request, and what the session actually wanted if those
/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Choice {
/// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing.
pub(crate) mode: Mode,
/// Set only when `mode` is a downgrade: the mode the session asked for and could not have.
pub(crate) wanted: Option<Mode>,
}
/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield.
///
/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail
/// comment, which is the one case with no right answer.
pub(crate) fn pick(advertised: u32, want: Mode) -> Choice {
if advertised & want.bit() != 0 {
return Choice {
mode: want,
wanted: None,
};
}
for alt in want.fallbacks() {
if advertised & alt.bit() != 0 {
return Choice {
mode: alt,
wanted: Some(want),
};
}
}
// The backend advertised no mode this build knows — 0, or only bits from a spec revision newer
// than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is
// both the most universally implemented and the only one that cannot end up drawing two
// pointers. The caller warns: whatever this backend is doing, we are guessing.
Choice {
mode: Mode::Hidden,
wanted: Some(want),
}
}
/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Pin {
/// Unset or `auto` — the session's own negotiation decides.
Auto,
/// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin
/// can never re-create the session-killing request this module exists to prevent.
Mode(Mode),
/// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud —
/// a typo'd escape hatch that silently does nothing is worse than no escape hatch.
Unrecognised,
}
/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value.
pub(crate) fn parse_pin(raw: &str) -> Pin {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Pin::Auto,
"hidden" | "none" => Pin::Mode(Mode::Hidden),
"embedded" | "composited" => Pin::Mode(Mode::Embedded),
"metadata" | "meta" => Pin::Mode(Mode::Metadata),
_ => Pin::Unrecognised,
}
}
/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel
/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn
/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both.
///
/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
let negotiated = if hw_cursor {
Mode::Metadata
} else {
Mode::Embedded
};
let raw = match pf_host_config::config().portal_cursor_mode.as_deref() {
Some(raw) => raw,
None => return negotiated,
};
match parse_pin(raw) {
Pin::Auto => negotiated,
Pin::Mode(pinned) => {
tracing::info!(
backend,
pinned = pinned.name(),
negotiated = negotiated.name(),
"ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE"
);
pinned
}
Pin::Unrecognised => {
tracing::warn!(
backend,
value = raw,
negotiated = negotiated.name(),
"ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\
metadata) ignoring"
);
negotiated
}
}
}
#[cfg(target_os = "linux")]
impl Mode {
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match self {
Mode::Hidden => CursorMode::Hidden,
Mode::Embedded => CursorMode::Embedded,
Mode::Metadata => CursorMode::Metadata,
}
}
}
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
/// the mode that predates the property and that every implementation has always had.
#[cfg(target_os = "linux")]
pub(crate) async fn negotiate(
proxy: &ashpd::desktop::screencast::Screencast,
hw_cursor: bool,
backend: &str,
) -> ashpd::desktop::screencast::CursorMode {
let want = want(hw_cursor, backend);
let advertised = match proxy.available_cursor_modes().await {
Ok(avail) => avail.bits(),
Err(e) => {
// `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to
// publish it is also too old to have metadata, and `Embedded` is what this backend
// requested for its whole life before the cursor channel existed.
tracing::warn!(
backend,
error = %e,
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
);
return Mode::Embedded.to_ashpd();
}
};
let choice = pick(advertised, want);
match choice.wanted {
None => tracing::info!(
backend,
advertised = format_args!("{advertised:#05b}"),
mode = choice.mode.name(),
"ScreenCast: cursor mode negotiated"
),
// The downgrade path — and the one that used to be a dead session. Loud, because a stream
// whose pointer quietly changed hands is exactly what nobody thinks to check.
Some(wanted) => tracing::warn!(
backend,
advertised = format_args!("{advertised:#05b}"),
wanted = wanted.name(),
mode = choice.mode.name(),
"ScreenCast: requested cursor mode is not advertised by this portal — downgrading \
(requesting it anyway would close the session)"
),
}
choice.mode.to_ashpd()
}
#[cfg(test)]
mod tests {
use super::*;
/// The portal's wire values. These are ABI — a backend rejecting our request prints the
/// number, and `4` is the one in the field report that started this module.
#[test]
fn mode_bits_are_the_portal_wire_values() {
assert_eq!(Mode::Hidden.bit(), 1);
assert_eq!(Mode::Embedded.bit(), 2);
assert_eq!(Mode::Metadata.bit(), 4);
}
/// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from
/// declaration order — so a reordering upstream would silently repoint every mode. Pin it
/// where ashpd is actually compiled.
#[cfg(target_os = "linux")]
#[test]
fn mode_bits_match_ashpd() {
use ashpd::desktop::screencast::CursorMode;
use ashpd::enumflags2::BitFlags;
for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] {
assert_eq!(
BitFlags::from_flag(m.to_ashpd()).bits(),
m.bit(),
"{} drifted from ashpd",
m.name()
);
}
assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4);
}
/// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on
/// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward
/// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client
/// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4".
#[test]
fn metadata_wanted_but_unadvertised_downgrades_to_embedded() {
// Exactly the bitfield the portal reported on glass.
assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3);
let c = pick(3, Mode::Metadata);
assert_eq!(c.mode, Mode::Embedded);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// The same portal, a session with no cursor channel: already asking for what exists, so the
/// fix must not perturb it.
#[test]
fn embedded_wanted_and_advertised_is_untouched() {
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded);
assert_eq!(c.mode, Mode::Embedded);
assert_eq!(c.wanted, None);
}
/// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point
/// is to stop asserting, not to stop using it.
#[test]
fn metadata_is_used_where_advertised() {
let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit();
let c = pick(all, Mode::Metadata);
assert_eq!(c.mode, Mode::Metadata);
assert_eq!(c.wanted, None);
}
/// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer
/// survives. (Mirrors `pf-capture`'s ladder.)
#[test]
fn embedded_unadvertised_falls_to_metadata_not_hidden() {
let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded);
assert_eq!(c.mode, Mode::Metadata);
assert_eq!(c.wanted, Some(Mode::Embedded));
}
/// A backend offering only `Hidden`: a cursorless stream beats a closed session.
#[test]
fn hidden_only_backend_yields_hidden() {
let c = pick(Mode::Hidden.bit(), Mode::Metadata);
assert_eq!(c.mode, Mode::Hidden);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged
/// as a downgrade so the warn fires.
#[test]
fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() {
for advertised in [0, 0b1000_0000] {
let c = pick(advertised, Mode::Metadata);
assert_eq!(c.mode, Mode::Hidden);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
}
/// Whatever the ladder returns must be a mode the backend named — the invariant the old
/// hardcode broke. Exhaustive over every advertisement × every want.
#[test]
fn never_requests_an_unadvertised_mode() {
let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata];
for advertised in 1u32..=0b111 {
for want in modes {
let c = pick(advertised, want);
assert!(
advertised & c.mode.bit() != 0,
"picked {} from advertised {advertised:#05b} (want {})",
c.mode.name(),
want.name()
);
// A downgrade is reported exactly when one happened.
assert_eq!(c.wanted.is_some(), c.mode != want);
}
}
}
#[test]
fn pin_parses_the_spellings_we_document() {
assert_eq!(parse_pin(""), Pin::Auto);
assert_eq!(parse_pin("auto"), Pin::Auto);
assert_eq!(parse_pin(" AUTO "), Pin::Auto);
assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded));
assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded));
assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata));
assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden));
assert_eq!(parse_pin("2"), Pin::Unrecognised);
assert_eq!(parse_pin("yes"), Pin::Unrecognised);
}
/// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must
/// still come out embedded rather than re-closing the session.
#[test]
fn a_pin_still_runs_the_ladder() {
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata);
assert_eq!(c.mode, Mode::Embedded);
}
}
@@ -55,12 +55,17 @@ fn chooser_cmd() -> String {
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
/// adds one headless output and spins up a portal thread owning the cast on it.
pub struct WlrootsDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had).
///
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually
/// advertises, because requesting an unadvertised mode closes the session outright. xdpw
/// refuses metadata by construction (see the portal thread), so on this backend the channel can
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
/// cancel the cast and hand the client a black screen.
hw_cursor: bool,
}
@@ -512,13 +517,7 @@ fn portal_thread(
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -542,6 +541,14 @@ fn portal_thread(
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
)?;
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
// screencast.c refuses the mode outright —
// if (sess->screencast_data.cursor_mode & METADATA) {
// logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling");
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
let session = proxy
.create_session(Default::default())
.await
+291 -22
View File
@@ -150,12 +150,13 @@ fn percent_decode(s: &str) -> String {
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
/// operator whose library is somewhere else again.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
@@ -174,6 +175,8 @@ fn art_roots() -> Vec<PathBuf> {
roots.push(PathBuf::from(drive).join("Users"));
}
}
#[cfg(windows)]
roots.extend(steam_art_roots());
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
@@ -200,6 +203,54 @@ fn art_roots() -> Vec<PathBuf> {
roots
}
/// Windows: every Steam install root that exists on this box.
///
/// Steam is the one launcher whose art is NOT under the users base: it installs to
/// `C:\Program Files (x86)\Steam`, and both places the `steam` library plugin publishes covers from
/// — `appcache\librarycache\<appid>\…` and each account's `userdata\<id>\config\grid\` overrides —
/// live under that root. Without this the users base rejected every one of them, and because an
/// unservable path used to fail the WHOLE reconcile payload the plugin synced NO GAMES AT ALL, not
/// merely no art. That is a v0.28.0 regression: the built-in scanner this plugin replaced served its
/// covers through the legacy `steam:` art-proxy branch, which never passed through this confinement.
/// (POSIX needs no equivalent — every Steam layout there, native and Flatpak, is already under
/// `$HOME`.)
///
/// This does not widen what the host can be *tricked* into reading. The confinement exists to close
/// one asymmetry: the host reads as SYSTEM, while the plugin lane that supplies the path is the far
/// weaker LocalService (2026-08-05 review H-2). The Steam directory is readable by LocalService
/// already, so nothing reachable through it is reachable *because* the host is privileged. The
/// extension, regular-file, magic-byte and config-dir gates all still apply on top, so Steam's own
/// `config.vdf` and `ssfn*` credential blobs are not servable from it either.
#[cfg(windows)]
fn steam_art_roots() -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
// `is_dir` before dedup: `%ProgramFiles%` and `%ProgramW6432%` are the same directory on a
// 64-bit host, and the registry commonly repeats whichever of the two Steam sits in.
if p.is_dir() && !out.contains(&p) {
out.push(p);
}
};
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
push(PathBuf::from(pf).join("Steam"));
}
}
// A Steam installed off the default path — a second drive is common — is only discoverable from
// the registry. HKLM and not HKCU, for the same reason the plugin reads HKLM: the host is
// SYSTEM, whose own hive knows nothing about where the operator installed anything.
for key in [r"SOFTWARE\WOW6432Node\Valve\Steam", r"SOFTWARE\Valve\Steam"] {
if let Some(p) = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey(key)
.ok()
.and_then(|k| k.get_value::<String, _>("InstallPath").ok())
{
push(PathBuf::from(p));
}
}
out
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
@@ -317,6 +368,43 @@ pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
Ok(())
}
/// Strip every **local-file** art value the proxy would refuse to serve, returning the
/// `(field, value)` pairs dropped. URLs and already-proxied paths are left alone.
///
/// The provider-reconcile counterpart to [`validate_art_paths`]. Both enforce the same invariant —
/// an unservable path never reaches `library.json` — and differ only on what the REST of the payload
/// is worth. An operator writing one custom entry typed that path by hand, so a hard 400 is the
/// feedback they need. A plugin reconciling its whole entry set did not: it publishes hundreds of
/// covers it resolved from disk, and refusing the payload over one of them costs the operator their
/// entire library for that store.
///
/// That is not hypothetical. A default Windows Steam install put every cover outside the art roots,
/// so `PUT /library/provider/steam` 400'd, the plugin could only report `HostRequestError`, and the
/// grid stayed empty with no indication that the games themselves were fine. [`steam_art_roots`]
/// fixes that specific mismatch; this makes the NEXT one cost a cover instead of a library.
///
/// Dropping rather than rewriting is deliberate: `None` is exactly what an entry with no art
/// carries, and every client already renders that.
pub fn sanitize_art_paths(art: &mut Artwork) -> Vec<(&'static str, String)> {
let mut dropped = Vec::new();
for (field, value) in [
("portrait", &mut art.portrait),
("hero", &mut art.hero),
("logo", &mut art.logo),
("header", &mut art.header),
] {
let unservable = value
.as_deref()
.is_some_and(|v| is_local_art_path(v) && !art_path_is_servable(v));
if unservable {
if let Some(v) = value.take() {
dropped.push((field, v));
}
}
}
dropped
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
@@ -542,15 +630,67 @@ mod tests {
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
/// The variables the art roots derive from are process-global while cargo runs tests as threads,
/// so the tests that repoint them must not overlap — one clearing a variable mid-flight makes
/// another's temp root stop being a root, which fails as a confinement bug that isn't there.
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
/// failure, not cascade into an unrelated `PoisonError`.
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
/// Holds `ART_ROOTS_LOCK` and the overrides one test needs, restoring the previous values on
/// drop. **The only place these tests touch the process environment** — which is what keeps the
/// unsafe-hygiene gate's count flat as tests are added, and what makes the restore run on an
/// unwind (the hand-rolled set/restore this replaced leaked its override to every later test
/// whenever an assertion fired between the two halves).
struct ArtRootsEnv {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl ArtRootsEnv {
/// `None` unsets the variable for the test's duration.
fn set(vars: &[(&'static str, Option<&Path>)]) -> Self {
let _lock = ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut saved = Vec::new();
for (key, value) in vars {
saved.push((*key, std::env::var_os(key)));
// SAFETY: `_lock` is held for this guard's whole lifetime, and this type is the
// only writer of these variables in the binary — so no other thread is reading
// them while they change.
unsafe { write_env(key, value.map(|p| p.as_os_str())) };
}
Self { _lock, saved }
}
}
impl Drop for ArtRootsEnv {
fn drop(&mut self) {
for (key, value) in &self.saved {
// SAFETY: still under `_lock`, which outlives this loop — same argument as `set`.
unsafe { write_env(key, value.as_deref()) };
}
}
}
/// The single write point, so the hygiene gate has exactly one pair of call sites to judge.
///
/// # Safety
/// The caller must hold `ART_ROOTS_LOCK`; the process environment is global and unsound to
/// mutate while another thread reads it.
unsafe fn write_env(key: &str, value: Option<&std::ffi::OsStr>) {
match value {
// SAFETY: the caller holds `ART_ROOTS_LOCK` (this function's documented contract), and
// `ArtRootsEnv` is the only writer in the binary — so no other thread is reading the
// environment while it changes.
Some(v) => unsafe { std::env::set_var(key, v) },
// SAFETY: as above — the caller's lock is what makes this sound.
None => unsafe { std::env::remove_var(key) },
}
}
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` pointed at one directory — what most of these tests want.
fn confine_art_to(dir: &Path) -> ArtRootsEnv {
ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", Some(dir))])
}
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
@@ -558,15 +698,12 @@ mod tests {
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
@@ -642,8 +779,6 @@ mod tests {
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
@@ -706,14 +841,11 @@ mod tests {
/// readable together is the point: either alone passes with the bug present.
#[test]
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
@@ -765,12 +897,149 @@ mod tests {
"an out-of-root file:// cover is still refused"
);
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// A reconcile keeps its entries when a cover is unservable — it drops the cover.
///
/// Regression for the report that opened this: on a default Windows Steam install every
/// `appcache\librarycache` path fell outside the users base, `validate_art_paths` refused the
/// whole `PUT /library/provider/steam` payload, and the operator's grid stayed EMPTY. The games
/// were never the problem. Asserting the survivors matters as much as the drop: a sanitizer that
/// cleared the whole struct would also "pass" a drop-only test.
#[test]
fn sanitize_drops_only_the_unservable_local_art() {
let dir = std::env::temp_dir().join(format!("pf-art-san-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let cover_url = file_url(&cover);
let outside = if cfg!(windows) {
r"C:\Program Files (x86)\Steam\appcache\librarycache\570\a\library_hero.jpg".to_string()
} else {
"/opt/steam/appcache/librarycache/570/a/library_hero.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(cover_url.clone()),
hero: Some(outside.clone()),
logo: Some("https://cdn/l.png".into()),
header: Some("/api/v1/library/art/steam:570/header".into()),
};
let dropped = sanitize_art_paths(&mut art);
assert_eq!(
dropped,
vec![("hero", outside)],
"only the out-of-root local path is dropped, and it is reported"
);
assert!(art.hero.is_none(), "the unservable value is gone, not kept");
// A servable local cover, a remote URL and an already-proxied path all survive untouched —
// the entry still renders everything it legitimately can.
assert_eq!(art.portrait.as_deref(), Some(cover_url.as_str()));
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
// Idempotent: what survived one pass survives the next, and nothing new is reported.
assert!(sanitize_art_paths(&mut art).is_empty());
// The invariant the hard 400 used to hold is still held — nothing the write gate would
// refuse comes out the other side.
assert!(validate_art_paths(&art).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
/// Windows only, and the actual bug report: a Steam cover under Program Files is servable with
/// NO `PUNKTFUNK_LIBRARY_ART_ROOTS` set.
///
/// Drives the whole chain the `steam` plugin's payload traverses — Program Files probe →
/// [`steam_art_roots`] → [`art_roots`] → confinement → [`art_path_is_servable`] →
/// [`local_art_bytes`] — against a synthetic Steam tree, by repointing `%ProgramFiles(x86)%` at
/// a temp dir. Hermetic on purpose: asserting over whatever Steam this box happens to have would
/// pass vacuously on every CI runner, which is exactly the shape of test that let this ship.
#[cfg(windows)]
#[test]
fn steam_librarycache_cover_is_servable_without_configuration() {
let base = std::env::temp_dir().join(format!("pf-art-steam-{}", std::process::id()));
// `appcache\librarycache\<appid>\<hash>\library_hero.jpg` — the exact shape the plugin
// publishes, and the exact field the reported failure named.
let hero = base
.join("Steam")
.join("appcache")
.join("librarycache")
.join("570")
.join("abcdef")
.join("library_hero.jpg");
std::fs::create_dir_all(hero.parent().unwrap()).unwrap();
std::fs::write(&hero, PNG).unwrap();
// No configured roots (that is the claim under test), and the Program Files probe pointed
// at the synthetic tree. Both restored on drop — `%ProgramFiles(x86)%` is a real variable
// on this box that later tests in the same process may legitimately read.
let _env = ArtRootsEnv::set(&[
("PUNKTFUNK_LIBRARY_ART_ROOTS", None),
("ProgramFiles(x86)", Some(&base)),
]);
let steam_root = base.join("Steam");
assert!(
steam_art_roots().contains(&steam_root),
"the Program Files probe must find the Steam install"
);
assert!(
art_roots().contains(&steam_root),
"the DEFAULT art roots must include it — the whole point is that no env var is needed"
);
// The plugin sends `file://`, so that is what has to be accepted; before the fix this was
// false and `validate_art_paths` 400'd the entire reconcile.
let url = file_url(&hero);
assert!(art_path_is_servable(&url), "{url} must be servable");
assert!(
validate_art_paths(&Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_ok(),
"a Steam-shaped payload must reconcile"
);
assert!(
sanitize_art_paths(&mut Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_empty(),
"and nothing about it is dropped"
);
assert_eq!(
local_art_bytes(&url).expect("read time serves it too").0,
PNG
);
// The confinement did not go slack on the way: a secret next door is still not servable,
// and neither is a non-image that merely wears the extension.
let secret = base.join("Steam").join("config").join("config.vdf");
std::fs::create_dir_all(secret.parent().unwrap()).unwrap();
std::fs::write(&secret, b"\"Accounts\"\n{\n\"user\" \"token\"\n}\n").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"Steam's own credential blob must not be servable from an art root"
);
let disguised = base.join("Steam").join("config.png");
std::fs::write(&disguised, b"\"Accounts\" { \"user\" \"token\" }").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension is still not enough — the bytes must BE an image"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
+56 -7
View File
@@ -9,6 +9,10 @@ use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// The **single-entry writes** — the operator creating or editing one custom entry. The provider
/// reconcile takes [`check_privileged_fields`] and sanitizes art instead; the split is the whole
/// point, and [`crate::library::sanitize_art_paths`] carries the reasoning.
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
@@ -22,14 +26,32 @@ use axum::Extension;
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
/// auth problem.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
check_privileged_fields(lane, launch, prep, icon).or_else(|| {
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
})
}
/// The half of [`check_entry_fields`] that is about *authority* rather than about art: an
/// operator-privileged field this lane may not set (403), or an unrepresentable icon token (400).
///
/// Split out for the provider reconcile, which must apply exactly these two and NOT the art check —
/// it sanitizes unservable covers instead of refusing the payload
/// ([`crate::library::sanitize_art_paths`] explains why the two callers want different answers).
fn check_privileged_fields(
lane: AuthLane,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
@@ -55,9 +77,7 @@ fn check_entry_fields(
if let Err(e) = crate::library::validate_icon(icon) {
return Some((e.clone(), api_error(StatusCode::BAD_REQUEST, &e)));
}
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
None
}
#[derive(Deserialize)]
@@ -468,7 +488,7 @@ pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
ApiJson(mut inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
@@ -484,9 +504,15 @@ pub(crate) async fn reconcile_provider_entries(
}
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
//
// Art is deliberately NOT part of this refusal. A privileged field is the plugin overreaching
// and must fail the write; an unservable cover is a path mismatch between where a launcher keeps
// its art and where the host is allowed to read, and failing the payload over one of those threw
// away a working library to save a thumbnail. Those covers are stripped below instead, which
// holds the same "no unservable path is ever persisted" invariant.
for (i, e) in inputs.iter().enumerate() {
if let Some((reason, denied)) =
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
check_privileged_fields(lane, e.launch.as_ref(), &e.prep, e.icon.as_deref())
{
tracing::warn!(
provider,
@@ -498,6 +524,29 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
let mut first_dropped: Option<(String, &'static str, String)> = None;
for e in inputs.iter_mut() {
for (field, value) in crate::library::sanitize_art_paths(&mut e.art) {
dropped_art += 1;
first_dropped.get_or_insert_with(|| (e.title.clone(), field, value));
}
}
if let Some((title, field, path)) = first_dropped {
tracing::warn!(
provider,
dropped = dropped_art,
example_title = %title,
example_field = field,
example_path = %path,
"library reconcile: dropped local art the proxy may not serve — these entries still \
sync, but their covers will be blank. The path must be an image file (jpg/png/webp/\
gif/bmp/ico/tga) inside an allowed art root; set PUNKTFUNK_LIBRARY_ART_ROOTS if this \
library's art lives outside the defaults"
);
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
+4 -2
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. |
@@ -114,6 +115,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
| `PUNKTFUNK_PORTAL_CURSOR_MODE` | `auto` *(default)* · `embedded` · `metadata` · `hidden` | **Hyprland / wlroots only, and a troubleshooting knob** — which ScreenCast cursor mode the host asks the portal for. Unset, the host asks for `metadata` when the client draws the pointer itself and `embedded` otherwise, then settles that against the modes your portal advertises; it never requests one your portal lacks. Set `embedded` if the pointer misbehaves on a portal that *claims* metadata support but implements it poorly — that is the one case the automatic negotiation cannot detect. A pin is still only a preference: it is checked against the advertised modes like any other. |
## Session recovery (Linux)
@@ -217,7 +219,7 @@ it — leave it or delete it, it makes no difference.
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, `;`-separated | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots (your home directory on Linux/macOS); set it when box art lives elsewhere — a second drive, a network mount. The host log's "not under an allowed art root" line is this knob's cue. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
## Updates
+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
+25
View File
@@ -84,6 +84,31 @@ and fails the session with a clear error rather than streaming a blank surface.
capture the Hyprland log (`hyprctl` instance dir → `hyprland.log`) and check your GPU's GBM support;
running Hyprland as a real session (not nested) is the supported configuration.
## Troubleshooting: black client + "unavailable cursor mode 4"
A black client, `pipeline build failed` in the host log, and **`unavailable cursor mode 4`** from
xdph are one failure, not three.
`4` is the ScreenCast portal's *metadata* cursor mode, which the host prefers when the client draws
the pointer locally (desktop mouse mode). xdg-desktop-portal-hyprland **does not offer that mode**
on a current stack (Hyprland 0.56.2, xdph 1.4.1) its `AvailableCursorModes` is `3`, meaning hidden
and embedded only. Asking for a mode the backend does not advertise is not a soft failure:
`xdg-desktop-portal` rejects the call outright, so the cast died during setup and the client had
nothing to show.
Updating xdph does **not** fix this — the mode is absent on current versions, not just old ones.
Hosts from this release check what your portal advertises and use an embedded cursor instead, so the
session streams. If you are on an older host, switch the client to **game mouse mode**: that stops
it asking for the metadata cursor at all.
If the pointer misbehaves on an xdph that *does* advertise metadata support, pin the mode:
```sh
PUNKTFUNK_PORTAL_CURSOR_MODE=embedded
```
See [Configuration](/docs/configuration#compositor-specific-linux).
## Permission system
Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny
+14
View File
@@ -82,6 +82,20 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see
Then `systemctl --user restart xdg-desktop-portal`. On a box with only xdpw installed there is
nothing to choose between, so you can skip this.
## Troubleshooting: black client + "unsupported cursor mode requested"
A black client with `pipeline build failed` in the host log and **`dbus: unsupported cursor mode
requested, cancelling`** from xdpw is one failure, not two.
xdpw refuses the ScreenCast *metadata* cursor mode and cancels the cast, and the portal spec makes
that fatal rather than a fallback. Hosts before this release asked for it whenever the client drew
the pointer itself (desktop mouse mode), so those sessions never produced a frame. Hosts from this
release check what xdpw advertises first and use an embedded cursor instead, so the session streams.
On an older host, switch the client to **game mouse mode** — it stops asking for the metadata cursor
and the stream comes up. The same failure on Hyprland reads `unavailable cursor mode 4`; see
[Hyprland](/docs/hyprland).
## Start the host
With the backend selected, start the host from **inside your Sway session**:
+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
+1 -1
View File
@@ -182,7 +182,7 @@ crates/pf-vkdecode/tests/gpu_parity.rs:5
crates/pf-win-display/src/win_display.rs:2
crates/punktfunk-core/src/quic/endpoint.rs:2
crates/punktfunk-host/src/identity.rs:3
crates/punktfunk-host/src/library/art.rs:4
crates/punktfunk-host/src/library/art.rs:2
crates/punktfunk-host/src/mgmt/tests.rs:3
crates/punktfunk-host/src/native.rs:4
crates/punktfunk-host/src/windows/service.rs:1