Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9ccfa8b6 | ||
|
|
8d60f1cec0 | ||
|
|
6dd4add11b | ||
|
|
b6cc76c472 | ||
|
|
e2239fd964 | ||
|
|
f17fe1fc2b | ||
|
|
38631d43df | ||
|
|
4caf2b76e8 | ||
|
|
0f64551c56 | ||
|
|
8fe5acf7f2 | ||
|
|
fb33555059 | ||
|
|
81022bcc80 | ||
|
|
082c65755f | ||
|
|
3ac4548cf8 | ||
|
|
a190fdb1ae | ||
|
|
94f049ba70 | ||
|
|
c29e720324 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 host→client audio: Opus decode → jitter ring → AAudio (LowLatency), all in Rust.
|
||||
* Start host→client 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)
|
||||
|
||||
+692
-236
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -262,10 +262,12 @@ pub struct HostConfig {
|
||||
/// spawn flags included.
|
||||
pub gamescope_hdr: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
|
||||
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
|
||||
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`).
|
||||
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
|
||||
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
|
||||
/// `None` = leave gamescope's own default.
|
||||
/// `None` = 203 nits, BT.2408 reference white, which is what our clients decode against —
|
||||
/// NOT gamescope's own default of 400, which sits nearly a stop above it. See `pf-vdisplay`'s
|
||||
/// `SDR_REFERENCE_WHITE_NITS` for why the host pins this rather than letting it float.
|
||||
pub gamescope_sdr_nits: Option<u32>,
|
||||
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
|
||||
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
|
||||
|
||||
@@ -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":
|
||||
///
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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. |
|
||||
@@ -217,7 +218,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+159
@@ -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);
|
||||
@@ -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))
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user