From c29e720324f6c737a697d0af9cbbdee55cc11370 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:30 +0200 Subject: [PATCH 1/8] 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 From 94f049ba70db3abf8ca269d6f9b83de1d707d73f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:36 +0200 Subject: [PATCH 2/8] 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 From a190fdb1aef877c811b3026e7a8a0e3603b809d9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 22:59:38 +0200 Subject: [PATCH 3/8] 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 } From 3ac4548cf8a13398bf320cdeea2f88185ac0f996 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:18:02 +0200 Subject: [PATCH 4/8] 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. From 082c65755f2644a4a0afdb09d3071f46e802231a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:37:48 +0200 Subject: [PATCH 5/8] 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 From 81022bcc80f939e2ad9c69755b393f23a261a262 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 23:43:39 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(client/stats):=20keep=20the=20stage=20l?= =?UTF-8?q?ine=20a=20partition=20=E2=80=94=20an=20async=20decode=20figure?= =?UTF-8?q?=20is=20not=20one=20of=20its=20terms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2026-08-13 field report read the OSD's stage line as a breakdown of e2e and asked why the parts did not add up: `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1/9.1`. Fair question, and the numbers are all individually true. They add up without `decode`: 5.4 + 0.3 + 1.4 ≈ 8.1. The stages ARE a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded →(display)→ displayed — and that holds for as long as the `decoded` stamp is a COMPLETION stamp. On the synchronous rungs it is. On the native-Vulkan rung `receive_frame` returns at SUBMISSION (~0.1 ms) and the stamp shipped to the presenter is taken there, so `display` is measured from submit and the GPU decode happens INSIDE it. `host+net` and `display` already tile e2e between them; the `decode` figure, measured received → fence-complete, re-counts the GPU work `display` contains. Two figures, one overlap, printed side by side as though they tiled. So on that rung `decode` leaves the stage line and gets its own, carrying the two caveats a reader needs before the number means anything: it is ONE sample per window there, not the p50 every other figure on that line is, and it is already inside `display` so adding it double-counts. The synchronous rungs are untouched — `decode` is a real term there and stays inline. Deliberately NOT changed: the one-sample-per-window design. `pf_client_core:: session` argues it at length — a per-frame fence wait serialises the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps), and M4 already re-examined and rejected polling, which quantises every sample up by a frame interval (8.3 ms at 120 Hz against decodes of ~0.1-2 ms). That reasoning still holds; the reporting around it was the defect. Making `decode` a genuine per-frame term would need a completion stamp off the hot path — a waiter thread on the timeline, which that comment already names as the remaining option — and is a bigger change than this one. Also not answered here: why the sampled frame read 6.6 ms when the sampling comment expects 0.1-2 ms. It is a tail frame by construction (a frame that took 6.6 ms to decode also took ≥ 6.6 ms to display, against a 1.4 ms display p50), but whether the first frame of a window is SYSTEMATICALLY a tail frame needs instrumenting rather than guessing. Verified in the linux/amd64 container: pf-presenter 47/47 (incl. the new case, which pins both shapes and the timed-out-window zero), pf-client-core 188/188, `clippy --all-targets -D warnings` clean on both, fmt clean. The pf-client-core leg was proven non-vacuous with a planted compile_error! first. --- crates/pf-client-core/src/session.rs | 29 ++++++++ crates/pf-presenter/src/run.rs | 100 +++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index bcffb7fb..ca66765f 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -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 = Vec::with_capacity(256); let mut decode_us: Vec = 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(); diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index f15809ea..8b0810e1 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -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": /// From 0f64551c5608fc5f23c065259ec8a3081c8cbcb2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 00:03:17 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(packaging/gamescope):=20+pfhdr7=20?= =?UTF-8?q?=E2=80=94=20linger=20no=20longer=20dies=20of=20its=20own=20capt?= =?UTF-8?q?ure=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch 0009, reported, written and proven live by luxus (punktfunk-overlay#9): when the capture consumer leaves, stream_handle_remove_buffer — and the stale-push path in dispatch_nudge — destroyed idle buffers on the PipeWire thread. Dropping the last CVulkanTexture reference there calls into the Vulkan driver (vkDestroyImage / FreeMemory / dmabuf fds) while steamcompmgr can still be inside vulkan_screenshot on another buffer of the same 4-buffer pool; on NVIDIA the race lands as a SIGSEGV in CVulkanCmdBuffer::insertBarrier. The timing is what made it selectively lethal: it fires at stream END — exactly the window where the host keeps the headless display lingering for a reconnect. So the kept display was already dead (journal: linger line → coredump → "kept display was dead — recreating") and the "resumed" session was a fresh compositor with the game lost. The fix queues the corpses (bury_buffer, mutex-guarded) and steamcompmgr reaps them on every vblank, including while the stream is only paused — the linger state itself. Field-proven on the reporter's NVIDIA host: 4 coredumps in one evening of BG3 at 4K60 HDR with --pipewire-composite- cursor (the heaviest paint path we ship), zero after; disconnect/reconnect confirmed live to reuse the lingered session (2026-08-13). Three of the four stacks are this race; the fourth (~CVulkanDevice during exit) is patch 0006's already-fixed static-destruction bug — do not re-diagnose it as part of this. Ours differs from the overlay's original only by the meson.build banner hunk: +pfhdr6 → +pfhdr7, PKGBUILD 3.16.25.pfhdr7-1. No new capability — same rule as pfhdr5/6: "reconnect lost my game" triage has to read a box's exposure off its banner, and every probe is >=. Known residual, deliberately untouched: add_buffer's error path still deletes on the PW thread. By the later `goto error`s a texture may be attached, so the race is reachable there in theory — but only when an add FAILS mid-renegotiation, which no field coredump shows; the patch stays byte-identical with what was proven on-glass. Verified: the full 0001..0009 series applies onto the bare 5fb8dce4 pin with plain `git am` (the build script's own invocation, no -3, no fuzz) and with `git am -3`; the fc44 CI image (punktfunk-fedora44-rpm) builds the result with rpm.yml's exact dep recipe to a binary whose banner reads `3.16.25-20-g40fe8b5+pfhdr7 (gcc 16.1.1)`. After 0009, destroy_buffer has exactly two callers left — pipewire_reap_dead_buffers (steamcompmgr vblank) and pipewire_destroy_buffer (steamcompmgr's copy-completion path) — both on the compositor thread. Nix, deb, sysext and rpm all glob patches/*.patch and read the level off the banner, so no other packaging file moves. --- CHANGELOG.md | 19 +++ packaging/gamescope/PKGBUILD | 8 +- packaging/gamescope/README.md | 16 ++ ...-capture-textures-on-the-compositor-.patch | 159 ++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packaging/gamescope/patches/0009-pipewire-destroy-capture-textures-on-the-compositor-.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc8bcf1..80666cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ with the version table of the release you are moving to, then read **Breaking ch ## v0.28.1 — in development +### 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 diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD index 54694f87..18472c65 100644 --- a/packaging/gamescope/PKGBUILD +++ b/packaging/gamescope/PKGBUILD @@ -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') diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index 426a70a0..fbc18796 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -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. diff --git a/packaging/gamescope/patches/0009-pipewire-destroy-capture-textures-on-the-compositor-.patch b/packaging/gamescope/patches/0009-pipewire-destroy-capture-textures-on-the-compositor-.patch new file mode 100644 index 00000000..da784b82 --- /dev/null +++ b/packaging/gamescope/patches/0009-pipewire-destroy-capture-textures-on-the-compositor-.patch @@ -0,0 +1,159 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: luxus +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 + + #include ++#include + #include + #include + +@@ -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 s_deadBuffers; ++ ++static void bury_buffer(struct pipewire_buffer *buffer) ++{ ++ std::lock_guard lock(s_deadBuffersMutex); ++ s_deadBuffers.push_back(buffer); ++} ++ ++void pipewire_reap_dead_buffers(void) ++{ ++ std::vector dead; ++ { ++ std::lock_guard 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); From a3a6444e6e877768843a16a899dbdb65250dd40d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 00:35:33 +0200 Subject: [PATCH 8/8] feat(host,web): unpair every device from one button, over a collection DELETE per plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a host's trust store meant clicking the row trash icon once per device and confirming each time — tedious with a handful of clients, and easy to leave half-done. The "Paired devices" card header now carries an "Unpair all" action behind a single confirmation. It is backed by two new endpoints rather than a loop over the per-fingerprint deletes: DELETE /api/v1/clients -> {"unpaired": N} DELETE /api/v1/native/clients -> {"unpaired": N} one per pairing plane, because the two planes own separate trust stores with separate persistence and separate revocation duties. Each empties its store in ONE persisted write. Doing it as N deletes would rewrite (and atomically rename) the store once per client, and a failure partway would leave the operator with a half-emptied store and no way to tell which half. They are collection deletes, so they carry the single delete's revocation guarantees across the whole set: a live session owned by any removed certificate is ended, and on the Moonlight side the ENet control port closes, because no pairing is left to hold it open. 200 with a count rather than the single delete's 204/404: "unpair everything" is idempotent, an already-empty store satisfies it, and the count still tells the operator whether that meant three devices or none. Both gates match on (method, path), so the roster's plugin-readable GET does not carry over to emptying it — both new routes are admin-token only, like every other pairing-administration route, with explicit rows in the route-classification table. The console calls only the planes that actually have a row: the native endpoint answers 503 on a host built without that plane, which would otherwise report a failure for devices that were never there. --- api/openapi.json | 98 +++++++++++++- crates/punktfunk-host/src/mgmt.rs | 13 +- crates/punktfunk-host/src/mgmt/clients.rs | 56 ++++++++ crates/punktfunk-host/src/mgmt/native.rs | 46 +++++++ crates/punktfunk-host/src/mgmt/shared.rs | 12 ++ crates/punktfunk-host/src/mgmt/tests.rs | 123 +++++++++++++++++- crates/punktfunk-host/src/native_pairing.rs | 6 + .../src/native_pairing/store.rs | 22 ++++ web/messages/de.json | 4 + web/messages/en.json | 4 + web/src/sections/Pairing/PairedDevices.tsx | 80 +++++++++++- web/src/stories/Pairing.stories.tsx | 2 + 12 files changed, 459 insertions(+), 7 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 9b27d5c8..9d0bc2fe 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.27.0" + "version": "0.28.0" }, "paths": { "/api/v1/clients": { @@ -45,6 +45,36 @@ } } } + }, + "delete": { + "tags": [ + "clients" + ], + "summary": "Unpair every client", + "description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.", + "operationId": "unpairAllClients", + "responses": { + "200": { + "description": "Every client unpaired (possibly none)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnpairAllResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } } }, "/api/v1/clients/{fingerprint}": { @@ -1767,6 +1797,56 @@ } } } + }, + "delete": { + "tags": [ + "native" + ], + "summary": "Unpair every native client", + "description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.", + "operationId": "unpairAllNativeClients", + "responses": { + "200": { + "description": "Every native client unpaired (possibly none)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnpairAllResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the trust store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } } }, "/api/v1/native/clients/{fingerprint}": { @@ -7687,6 +7767,22 @@ } } }, + "UnpairAllResult": { + "type": "object", + "description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.", + "required": [ + "unpaired" + ], + "properties": { + "unpaired": { + "type": "integer", + "format": "int32", + "description": "Clients removed from the trust store — 0 when nothing was paired.", + "example": 3, + "minimum": 0 + } + } + }, "UpdateJobInfo": { "type": "object", "description": "A running apply job (or a spawned installer that hasn't resolved yet).", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 5f476435..f747f381 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -214,7 +214,12 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { )) .routes(routes!(host::get_status)) .routes(routes!(host::get_local_summary)) - .routes(routes!(clients::list_paired_clients)) + // GET and DELETE share the `/clients` path, so they must be ONE `routes!` — utoipa-axum + // merges the methods of a single call into one route; two calls collide on the path. + .routes(routes!( + clients::list_paired_clients, + clients::unpair_all_clients + )) .routes(routes!(clients::unpair_client)); // The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only // build's API (and its OpenAPI document) simply has no such endpoints. @@ -226,7 +231,11 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(native::get_native_pairing)) .routes(routes!(native::arm_native_pairing)) .routes(routes!(native::disarm_native_pairing)) - .routes(routes!(native::list_native_clients)) + // Same-path pair as `/clients` above — one `routes!` for both methods. + .routes(routes!( + native::list_native_clients, + native::unpair_all_native_clients + )) .routes(routes!(native::unpair_native_client)) .routes(routes!(native::list_pending_devices)) .routes(routes!(native::approve_pending_device)) diff --git a/crates/punktfunk-host/src/mgmt/clients.rs b/crates/punktfunk-host/src/mgmt/clients.rs index fc8a7656..b59b5b46 100644 --- a/crates/punktfunk-host/src/mgmt/clients.rs +++ b/crates/punktfunk-host/src/mgmt/clients.rs @@ -153,6 +153,62 @@ pub(crate) async fn unpair_client( } } +/// Unpair every client +/// +/// The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write, +/// carrying the same revocation guarantees across the whole set. A LIVE GameStream session is +/// ended (its owning certificate is necessarily one of those just removed), and the ENet control +/// port (UDP 47999) closes, because no pairing is left to hold it open. +/// +/// Idempotent, and so a 200 rather than the single unpair's 204/404 pair: "unpair everything" is +/// satisfied by an already-empty store, and the operator still wants to know whether that meant +/// three devices or none. +#[utoipa::path( + delete, + path = "/clients", + tag = "clients", + operation_id = "unpairAllClients", + responses( + (status = OK, description = "Every client unpaired (possibly none)", body = UnpairAllResult), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn unpair_all_clients(State(st): State>) -> Response { + let mut paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner()); + if paired.is_empty() { + // Nothing to persist, no port to sync — an empty store is already the requested state. + return Json(UnpairAllResult { unpaired: 0 }).into_response(); + } + let removed: Vec<[u8; 32]> = paired + .iter() + .map(|der| Sha256::digest(der).into()) + .collect(); + paired.clear(); + // Persist under the lock, as the single unpair does: a pairing resurrected by a restart would + // silently re-open the control port. + crate::gamestream::save_paired(&paired); + drop(paired); + // A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch + // makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less + // launch — the cert was unreadable at /launch — cannot be attributed, and is left to the port + // teardown below, which here always fires: no pairing remains.) + let live_owner = st + .app + .launch + .lock() + .unwrap_or_else(|e| e.into_inner()) + .and_then(|l| l.owner_fp); + if live_owner.is_some_and(|fp| removed.contains(&fp)) { + st.app.quit_session("client unpaired"); + } + if let Err(e) = crate::gamestream::sync_control(&st.app) { + tracing::warn!(error = %format!("{e:#}"), "control port sync after unpair-all failed"); + } + let unpaired = removed.len() as u32; + tracing::info!(unpaired, "management API: all clients unpaired"); + Json(UnpairAllResult { unpaired }).into_response() +} + /// Pairing-flow status /// /// Poll this to know when to prompt the user for the PIN Moonlight displays. diff --git a/crates/punktfunk-host/src/mgmt/native.rs b/crates/punktfunk-host/src/mgmt/native.rs index 3de638a0..17910de6 100644 --- a/crates/punktfunk-host/src/mgmt/native.rs +++ b/crates/punktfunk-host/src/mgmt/native.rs @@ -256,6 +256,52 @@ pub(crate) async fn unpair_native_client( } } +/// Unpair every native client +/// +/// The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE +/// persisted write (not a loop of them — a failure partway would leave a half-emptied store), and +/// ends every live native session the removed clients own. +/// +/// Idempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store +/// satisfies the request, and the count still tells the operator what it meant. +#[utoipa::path( + delete, + path = "/native/clients", + tag = "native", + operation_id = "unpairAllNativeClients", + responses( + (status = OK, description = "Every native client unpaired (possibly none)", body = UnpairAllResult), + (status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError), + ) +)] +pub(crate) async fn unpair_all_native_clients(State(st): State>) -> Response { + let Some(np) = &st.native else { + return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled"); + }; + match np.remove_all() { + Ok(removed) => { + // Revocation reaches LIVE sessions too — the same guarantee the single unpair gives, + // applied across the set. + let stopped: usize = removed + .iter() + .map(|fp| crate::session_status::stop_by_fingerprint(&fp.to_ascii_lowercase())) + .sum(); + if stopped > 0 { + tracing::info!(stopped, "unpair-all: live native session(s) stopped"); + } + let unpaired = removed.len() as u32; + tracing::info!(unpaired, "management API: all native clients unpaired"); + Json(UnpairAllResult { unpaired }).into_response() + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not persist trust store: {e}"), + ), + } +} + /// List devices awaiting pairing approval /// /// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair diff --git a/crates/punktfunk-host/src/mgmt/shared.rs b/crates/punktfunk-host/src/mgmt/shared.rs index 822d652f..e4a1b797 100644 --- a/crates/punktfunk-host/src/mgmt/shared.rs +++ b/crates/punktfunk-host/src/mgmt/shared.rs @@ -21,6 +21,18 @@ pub(crate) struct ApiError { error: String, } +/// What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and +/// `/native/clients`) so the console sees one schema across both pairing planes. +/// +/// A count rather than 204: "unpair everything" is idempotent, so an empty store is a success, and +/// the operator still wants to be told whether that meant three devices or none. +#[derive(Serialize, ToSchema)] +pub(crate) struct UnpairAllResult { + /// Clients removed from the trust store — 0 when nothing was paired. + #[schema(example = 3)] + pub(crate) unpaired: u32, +} + pub(crate) fn api_error(status: StatusCode, message: &str) -> Response { ( status, diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 94fc0b88..a5d84bc4 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -819,7 +819,8 @@ async fn paired_clients_list_and_unpair() { { let mut p = state.paired.lock().unwrap(); p.clear(); - p.push(der); + // Cloned, not moved: the unpair-all section at the end of this test re-seeds it. + p.push(der.clone()); } let (status, body) = send(&app, get_req("/api/v1/clients")).await; @@ -888,6 +889,71 @@ async fn paired_clients_list_and_unpair() { serde_json::from_slice::>>(&disk).unwrap(), Vec::>::new() ); + + // ---- the COLLECTION delete: unpair everything at once ----------------------------------- + // + // Re-seed two clients (the store was just emptied) and clear the teardown flags, so what the + // bulk delete does to a live session is attributable to IT and not left over from above. + let second = crate::identity::ephemeral().unwrap(); + let (_, second_pem) = x509_parser::pem::parse_x509_pem(second.cert_pem.as_bytes()).unwrap(); + let second_der = second_pem.contents.clone(); + let second_fp = hex::encode(Sha256::digest(&second_der)); + { + use std::sync::atomic::Ordering; + let mut p = state.paired.lock().unwrap(); + p.clear(); + p.push(der.clone()); + p.push(second_der); + state.quit.store(false, Ordering::SeqCst); + state.streaming.store(true, Ordering::SeqCst); + // A live session owned by the SECOND client — the bulk delete must end whichever of the + // removed certs owns it, not just the first one it happens to walk past. + let mut owner = [0u8; 32]; + owner.copy_from_slice(&hex::decode(&second_fp).unwrap()); + *state.launch.lock().unwrap() = Some(LaunchSession { + gcm_key: [0; 16], + rikeyid: 0, + width: 1920, + height: 1080, + fps: 60, + appid: 1, + peer_ip: None, + owner_fp: Some(owner), + }); + } + + let del_all = || { + axum::http::Request::delete("/api/v1/clients") + .body(Body::empty()) + .unwrap() + }; + let (status, body) = send(&app, del_all()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["unpaired"], 2, "both clients must be reported removed"); + + let (_, body) = send(&app, get_req("/api/v1/clients")).await; + assert_eq!(body, serde_json::json!([])); + { + use std::sync::atomic::Ordering; + assert!( + state.launch.lock().unwrap().is_none(), + "unpair-all must end the live session of any client it revokes" + ); + assert!(state.quit.load(Ordering::SeqCst)); + } + // Persisted, for the same reason the single unpair is: a resurrected pairing would re-open + // the control port on the next boot. + let disk = std::fs::read(tmp.path().join("paired.json")).unwrap(); + assert_eq!( + serde_json::from_slice::>>(&disk).unwrap(), + Vec::>::new() + ); + + // Idempotent: emptying an empty store is a 200 with a zero count, NOT the single delete's 404. + // ("unpair everything" is already satisfied — there is no missing resource to report.) + let (status, body) = send(&app, del_all()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["unpaired"], 0); } #[cfg(feature = "gamestream")] @@ -1248,8 +1314,13 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() { // ---- paired-device rosters: readable by a plugin, never by another paired client, and // removal is pairing administration in both lanes. ("GET", "/api/v1/clients", true, false), + // The bulk form is the same authority as the single one — and, sharing its path with a + // plugin-readable GET, worth an explicit row: both gates match on (method, path), so the + // roster's read permission must never carry over to emptying it. + ("DELETE", "/api/v1/clients", false, false), ("DELETE", "/api/v1/clients/{fingerprint}", false, false), ("GET", "/api/v1/native/clients", true, false), + ("DELETE", "/api/v1/native/clients", false, false), ( "DELETE", "/api/v1/native/clients/{fingerprint}", @@ -1667,6 +1738,56 @@ async fn native_pairing_arm_show_and_unpair() { assert_eq!(b["armed"], false); } +/// The collection delete on the native plane: one call empties the trust store, and repeating it +/// is a zero-count success rather than an error. +#[tokio::test] +async fn native_unpair_all_empties_the_trust_store() { + let np = Arc::new( + crate::native_pairing::NativePairing::load_with( + Some(std::env::temp_dir().join(format!("pf-mgmt-np-all-{}.json", std::process::id()))), + None, + false, + ) + .unwrap(), + ); + let app = test_app_native(test_state(), np.clone()); + + np.add("Living room TV", "aa11").unwrap(); + np.add("Studio Deck", "bb22").unwrap(); + assert_eq!(np.list().len(), 2); + + let del_all = || { + axum::http::Request::delete("/api/v1/native/clients") + .body(Body::empty()) + .unwrap() + }; + let (status, body) = send(&app, del_all()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["unpaired"], 2); + + // Gone from both the API and the store behind it (one persisted write, not two). + let (_, body) = send(&app, get_req("/api/v1/native/clients")).await; + assert_eq!(body, serde_json::json!([])); + assert!(np.list().is_empty()); + assert!(!np.is_paired("aa11") && !np.is_paired("bb22")); + + // Idempotent — unlike the single delete, which 404s on a fingerprint it cannot find. + let (status, body) = send(&app, del_all()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["unpaired"], 0); +} + +/// Without a native plane there is no trust store to empty — 503, matching every other +/// `/native/*` route (and NOT a silent 200 that would tell the console it had unpaired something). +#[tokio::test] +async fn native_unpair_all_without_a_native_host_is_unavailable() { + let app = test_app(test_state(), None); + let req = axum::http::Request::delete("/api/v1/native/clients") + .body(Body::empty()) + .unwrap(); + assert_eq!(send(&app, req).await.0, StatusCode::SERVICE_UNAVAILABLE); +} + #[tokio::test] async fn pending_devices_approve_and_deny() { let np = Arc::new( diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index 0bf335d3..f0701f5a 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -159,6 +159,12 @@ impl NativePairing { self.store.remove(fp_hex) } + /// Remove EVERY paired client in one persisted write. Returns the fingerprints removed, so the + /// caller can end the sessions they own. On a persist failure nothing is removed. + pub fn remove_all(&self) -> Result> { + self.store.remove_all() + } + // -- Delegated approval (roadmap §8b-1) --------------------------------- /// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint diff --git a/crates/punktfunk-host/src/native_pairing/store.rs b/crates/punktfunk-host/src/native_pairing/store.rs index e685a196..5acb32b5 100644 --- a/crates/punktfunk-host/src/native_pairing/store.rs +++ b/crates/punktfunk-host/src/native_pairing/store.rs @@ -130,6 +130,28 @@ impl TrustStore { Ok(removed) } + /// Remove EVERY paired client, in ONE persisted write. Returns the fingerprints removed, so + /// the caller can tear down the live sessions they own. On a persist failure the in-memory + /// store is rolled back (it never diverges from disk), exactly like [`Self::remove`]. + /// + /// Not a loop over [`Self::remove`]: that would rewrite (and fsync-rename) the store once per + /// client, and a failure partway would leave the operator with a half-emptied trust store and + /// no way to tell which half. + pub(super) fn remove_all(&self) -> Result> { + let mut p = self.paired.lock().unwrap(); + if p.clients.clients.is_empty() { + return Ok(Vec::new()); + } + // `take` leaves the empty list in place to be persisted, and hands us the snapshot that + // doubles as both the rollback value and the removed-fingerprint report. + let snapshot = std::mem::take(&mut p.clients.clients); + if let Err(e) = save(&p) { + p.clients.clients = snapshot; + return Err(e); + } + Ok(snapshot.into_iter().map(|c| c.fingerprint).collect()) + } + /// The number of paired clients (for the status snapshot). pub(super) fn count(&self) -> u32 { self.paired.lock().unwrap().clients.clients.len() as u32 diff --git a/web/messages/de.json b/web/messages/de.json index 14c2d9ba..a55bca3a 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -118,6 +118,7 @@ "action_stop_session": "Sitzung beenden", "action_request_idr": "Keyframe anfordern", "action_unpair": "Entkoppeln", + "action_unpair_all": "Alle entkoppeln", "connect_title": "Gerät verbinden", "connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.", "connect_address": "Host-Adresse", @@ -263,6 +264,9 @@ "pairing_native_empty": "Noch keine Geräte gekoppelt.", "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln?", "pairing_native_unpair_body": "Es muss sich erneut koppeln, um zu verbinden.", + "pairing_native_unpair_all_confirm": "Alle {count} Geräte entkoppeln?", + "pairing_native_unpair_all_body": "Jedes gekoppelte Gerät — punktfunk/1 wie Moonlight — muss sich erneut koppeln, um zu verbinden; was gerade streamt, wird getrennt.", + "pairing_native_unpair_all_failed": "Einige Geräte konnten nicht entkoppelt werden.", "pairing_protocol": "Protokoll", "pairing_protocol_native": "punktfunk/1", "pairing_protocol_moonlight": "Moonlight", diff --git a/web/messages/en.json b/web/messages/en.json index 7c461e7f..2f218b01 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -118,6 +118,7 @@ "action_stop_session": "Stop session", "action_request_idr": "Request keyframe", "action_unpair": "Unpair", + "action_unpair_all": "Unpair all", "connect_title": "Connect a device", "connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.", "connect_address": "Host address", @@ -263,6 +264,9 @@ "pairing_native_empty": "No devices paired yet.", "pairing_native_unpair_confirm": "Unpair this device?", "pairing_native_unpair_body": "It will need to pair again to connect.", + "pairing_native_unpair_all_confirm": "Unpair all {count} devices?", + "pairing_native_unpair_all_body": "Every paired device — punktfunk/1 and Moonlight alike — will need to pair again to connect, and anything streaming right now is disconnected.", + "pairing_native_unpair_all_failed": "Some devices could not be unpaired.", "pairing_protocol": "Protocol", "pairing_protocol_native": "punktfunk/1", "pairing_protocol_moonlight": "Moonlight", diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx index 73fee68c..95768ceb 100644 --- a/web/src/sections/Pairing/PairedDevices.tsx +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -1,14 +1,17 @@ import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "@unom/ui/toast"; import { Trash2 } from "lucide-react"; import type { FC } from "react"; import { getListPairedClientsQueryKey, useListPairedClients, + useUnpairAllClients, useUnpairClient, } from "@/api/gen/clients/clients"; import { getListNativeClientsQueryKey, useListNativeClients, + useUnpairAllNativeClients, useUnpairNativeClient, } from "@/api/gen/native/native"; import { useDialogs } from "@/components/dialogs"; @@ -49,6 +52,8 @@ export const PairedDevicesSection: FC = () => { const moonlight = useListPairedClients(); const unpairNative = useUnpairNativeClient(); const unpairMoonlight = useUnpairClient(); + const unpairAllNative = useUnpairAllNativeClients(); + const unpairAllMoonlight = useUnpairAllClients(); const rows: PairedRow[] = [ ...(native.data ?? []).map( @@ -94,6 +99,39 @@ export const PairedDevicesSection: FC = () => { } }; + /** + * Unpair EVERY device, in one confirmation. + * + * Two calls, not one per device: each plane owns a separate trust store behind its own + * collection DELETE, and each of those empties its store in a single persisted write host-side. + * Only the planes actually holding a row are called — the native endpoint answers 503 on a host + * built without it, which would otherwise report a failure for devices that were never there. + */ + const onUnpairAll = async () => { + const ok = await confirm({ + title: m.pairing_native_unpair_all_confirm({ count: rows.length }), + description: m.pairing_native_unpair_all_body(), + confirmLabel: m.action_unpair_all(), + destructive: true, + }); + if (!ok) return; + const calls: Promise[] = []; + if (rows.some((r) => r.protocol === "native")) { + calls.push(unpairAllNative.mutateAsync()); + } + if (rows.some((r) => r.protocol === "moonlight")) { + calls.push(unpairAllMoonlight.mutateAsync()); + } + // allSettled, not all: the two planes are independent, so one failing must neither cancel + // the other nor throw past this handler. + const settled = await Promise.allSettled(calls); + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); + qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }); + if (settled.some((r) => r.status === "rejected")) { + toast.error(m.pairing_native_unpair_all_failed()); + } + }; + // The fingerprint of the row whose unpair is in flight (if any) — so only THAT row's button // disables, not every row's. const pendingFingerprint = @@ -105,6 +143,11 @@ export const PairedDevicesSection: FC = () => { : undefined) ?? null; + // Derived, not state: the two bulk calls are launched together and awaited together, so their + // pending flags cover the whole run without a gap in the middle to flicker through. + const isUnpairingAll = + unpairAllNative.isPending || unpairAllMoonlight.isPending; + return ( { moonlight.refetch(); }} onUnpair={onUnpair} + onUnpairAll={onUnpairAll} pendingFingerprint={pendingFingerprint} + isUnpairingAll={isUnpairingAll} /> ); }; @@ -127,12 +172,39 @@ export const PairedDevices: FC<{ error: unknown; refetch: () => void; onUnpair: (protocol: PairedProtocol, fingerprint: string) => void; + /** Unpair every row, behind one confirmation. */ + onUnpairAll: () => void; /** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */ pendingFingerprint: string | null; -}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => ( + /** A bulk unpair is walking the list — every control in the card disables until it finishes. */ + isUnpairingAll: boolean; +}> = ({ + rows, + isLoading, + error, + refetch, + onUnpair, + onUnpairAll, + pendingFingerprint, + isUnpairingAll, +}) => ( - + {/* flex-row: CardHeader stacks by default, and this one carries a trailing action. */} +

{m.pairing_native_devices()}

+ {/* Nothing to unpair in bulk when the list is empty (or still loading) — an enabled + button there would open a confirmation reading "Unpair all 0 devices?". */} + {rows.length > 0 && ( + + )}
@@ -172,7 +244,9 @@ export const PairedDevices: FC<{ variant="ghost" size="icon" aria-label={m.action_unpair()} - disabled={pendingFingerprint === r.fingerprint} + disabled={ + isUnpairingAll || pendingFingerprint === r.fingerprint + } onClick={() => onUnpair(r.protocol, r.fingerprint)} > diff --git a/web/src/stories/Pairing.stories.tsx b/web/src/stories/Pairing.stories.tsx index a260b63b..ea0a5520 100644 --- a/web/src/stories/Pairing.stories.tsx +++ b/web/src/stories/Pairing.stories.tsx @@ -77,7 +77,9 @@ export const Armed: Story = { error={null} refetch={noop} onUnpair={noop} + onUnpairAll={noop} pendingFingerprint={null} + isUnpairingAll={false} /> ), },