From c29e720324f6c737a697d0af9cbbdee55cc11370 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:30 +0200 Subject: [PATCH 1/5] fix(gamescope): HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable Field report 2026-08-13, Bazzite host in gaming mode to an iPad: Steam's Big Picture UI looked glaring and over-saturated while HDR game content looked washed out, both on the same stream. Those are one error. gamescope maps everything that is not an HDR game -- the desktop, the Steam overlay, an SDR title -- into the session's PQ container at --hdr-sdr-content-nits, and we only passed that flag when an operator had set PUNKTFUNK_GAMESCOPE_SDR_NITS. Unset, gamescope used its own default of 400, while every first-party client anchors diffuse white at 203 (BT.2408 reference white; the Apple presenter hands exactly that to CAEDRMetadata.hdr10's opticalOutputScale). The two ends were nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked from a reference point the host had never used, flattening the content around it. The flag is now always passed, defaulting to 203. The knob still overrides it for anyone who wants a brighter or dimmer desktop. Separately, and visible in the same log: the two HDR decisions in a gamescope session are made independently. hdr_args() never consults wsi_layer_matches_our_gamescope(), so when the WSI-layer version check fires -- which it does on essentially every Bazzite box, since we pin our own gamescope rev and the check compares version triples -- the session launches advertising HDR while having made an HDR10 swapchain unreachable for every game in it. That layer is the only route to one, so a title told to render HDR renders it into an SDR swapchain and looks washed out, with nothing anywhere saying why. It now warns. The behaviour of the check itself is deliberately left alone: re-enabling a genuinely mismatched layer black-screens every Vulkan client, which is worse than losing HDR, and that trade needs a real box to retest. Verified: scripts/xcheck.sh linux clippy clean on pf-vdisplay (-D warnings), rustfmt clean. Not yet verified on glass. --- crates/pf-host-config/src/lib.rs | 6 +- .../src/vdisplay/linux/gamescope.rs | 55 +++++++++++++++---- docs-site/content/docs/configuration.md | 2 +- docs-site/content/docs/hdr.md | 7 ++- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 9217e889..d50c89d4 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -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, /// `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 diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 2651a2ae..4638abec 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -4244,8 +4244,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 { @@ -4315,6 +4320,19 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul on a black screen, with no other symptom." ); } + // The two HDR decisions are made independently — `hdr_args` never consults the layer check — 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 !wsi_ok && 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. Fix by installing a \ + VkLayer_FROG_gamescope_wsi built for the gamescope we run." + ); + } let start_unit = |bind: Option<&SessionBind>| -> Result<()> { let mut cmd = Command::new("systemd-run"); cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]); @@ -4559,23 +4577,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 { 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. diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 94fbbd5a..d448e8e2 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -93,7 +93,7 @@ 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_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. | diff --git a/docs-site/content/docs/hdr.md b/docs-site/content/docs/hdr.md index 5c0265f8..9b188d24 100644 --- a/docs-site/content/docs/hdr.md +++ b/docs-site/content/docs/hdr.md @@ -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 -- 2.54.0 From 94f049ba70db3abf8ca269d6f9b83de1d707d73f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:36 +0200 Subject: [PATCH 2/5] fix(apple): the SDR layer shipped untagged, because an SDR session never configured colour configure(hdr:) guards on hdr != hdrActive, and hdrActive starts false. A session that is SDR from its first frame therefore matched the initial state and fell straight through the guard, so configureColor never ran even once and the layer kept make()'s bare configuration -- which never assigns a colour space. An untagged CAMetalLayer gets no colour matching: the BT.709-encoded stream is drawn in the display's native space. That is mild oversaturation on a P3 Mac or iPad, and on a tvOS display composited for HDR it also lifts the black floor. It matches a field report of greys where blacks should be, which arrived with the client's own HDR switch already OFF -- so nothing else in the pipeline had tagged those pixels either. It also meant PUNKTFUNK_SDR_COLORSPACE was dead code on exactly the sessions it existed to fix: an operator A/B-ing it in the field would have seen no change at all, because the assignment it feeds was never executed. So: configureColor now runs once regardless (same-state calls after the first are still no-ops), and tagging is the default rather than opt-in, since drawing a BT.709 stream in the panel's native space is not a rendering anyone asked for. PUNKTFUNK_SDR_COLORSPACE=none restores the untagged look as the A/B lever. The tvOS HDR tone-map branch gets the same tag -- pf_frag_hdr_tv outputs BT.709, so it is an SDR layer by the time it is presented. Verified: full tvOS compile of every PunktfunkKit source clean against the AppleTVOS SDK. (The build's link step fails on three ABI symbols missing from the checked-in PunktfunkCore.xcframework, which predates them -- pre-existing, main fails identically, unrelated to these files.) Not yet verified on glass. --- .../Video/MetalVideoPresenter.swift | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 9f8a70ec..636c38b0 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -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`. @@ -592,13 +610,16 @@ public final class MetalVideoPresenter { stagingLock.lock() let passthrough = stagedDisplayHeadroom > 1.0 stagingLock.unlock() - guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return } + guard !didConfigureColor || hdr != hdrActive + || (hdr && passthrough != hdrPassthroughActive) + else { return } hdrActive = hdr hdrPassthroughActive = passthrough #else - guard hdr != hdrActive else { return } + guard !didConfigureColor || hdr != hdrActive else { return } hdrActive = hdr #endif + didConfigureColor = true configureColor(hdr: hdr) } @@ -628,9 +649,10 @@ public final class MetalVideoPresenter { layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ) } else { // SDR-composited display: PQ would render untone-mapped (blown out) — the - // pf_frag_hdr_tv shader tone-maps to SDR instead. + // pf_frag_hdr_tv shader tone-maps to SDR instead. Its output is BT.709, so it + // carries the same SDR tag as a genuinely SDR session. layer.pixelFormat = .bgra8Unorm - layer.colorspace = nil + layer.colorspace = sdrColorspace } #else layer.pixelFormat = .rgba16Float @@ -641,12 +663,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 -- 2.54.0 From a190fdb1aef877c811b3026e7a8a0e3603b809d9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:38 +0200 Subject: [PATCH 3/5] fix(apple/tvos): an SDR stream forced the TV into HDR10 output applyDisplayCriteriaIfNeeded builds a synthetic format description hardcoding BT.2020 primaries, ST.2084 PQ and the BT.2020 matrix, then hands it to AVDisplayManager to pick a display mode. Its guard checked only that no criteria had been set yet and that the user's HDR setting was on -- never that the stream itself was HDR. Since that setting defaults to true, an ordinary SDR session drove an HDR-capable TV into PQ output. That is a standard way to raise the black floor: the Apple TV switches HDMI to limited-range levels in its HDR modes, and a set configured for full range then renders code 16 as grey rather than black. Now gated on connection.isHDR as well. Layout re-runs this, so a session that flips to HDR mid-stream still picks the mode up on the next pass. This was NOT the cause of the 2026-08-13 grey-blacks report -- that one had the client's HDR setting off, so this path never ran (see the SDR layer tagging in the previous commit for the mechanism that did apply). It is a real bug on its own, found while investigating it. Verified: full tvOS compile clean against the AppleTVOS SDK. Not yet verified on glass. --- .../Sources/PunktfunkKit/Views/StreamViewIOS.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index f0bca1e2..7a27248d 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -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 } -- 2.54.0 From 3ac4548cf8a13398bf320cdeea2f88185ac0f996 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:18:02 +0200 Subject: [PATCH 4/5] fix(gamescope): ship the WSI layer built beside our compositor, instead of guessing at the distro's A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing else -- gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate through. That layer talks `gamescope_swapchain` to the compositor, and when the two disagree the compositor rejects the client's swapchain_feedback and every Vulkan client dies on a black screen with sound and input and no error anywhere. We ship our own compositor and did NOT ship a layer, on the recorded grounds that the layer is "version-independent of the compositor binary". It is not, and wsi_layer_matches_our_gamescope() exists because it is not. So the host was left guessing from version triples, and that guess is wrong in both directions: a distro at the same upstream tag that patched the protocol compares EQUAL and keeps a layer that will kill every game, while a distro at a different tag with a byte-identical protocol compares unequal and loses HDR for nothing. Since we pin a rev, the second case is the normal one -- on essentially every box with a distro gamescope, the layer was disabled and no game could render HDR. Ship the layer instead. It is built from the same tree at the same rev as the compositor, so the two cannot drift, and the guess stops being load-bearing. It is installed under our own name (VK_LAYER_PUNKTFUNK_gamescope_wsi) at our own path with our own enable/disable variables, so it coexists with the distro's rather than colliding -- the Vulkan loader keys implicit layers on that name -- and the host switches the two independently in one session. WsiPlan makes the three states explicit and resolves them once per launch, since the fallback spawns `--version` probes: Ours our layer is installed: enable it, force the distro's off DistroKept no layer of ours, distro's looks compatible: touch nothing DistroDisabled no layer of ours, distro's untrusted: today's behaviour That last arm is the fail-safe. A host newer than its gamescope package behaves exactly as it does today rather than enabling a layer that is not there, so this can roll out one packaging surface at a time without a flag day. Only the Arch PKGBUILD carries the new files so far. The rpm path takes a CI-cached binary rather than the build script's stage dir, so it needs the cache, build-gamescope-rpm.sh and the spec moved together; the deb, both sysexts and gamescope.nix need the same two files added. Until each lands, those boxes take the DistroDisabled arm and are no worse off than before. Verified: 214 pf-vdisplay tests pass in a linux container (including a new one pinning that the Ours arm enables ours AND forces the distro's off together -- either half alone is a bug), clippy -D warnings and rustfmt clean, both shell files pass bash -n, and the manifest rewrite was run against a synthetic FROG manifest to confirm it renames/repoints/regates while preserving the `functions` block. NOT verified: an actual gamescope build, any package build, or a game taking an HDR swapchain on glass. --- .../src/vdisplay/linux/gamescope.rs | 184 +++++++++++++----- packaging/gamescope/PKGBUILD | 16 +- .../gamescope/build-punktfunk-gamescope.sh | 62 +++++- 3 files changed, 205 insertions(+), 57 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 4638abec..1b7f82cb 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -1237,7 +1237,7 @@ fn write_session_plus_dropin( wrapper: &std::path::Path, mode: Mode, hdr: bool, - wsi_ok: bool, + wsi: WsiPlan, ) -> Result { 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::>() .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 { // 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,89 @@ 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 { - 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: &str = "/usr/lib/punktfunk/vulkan/implicit_layer.d"; +const OUR_WSI_LAYER_MANIFEST: &str = + "/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"; + +/// 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, } -/// [`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() +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 { + if std::path::Path::new(OUR_WSI_LAYER_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, &'static str)> { + 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"), + ("DISABLE_GAMESCOPE_WSI", "1"), + ("ENABLE_GAMESCOPE_WSI", "0"), + ], + Self::DistroKept => Vec::new(), + Self::DistroDisabled => WSI_OFF_ENV.to_vec(), + } + } + + /// As `systemd-run` arguments, for the transient unit. + fn setenv_args(self) -> Vec { + 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 @@ -4309,29 +4369,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." - ); - } - // The two HDR decisions are made independently — `hdr_args` never consults the layer check — 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 !wsi_ok && 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. Fix by installing a \ - VkLayer_FROG_gamescope_wsi built for the gamescope we run." + 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"); @@ -4339,10 +4400,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 @@ -4814,9 +4873,9 @@ mod tests { mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, 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, OUR_WSI_LAYER_DIR, + STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR, }; fn argv(s: &str) -> Vec { @@ -5644,8 +5703,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!( @@ -5658,4 +5717,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) + .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()); + } } diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD index 54694f87..33463c85 100644 --- a/packaging/gamescope/PKGBUILD +++ b/packaging/gamescope/PKGBUILD @@ -98,10 +98,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" } diff --git a/packaging/gamescope/build-punktfunk-gamescope.sh b/packaging/gamescope/build-punktfunk-gamescope.sh index 9b7bf070..e8eaf996 100755 --- a/packaging/gamescope/build-punktfunk-gamescope.sh +++ b/packaging/gamescope/build-punktfunk-gamescope.sh @@ -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,54 @@ 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 - "$LAYER_SRC_JSON" "$LAYER_DEST_JSON" "$LAYER_LIB_PATH" <<'PY' +import json, sys + +src, dst, lib = sys.argv[1:4] +with open(src) as f: + manifest = json.load(f) +layer = manifest["layer"] +# A distinct name is what lets our layer and the distro's coexist: the loader deduplicates implicit +# layers by name, and with both called VK_LAYER_FROG_gamescope_wsi which one wins is unspecified. +layer["name"] = "VK_LAYER_PUNKTFUNK_gamescope_wsi" +# Absolute, so resolution never depends on where the loader happened to find the manifest. +layer["library_path"] = lib +# Our own gates. The distro layer's ENABLE_GAMESCOPE_WSI / DISABLE_GAMESCOPE_WSI keep working on the +# distro layer alone, so the host can switch the two independently in one session. +layer["enable_environment"] = {"PUNKTFUNK_GAMESCOPE_WSI": "1"} +layer["disable_environment"] = {"PUNKTFUNK_GAMESCOPE_WSI_DISABLE": "1"} +with open(dst, "w") as f: + json.dump(manifest, f, indent=2) + f.write("\n") +PY + 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. -- 2.54.0 From 082c65755f2644a4a0afdb09d3071f46e802231a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:37:48 +0200 Subject: [PATCH 5/5] fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install The previous commit built the layer and taught the host to use it, but only the Arch PKGBUILD carried the files, so every other channel still landed on the no-game-HDR fallback. This finishes the job. The packaging scripts now take `--stage`, the DESTDIR the gamescope build script wrote, instead of a path to one binary. That is the part worth keeping: the next file this package needs will not require a new flag in four scripts and two workflows. CI caches the whole staged tree for the same reason. The gs-cache key already hashes packaging/gamescope/**, which this commit changes, so stale caches in the old single-file shape cannot be restored into the new layout. Channels, all of them: rpm spec gains Source1/Source2 and %files entries deb build-gamescope-deb.sh copies the layer into the package root Arch PKGBUILD (previous commit); the sysext extracts the whole usr tree sysext bazzite takes --gamescope-stage; arch asserts the layer arrived nix the derivation keeps, renames and rewrites the layer rather than deleting it with everything else A missing layer is fatal in every one of them, not best-effort. A package that carries the compositor without it looks completely healthy and then silently denies every game an HDR10 swapchain -- the exact failure this whole change exists to end, so it must not be possible to ship it again by accident. Two things needed care: The layer manifest carries an ABSOLUTE library_path baked in at build time, so every channel has to install the .so at exactly that path. That means literal /usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens it by that path -- so multilib has no claim here. The rpm and nix install checks now read the path back out of the manifest and fail if it names a file the package does not install, because a manifest pointing at nothing is the silent shape of this bug. NixOS has no /usr, so the layer lives inside the gamescope derivation and the host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented. The manifest rewrite moved out of a heredoc into packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the Nix store both need it and must rename the layer identically; two copies would drift into a host looking for a name only one of them produces. Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs parse, and the rewrite script was run against a synthetic FROG manifest to confirm it renames/repoints/regates while preserving the `functions` block -- which is the field that decides whether the layer loads at all. NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module are unevaluated; no gamescope build, no package build of any kind, and no game has taken an HDR swapchain on glass. --- .gitea/workflows/deb.yml | 8 ++- .gitea/workflows/rpm.yml | 16 +++-- .../src/vdisplay/linux/gamescope.rs | 46 ++++++++----- docs-site/content/docs/configuration.md | 1 + flake.nix | 4 ++ packaging/arch/build-sysext.sh | 10 ++- packaging/bazzite/build-sysext.sh | 37 +++++++--- packaging/debian/build-gamescope-deb.sh | 33 +++++++-- packaging/gamescope/build-gamescope-rpm.sh | 27 ++++++-- .../gamescope/build-punktfunk-gamescope.sh | 22 +----- packaging/gamescope/punktfunk-gamescope.spec | 35 +++++++++- .../gamescope/rewrite-wsi-layer-manifest.py | 69 +++++++++++++++++++ packaging/nix/gamescope.nix | 42 +++++++++-- packaging/nix/nixos-module.nix | 10 +++ 14 files changed, 288 insertions(+), 72 deletions(-) create mode 100644 packaging/gamescope/rewrite-wsi-layer-manifest.py diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index 67642e71..bbf43021 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -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: diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index f660e120..da2d9a4b 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -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 diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 1b7f82cb..a36e25e1 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -4218,9 +4218,19 @@ const WSI_OFF_ENV: [(&str, &str); 2] = [ /// 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: &str = "/usr/lib/punktfunk/vulkan/implicit_layer.d"; -const OUR_WSI_LAYER_MANIFEST: &str = - "/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"; +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()) +} /// Which Vulkan WSI layer a session we spawn should run with. Three states, decided ONCE per /// launch because [`WsiPlan::resolve`] can spawn `--version` probes. @@ -4240,7 +4250,8 @@ 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 { - if std::path::Path::new(OUR_WSI_LAYER_MANIFEST).is_file() { + 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 @@ -4250,20 +4261,23 @@ impl WsiPlan { } /// The environment this plan needs, as `(name, value)` pairs. - fn env(self) -> Vec<(&'static str, &'static str)> { + 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"), - ("DISABLE_GAMESCOPE_WSI", "1"), - ("ENABLE_GAMESCOPE_WSI", "0"), + ("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.to_vec(), + Self::DistroDisabled => WSI_OFF_ENV + .iter() + .map(|(name, value)| (*name, (*value).to_string())) + .collect(), } } @@ -4870,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, xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, - TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, OUR_WSI_LAYER_DIR, - STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR, + TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, + WSI_OFF_ENV, X11_SOCKET_DIR, }; fn argv(s: &str) -> Vec { @@ -5729,11 +5743,11 @@ mod tests { let get = |k: &str| { env.iter() .find(|(name, _)| *name == k) - .map(|(_, v)| *v) + .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("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"); diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index d448e8e2..8d82ff57 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -95,6 +95,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics). | `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 = 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. | diff --git a/flake.nix b/flake.nix index faa9e701..a048fe27 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/packaging/arch/build-sysext.sh b/packaging/arch/build-sysext.sh index 980dee6f..3ba7843c 100755 --- a/packaging/arch/build-sysext.sh +++ b/packaging/arch/build-sysext.sh @@ -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 diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh index 9d9fa5e4..7b263763 100644 --- a/packaging/bazzite/build-sysext.sh +++ b/packaging/bazzite/build-sysext.sh @@ -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` diff --git a/packaging/debian/build-gamescope-deb.sh b/packaging/debian/build-gamescope-deb.sh index 2c02b285..57922182 100755 --- a/packaging/debian/build-gamescope-deb.sh +++ b/packaging/debian/build-gamescope-deb.sh @@ -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__.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 diff --git a/packaging/gamescope/build-gamescope-rpm.sh b/packaging/gamescope/build-gamescope-rpm.sh index 6d7f4df5..df7d836c 100755 --- a/packaging/gamescope/build-gamescope-rpm.sh +++ b/packaging/gamescope/build-gamescope-rpm.sh @@ -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: /punktfunk-gamescope--..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 \ diff --git a/packaging/gamescope/build-punktfunk-gamescope.sh b/packaging/gamescope/build-punktfunk-gamescope.sh index e8eaf996..107e2380 100755 --- a/packaging/gamescope/build-punktfunk-gamescope.sh +++ b/packaging/gamescope/build-punktfunk-gamescope.sh @@ -180,26 +180,8 @@ LAYER_DEST_JSON="${DESTDIR}${PREFIX}/lib/punktfunk/vulkan/implicit_layer.d/punkt echo "==> installing ${DESTDIR}${LAYER_LIB_PATH}" install -Dm755 "$LAYER_SO" "${DESTDIR}${LAYER_LIB_PATH}" install -d "$(dirname "$LAYER_DEST_JSON")" -python3 - "$LAYER_SRC_JSON" "$LAYER_DEST_JSON" "$LAYER_LIB_PATH" <<'PY' -import json, sys - -src, dst, lib = sys.argv[1:4] -with open(src) as f: - manifest = json.load(f) -layer = manifest["layer"] -# A distinct name is what lets our layer and the distro's coexist: the loader deduplicates implicit -# layers by name, and with both called VK_LAYER_FROG_gamescope_wsi which one wins is unspecified. -layer["name"] = "VK_LAYER_PUNKTFUNK_gamescope_wsi" -# Absolute, so resolution never depends on where the loader happened to find the manifest. -layer["library_path"] = lib -# Our own gates. The distro layer's ENABLE_GAMESCOPE_WSI / DISABLE_GAMESCOPE_WSI keep working on the -# distro layer alone, so the host can switch the two independently in one session. -layer["enable_environment"] = {"PUNKTFUNK_GAMESCOPE_WSI": "1"} -layer["disable_environment"] = {"PUNKTFUNK_GAMESCOPE_WSI_DISABLE": "1"} -with open(dst, "w") as f: - json.dump(manifest, f, indent=2) - f.write("\n") -PY +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 diff --git a/packaging/gamescope/punktfunk-gamescope.spec b/packaging/gamescope/punktfunk-gamescope.spec index bfbe163d..ae1e8a23 100644 --- a/packaging/gamescope/punktfunk-gamescope.spec +++ b/packaging/gamescope/punktfunk-gamescope.spec @@ -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 +# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --stage 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. diff --git a/packaging/gamescope/rewrite-wsi-layer-manifest.py b/packaging/gamescope/rewrite-wsi-layer-manifest.py new file mode 100644 index 00000000..f2825766 --- /dev/null +++ b/packaging/gamescope/rewrite-wsi-layer-manifest.py @@ -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 +""" + +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)) diff --git a/packaging/nix/gamescope.nix b/packaging/nix/gamescope.nix index f13bd953..991b1ee9 100644 --- a/packaging/nix/gamescope.nix +++ b/packaging/nix/gamescope.nix @@ -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 ''; diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 93929a00..7139514b 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -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//exe then resolves to the # very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is -- 2.54.0