From bc9201d136c9a443deda7fb82ab7d07f90304207 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 18:00:28 +0200 Subject: [PATCH 1/3] fix(packaging/gamescope): bump the pin past upstream's capture-format probe, and sign the RPM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, one delivery path — a Fedora/Nobara box getting the patched gamescope. **The pin moves 8c676c39 -> 5fb8dce4** (3.16.25-1 -> 3.16.25-11). The commit that matters is ff6b924, `rendervulkan: fall back to XBGR2101010 when XRGB2101010 is unsupported`: it probes `linearTilingFeatures` for STORAGE+SAMPLED and captures as XBGR2101010 where A2R10G10B10 linear storage is unavailable — which is every NVIDIA. That covers the paths that are upstream's rather than ours: the RGB intermediate `paint_pipewire()` acquires when the stream is YCbCr, and AVIF screenshots. #143 fixed our own node host-side; this is the other half, and its commit message asked for exactly this bump. All six patches rebased. Only 0006 conflicted: upstream's f8be7ee added `vulkan_has_drm_modifiers_for_features()` immediately above the `g_device` declaration our patch turns into a reference — both kept. 0003 and 0005 come out byte-identical; 0006 also picks up the `--zero-commit --no-signature` form 0001-0005 already used. **Patch 0001 now offers `xBGR_210LE` BEFORE `xRGB_210LE`**, mirroring the host-side `HDR_FORMAT_ORDER` rationale on the producer end. A consumer takes the first pod it can use, and we were handing third-party consumers (OBS and friends) the one format NVIDIA fills byte-reversed under a correct-looking label. Deliberately NOT done by calling upstream's `vulkan_get_rgb10_capture_format()`, which is what pw_pods.rs proposes: that symbol landed after 3.16.25, so it would break `packaging/nix/gamescope.nix` — which applies these patches to whatever gamescope nixpkgs pins — with an opaque C++ error instead of a patch conflict. The reorder gets the same outcome on any base. Note added there so the next reader does not "fix" it. **And the RPM was never signed.** `Sign RPMs` runs right after `Build RPM`; the gamescope RPM is built ~90 steps later, behind its own ~10-minute cache, so it missed the signing pass entirely — every punktfunk-gamescope RPM ever published went out unsigned. The repo file we tell users to install carries `gpgcheck=1`, so `dnf install punktfunk-gamescope` failed with "The package is not signed" on every Fedora and Nobara box. The package was in the channel the whole time and could not be installed from it, which is worse than absent: the notes and the docs-site both say it is there. `sign-rpms.sh` now takes explicit paths (defaulting to `dist/*.rpm` as before) and a second pass signs this one before publish, fail-closed on a tag like the first. Verified on Nobara 44 (VM 123, RTX 5070 Ti passthrough), canary 0.27.0-0.ci12611.g516a2954: * Builds clean in the fc44 CI image; banner `3.16.25-17-ga87390d+pfhdr4` (11 upstream + our 6), so the marker the host probes still reads 4 — no capability moved, hence pkgrel 3 and `.pfhdrN` staying put. * `pw-cli enum-params` on the live node: BGRx, NV12, **xBGR_210LE (81), xRGB_210LE (80)** — 8-bit consumers still negotiate bit-for-bit, 10-bit now leads with the safe one. * All four patched flags present, `--pipewire-composite-external-overlay` included. * Patch 0006 confirmed working by comparison, which is the only way to see it: the new build exits 0 where both the pre-0006 `+pfhdr2` build and the stock 3.16.23.2 abort with 134. * Signing fix proven with a throwaway key: `Signature: (none)` -> `digests signatures OK`. * Host health on the canary: synthetic spike 300/300 encoded, loopback 300 recovered, 0 mismatches. One unexplained one-off: the very first headless run after install segfaulted at exit (SIGSEGV, after "Primary child shut down!"). Not reproduced in 11 subsequent runs across every flag combination, so it is recorded rather than diagnosed — the binary is stripped and there is no symbolised core. --- .gitea/workflows/rpm.yml | 25 ++++++++++++++++ packaging/flatpak/io.unom.Punktfunk.yml | 2 +- packaging/gamescope/PKGBUILD | 10 +++++-- packaging/gamescope/README.md | 15 +++++++--- .../gamescope/build-punktfunk-gamescope.sh | 2 +- ...0-bit-BT.2020-PQ-capture-formats-HDR.patch | 29 ++++++++++++------- ...lly-composite-the-cursor-into-the-ca.patch | 6 ++-- ...lly-composite-the-external-overlay-i.patch | 4 +-- ...destroy-the-Vulkan-device-or-output-.patch | 17 +++++------ packaging/nix/gamescope.nix | 8 +++++ packaging/rpm/sign-rpms.sh | 22 ++++++++++++-- 11 files changed, 104 insertions(+), 36 deletions(-) diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index b39c6040..004bc787 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -247,6 +247,31 @@ jobs: echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM" fi + # A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build + # time, long before this RPM existed — the gamescope build sits behind its own ~10-minute + # cache and deliberately runs after the host RPMs are already published. So every + # punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to + # install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is + # not signed" on every Fedora and Nobara box. The package was in the channel the whole time + # and could not be installed from it — which is worse than absent, because the release notes + # and the docs-site both say it is there. + # + # Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org + # secret is missing, rather than republishing something a user's dnf will reject. + - name: Sign punktfunk-gamescope + env: + RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }} + RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }} + run: | + shopt -s nullglob + rpms=(dist/punktfunk-gamescope-*.rpm) + # No RPM here is the best-effort skip above, already warned about — not a signing failure. + if [ "${#rpms[@]}" -eq 0 ]; then + echo "no punktfunk-gamescope RPM to sign (see the packaging step above)" + exit 0 + fi + bash packaging/rpm/sign-rpms.sh "${rpms[@]}" + - name: Publish punktfunk-gamescope to the Gitea RPM registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 67cfac28..816f7a94 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -272,7 +272,7 @@ modules: - type: git url: https://github.com/ValveSoftware/gamescope.git # KEEP IN SYNC with `_gsrev` in packaging/gamescope/PKGBUILD. - commit: 8c676c399c761e4540587f61004c957993d12fea + commit: 5fb8dce4a09d0a68d097b9faf9513782106bc843 # Wrap pins as of that rev (`subprojects/*.wrap`). These are meson WRAPS, not gamescope # submodules, so nothing else populates them and they need explicit sources. # diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD index 4d847e18..c86767e1 100644 --- a/packaging/gamescope/PKGBUILD +++ b/packaging/gamescope/PKGBUILD @@ -18,11 +18,17 @@ pkgname=punktfunk-gamescope # The `.pfhdrN` suffix is the patch-set revision the binary stamps into its banner (see README.md); # bump it with the marker so pacman sees a new version when only our patches moved. _gsver=3.16.25 -_gsrev=8c676c399c761e4540587f61004c957993d12fea +_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843 pkgver="${_gsver}.pfhdr4" # 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the # `.pfhdrN` level deliberately stays put — see README.md. -pkgrel=2 +# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own +# `vulkan_get_rgb10_capture_format()` — the XBGR2101010 fallback for devices with no linear-tiled +# A2R10G10B10 storage (every NVIDIA). That fixes the NV12/P010 capture intermediate and AVIF +# screenshots, which are upstream's paths, not ours. Patch 0001 additionally now offers +# `xBGR_210LE` BEFORE `xRGB_210LE` so a third-party consumer cannot pick the one NVIDIA fills +# byte-reversed. Still no capability the host probes for, so `.pfhdrN` stays at 4. +pkgrel=3 pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming" arch=('x86_64' 'aarch64') url="https://git.unom.io/unom/punktfunk" diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index 056ca35d..3901074b 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -97,14 +97,21 @@ distro's `gamescope`. ## Building -Pinned upstream: `8c676c39` (master, 2026-07-27 — tags through 3.16.25). The patches apply -cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp` and -`src/meson.build` only. +Pinned upstream: `5fb8dce4` (master, 2026-08-03 — `3.16.25-11-g5fb8dce`). The patches apply +cleanly to that commit; they touch `src/pipewire.cpp`, `src/steamcompmgr.cpp`, +`src/rendervulkan.cpp`, `src/rendervulkan.hpp` and `src/meson.build` only. + +The bump from `8c676c39` is deliberate: it brings upstream's `vulkan_get_rgb10_capture_format()` +(`ff6b924`), which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to +`DRM_FORMAT_XBGR2101010` on devices that cannot do linear-tiled `A2R10G10B10` — i.e. every +NVIDIA. That covers the paths that are upstream's rather than ours: the RGB intermediate +`paint_pipewire()` acquires when the stream is YCbCr, and AVIF screenshots. Our own 10-bit RGB +node is covered by patch `0001`, which offers `xBGR_210LE` first for the same reason. ```sh git clone https://github.com/ValveSoftware/gamescope.git cd gamescope -git checkout 8c676c39 +git checkout 5fb8dce4 git submodule update --init --recursive # or let meson fetch the subprojects git am /path/to/punktfunk/packaging/gamescope/patches/*.patch diff --git a/packaging/gamescope/build-punktfunk-gamescope.sh b/packaging/gamescope/build-punktfunk-gamescope.sh index 2f15545d..f2ed3075 100755 --- a/packaging/gamescope/build-punktfunk-gamescope.sh +++ b/packaging/gamescope/build-punktfunk-gamescope.sh @@ -25,7 +25,7 @@ set -euo pipefail # The pinned upstream. Bump together with the patches (they are `git am`-able and rebase cheaply — # two files, mirroring code that already exists in-tree; see README.md). -GAMESCOPE_REV="8c676c399c761e4540587f61004c957993d12fea" +GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843" GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git" REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1 diff --git a/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch b/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch index 81708ccc..aae821fe 100644 --- a/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch +++ b/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch @@ -32,12 +32,12 @@ follow what the app happens to render. Works on the headless backend as well as a real connector: no HDR display is involved anywhere in the LUT set. --- - src/pipewire.cpp | 114 +++++++++++++++++++++++++++++++++---------- + src/pipewire.cpp | 123 ++++++++++++++++++++++++++++++++++--------- src/steamcompmgr.cpp | 21 ++++++-- - 2 files changed, 106 insertions(+), 29 deletions(-) + 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/pipewire.cpp b/src/pipewire.cpp -index 76b3ea8..6b56b01 100644 +index 76b3ea8..c84b19c 100644 --- a/src/pipewire.cpp +++ b/src/pipewire.cpp @@ -18,6 +18,40 @@ @@ -159,7 +159,7 @@ index 76b3ea8..6b56b01 100644 params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame)); // for (auto& param : params) -@@ -166,6 +209,14 @@ static std::vector build_format_params(struct spa_pod_bu +@@ -166,6 +209,23 @@ static std::vector build_format_params(struct spa_pod_bu build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params); build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params); @@ -168,13 +168,22 @@ index 76b3ea8..6b56b01 100644 + // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that + // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry + // above — ever reaches these. -+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params); ++ // ++ // xBGR_210LE FIRST, and that order is correctness, not style. A consumer takes the first pod ++ // it can use, and xBGR is the only one every vendor fills correctly: capture textures are ++ // mappable, hence linear-tiled, and linear STORAGE for A2R10G10B10 is an optional Vulkan ++ // feature NVIDIA does not implement — there the composite's `imageStore` lands in XBGR order, ++ // so a consumer that took xRGB_210LE gets a buffer LABELLED XRGB2101010 and FILLED as XBGR, ++ // i.e. red and blue swapped, with every format mapping on both ends individually correct. ++ // A2B10G10R10 is the universally supported packed-10 format, so leading with it costs nothing ++ // on AMD or Intel. xRGB_210LE stays as the second pod for a consumer that only speaks it. + build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params); ++ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params); +#endif return params; } -@@ -288,7 +339,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd) +@@ -288,7 +348,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd) if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) { pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight); @@ -183,7 +192,7 @@ index 76b3ea8..6b56b01 100644 struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); std::vector format_params = build_format_params(&builder); int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size()); -@@ -412,6 +463,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp +@@ -412,6 +472,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp state->video_info.size.width, state->video_info.size.height, s_nRequestedWidth, s_nRequestedHeight, state->video_info.format, state->shm_stride, shm_size, state->dmabuf); @@ -196,7 +205,7 @@ index 76b3ea8..6b56b01 100644 } static void randname(char *buf) -@@ -450,6 +507,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format) +@@ -450,6 +516,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format) switch (spa_format) { case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12; @@ -208,7 +217,7 @@ index 76b3ea8..6b56b01 100644 default: case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888; } -@@ -715,7 +777,7 @@ bool init_pipewire(void) +@@ -715,7 +786,7 @@ bool init_pipewire(void) s_nOutputHeight = g_nOutputHeight; calculate_capture_size(); @@ -218,7 +227,7 @@ index 76b3ea8..6b56b01 100644 std::vector format_params = build_format_params(&builder); diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index ff9ae1f..01b2abf 100644 +index ecb3808..83c751b 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -2335,17 +2335,32 @@ static void paint_pipewire() diff --git a/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch b/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch index 32f2b1ce..d80b3096 100644 --- a/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch +++ b/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch @@ -58,7 +58,7 @@ index 9fc54f0..1eb35b3 100644 " If this is not set, and there is a HDR client, it will be tonemapped SDR.\n" " --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n" diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 01b2abf..5c65420 100644 +index 83c751b..a8a816f 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -2316,6 +2316,13 @@ static void update_touch_scaling( const struct FrameInfo_t *frameInfo ) @@ -146,9 +146,9 @@ index 01b2abf..5c65420 100644 + } + gamescope::Rc pRGBTexture = s_pPipewireBuffer->texture->isYcbcr() - ? vulkan_acquire_capture_texture( uWidth, uHeight, false, DRM_FORMAT_XRGB2101010 ) + ? vulkan_acquire_capture_texture( uWidth, uHeight, false, vulkan_get_rgb10_capture_format() ) : gamescope::Rc{ s_pPipewireBuffer->texture }; -@@ -8397,6 +8452,12 @@ steamcompmgr_main(int argc, char **argv) +@@ -8404,6 +8459,12 @@ steamcompmgr_main(int argc, char **argv) g_FadeOutDuration = atoi(optarg); } else if (strcmp(opt_name, "force-windows-fullscreen") == 0) { bForceWindowsFullscreen = true; diff --git a/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch b/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch index 924f5455..d26a3048 100644 --- a/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch +++ b/packaging/gamescope/patches/0004-pipewire-optionally-composite-the-external-overlay-i.patch @@ -62,7 +62,7 @@ index 2c4fb50..b406caf 100644 " If this is not set, and there is a HDR client, it will be tonemapped SDR.\n" " --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n" diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 5c65420..0d293c6 100644 +index a8a816f..64e1a8c 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -2323,6 +2323,12 @@ gamescope::ConVar cv_pipewire_composite_cursor{ "pipewire_composite_cursor @@ -127,7 +127,7 @@ index 5c65420..0d293c6 100644 // The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the // PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play // consumer that draws its own would end up with two — so a consumer that has no cursor of -@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv) +@@ -8464,6 +8497,12 @@ steamcompmgr_main(int argc, char **argv) cv_pipewire_composite_cursor = true; #else fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" ); diff --git a/packaging/gamescope/patches/0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch b/packaging/gamescope/patches/0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch index c8b449b9..5e9be9c3 100644 --- a/packaging/gamescope/patches/0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch +++ b/packaging/gamescope/patches/0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch @@ -1,4 +1,4 @@ -From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001 +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrico=20B=C3=BChler?= Date: Sat, 8 Aug 2026 19:17:25 +0200 Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit @@ -49,7 +49,7 @@ Both are needed: pinning only the device relocated the fault into 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp -index 5c2dd11..8cd5ca2 100644 +index 3f7ea90..0382a4b 100644 --- a/src/rendervulkan.cpp +++ b/src/rendervulkan.cpp @@ -118,7 +118,37 @@ static VkResult vulkan_load_module() @@ -102,10 +102,10 @@ index 5c2dd11..8cd5ca2 100644 static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA ) { diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp -index b6749d4..a9335c4 100644 +index c49b95f..ee98b38 100644 --- a/src/rendervulkan.hpp +++ b/src/rendervulkan.hpp -@@ -564,7 +564,7 @@ enum ShaderType { +@@ -565,7 +565,7 @@ enum ShaderType { SHADER_TYPE_COUNT }; @@ -114,12 +114,9 @@ index b6749d4..a9335c4 100644 struct SamplerState { -@@ -1007,4 +1007,4 @@ void vulkan_wait_idle(); - // Whether the driver implements VK_EXT_physical_device_drm - bool vulkan_has_drm_props(); +@@ -1010,4 +1010,4 @@ bool vulkan_has_drm_props(); + + bool vulkan_has_drm_modifiers_for_features(VkFormat format, VkFormatFeatureFlags features); -extern CVulkanDevice g_device; +extern CVulkanDevice &g_device; --- -2.55.0 - diff --git a/packaging/nix/gamescope.nix b/packaging/nix/gamescope.nix index 55078b5f..f13bd953 100644 --- a/packaging/nix/gamescope.nix +++ b/packaging/nix/gamescope.nix @@ -20,6 +20,14 @@ # been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in # `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails # loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR. +# +# ⚠️ Kept deliberately free of any dependency on the pinned rev. The pin moved past upstream's +# `vulkan_get_rgb10_capture_format()` (`ff6b924`, after 3.16.25) to fix red/blue on NVIDIA, and it +# would have been natural to have patch `0001` call it — that is what the host-side note in +# `crates/pf-capture/src/linux/pw_pods.rs` proposes. It does NOT, precisely so this derivation +# keeps building against a nixpkgs that still pins 3.16.25, where that symbol does not exist and +# the failure would be an opaque C++ error rather than a patch conflict. Patch `0001` gets the +# same outcome version-independently by offering `xBGR_210LE` ahead of `xRGB_210LE`. { lib, gamescope, diff --git a/packaging/rpm/sign-rpms.sh b/packaging/rpm/sign-rpms.sh index 7c577e30..51a13548 100755 --- a/packaging/rpm/sign-rpms.sh +++ b/packaging/rpm/sign-rpms.sh @@ -17,8 +17,24 @@ # can't supply a passphrase non-interactively here. # # Usage (in rpm.yml, after build-rpm.sh): RPM_GPG_PRIVATE_KEY=... bash packaging/rpm/sign-rpms.sh +# +# Takes the RPMs to sign as arguments, defaulting to `dist/*.rpm`. The argument form exists because +# punktfunk-gamescope is built LATER in the job than the host RPMs — it is a ~10-minute C++ build +# behind its own cache — so it misses the main signing pass entirely and needs a second one just +# for itself. It shipped unsigned into a `gpgcheck=1` repo that way, which made +# `dnf install punktfunk-gamescope` fail with "The package is not signed" for every Fedora/Nobara +# user: the package was in the channel and still uninstallable. set -euo pipefail +# Default target, and a real glob rather than a literal when nothing matched. +if [ "$#" -gt 0 ]; then + RPMS=("$@") +else + shopt -s nullglob + RPMS=(dist/*.rpm) +fi +[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs to sign" >&2; exit 1; } + if [ -z "${RPM_GPG_PRIVATE_KEY:-}" ]; then case "${GITHUB_REF:-}" in refs/tags/v*) @@ -47,11 +63,11 @@ KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit} # correctly. (A custom __gpg_sign_cmd passed via --define reached gpg with those filename macros # UNEXPANDED -> "No such file or directory".) Just point rpm at our key; the GNUPGHOME above # (passphrase-less key + loopback) lets gpg sign headless. -for rpm in dist/*.rpm; do +for rpm in "${RPMS[@]}"; do rpmsign --define "_gpg_name $KEYID" --addsign "$rpm" done # Verify locally so a bad signature fails the build before publishing. rpm --import <(gpg --export --armor "$KEYID") -rpmkeys --checksig dist/*.rpm -echo "signed + verified $(find dist -name '*.rpm' | wc -l) RPM(s) with key $KEYID" +rpmkeys --checksig "${RPMS[@]}" +echo "signed + verified ${#RPMS[@]} RPM(s) with key $KEYID" From 3500e956604b07ef811d72c51cbfd1c0b57da275 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 20:15:52 +0200 Subject: [PATCH 2/3] fix(pf-vdisplay): make Nobara's session run the patched gamescope, and stop the WSI layer killing every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reasons a Nobara box could never stream from a gamescope session, both found on glass (VM 123, Nobara 44, RTX 5070 Ti). **1. The session ran a stock gamescope, so the host refused it.** Nobara's `gamescope-session-plus` builds its command as GAMESCOPECMD="/usr/bin/gamescope \ and reads `GAMESCOPE_BIN` NOWHERE. All three of our spawn levers miss at once: the env var is ignored, and an absolute path cannot be redirected by a PATH shim. So the session ran stock gamescope, the capability probe rejected it, and every session died with "pipeline build failed (out of retries) … it ignored GAMESCOPE_BIN / the PATH shim". `~/.gamescope-cmd.log` — which the script writes with the exact command it ran — settles that in one line, and is the first thing to read on any such report. Fixed by binding our wrapper over `/usr/bin/gamescope` inside the transient unit's mount namespace (`BindReadOnlyPaths`). Deliberately a bind, not a replacement: punktfunk-gamescope ships under its own name precisely so it sits BESIDE the distro package, and the bind is scoped to the session — nothing outside it sees the redirect and nothing is written to `/usr`. Skipped when the resolved binary already IS `/usr/bin/gamescope`. **2. With the patched gamescope finally running, every Vulkan client died — black screen.** The box's `VkLayer_FROG_gamescope_wsi` ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol. Ours disagrees, so the compositor rejects the client's `swapchain_feedback` ("message too short") and drops it. Steam never paints; there is no other symptom, which is what makes it expensive to find. Measured with `vkcube` under each build, layer on: ours 3.16.25-17 ON -> 1 rejected client ours 3.16.25-17 OFF -> 0 OLD pin 3.16.25-4 ON -> 1 rejected client stock 3.16.23.2 ON -> 0 ⭐ The upstream protocol XML is BYTE-IDENTICAL between the distro's commit (5cdb5b0) and our pin — same interface version, same `uuuuuus` signature — so this is the distro patching gamescope, not a version bump. Hence the gate is "do the upstream triples differ", not a floor, and an unreadable version on either side leaves the layer alone rather than degrading a box that works (Bazzite/SteamOS, where it has always been fine). ⚠⚠ The old pin fails identically, so REVERTING the pin bump fixes nothing here — this is pre-existing, not a regression from 5fb8dce4. Verified against the UNPATCHED distro script, reproducing exactly what this code emits: the session's own log reports `punktfunk-gamescope version 3.16.25-17-ga87390d+pfhdr4`, with 0 swapchain_feedback errors, 0 client-communication errors and 0 aborts. Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean. Non-vacuity re-verified per the xcheck note — a planted type error in the new function produced 3 errors, and removing it went back to Finished. Still open, deliberately NOT addressed here: a 10-bit HDR stream aborts gamescope in `destroy_buffer` (upstream `pipewire.cpp:88`), which is a separate defect. --- .../src/vdisplay/linux/gamescope.rs | 87 ++++++++++++++++++- .../src/vdisplay/linux/gamescope/discovery.rs | 16 ++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 53538899..314ea07e 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -2413,6 +2413,60 @@ fn write_gamescope_bin_wrapper() -> Result { Ok(path) } +/// The absolute path a session script may hardcode instead of honouring `GAMESCOPE_BIN`. +/// +/// Nobara's `gamescope-session-plus` builds its command as `GAMESCOPECMD="/usr/bin/gamescope …"` +/// and reads `GAMESCOPE_BIN` NOWHERE, so all three of our spawn levers miss at once: the env var +/// is ignored, and an absolute path cannot be redirected by a PATH shim. The session then runs a +/// stock gamescope, the capability probe rejects it, and every session dies with +/// "pipeline build failed (out of retries)". +const DISTRO_GAMESCOPE_PATH: &str = "/usr/bin/gamescope"; + +/// Bind our wrapper over [`DISTRO_GAMESCOPE_PATH`] **inside the session unit's mount namespace**, +/// so a script that hardcodes that path still gets the patched build. +/// +/// Deliberately a bind rather than replacing the distro's binary: `punktfunk-gamescope` ships under +/// its own name precisely so it sits BESIDE the distro package (a Steam gaming session keeps using +/// its own gamescope — see packaging/gamescope/README.md). The bind is scoped to this transient +/// unit, so nothing outside the session sees it and nothing is written to `/usr`. +/// +/// Skipped when the resolved binary IS the distro path (nothing to redirect) — binding a file over +/// itself is pointless, and on a box with no `punktfunk-gamescope` we must not pretend otherwise. +fn session_gamescope_bind(wrapper: &std::path::Path) -> Option { + if gamescope_bin() == DISTRO_GAMESCOPE_PATH { + return None; + } + Some(format!( + "--property=BindReadOnlyPaths={}:{DISTRO_GAMESCOPE_PATH}", + wrapper.display() + )) +} + +/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run. +/// +/// 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 +/// paints and the stream is a black screen with no error anywhere else. +/// +/// Measured on Nobara 44 (`vkcube` under each build, layer on): +/// distro 3.16.23.2 → 0 errors; our 3.16.25 → 1 rejected client. The upstream protocol XML is +/// 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. +/// +/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras +/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start. +fn wsi_layer_matches_our_gamescope() -> bool { + let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin())); + let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH)); + match (ours, distro) { + // Same upstream triple ⇒ the layer was built from the same protocol. Keep it. + (Some(a), Some(b)) => a == b, + // Either side unreadable: leave the layer alone rather than degrade a box that works. + _ => true, + } +} + /// Launch `gamescope-session-plus ` headless at `mode` as a transient `systemd --user` /// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via /// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and @@ -2451,9 +2505,38 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul r.dedup(); r.iter().map(u32::to_string).collect::>().join(",") }; + // Redirect a hardcoded `/usr/bin/gamescope` at our wrapper, for session scripts that never + // read `GAMESCOPE_BIN` (Nobara). Computed once so the log line below reflects what we did. + let bind = session_gamescope_bind(&wrapper); + if bind.is_some() { + tracing::info!( + bin = %gamescope_bin(), + "gamescope: binding the patched build over {DISTRO_GAMESCOPE_PATH} inside the session \ + unit — a session script that hardcodes that path (Nobara) gets the patched build \ + instead of the distro's stock one. Nothing outside this unit is affected." + ); + } + // The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a + // mismatch kills every Vulkan client (Steam included) with no error but a black screen. + let wsi_ok = wsi_layer_matches_our_gamescope(); + if !wsi_ok { + 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 (ENABLE_GAMESCOPE_WSI=0). Left \ + enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \ + which shows up as a black screen with no other symptom." + ); + } let start_unit = || -> Result<()> { - let status = Command::new("systemd-run") - .args(["--user", "--collect", &format!("--unit={unit_name}")]) + let mut cmd = Command::new("systemd-run"); + cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]); + if let Some(b) = bind.as_deref() { + cmd.arg(b); + } + if !wsi_ok { + cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0"); + } + let status = cmd // 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 // that would abort gamescope at startup. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 82e7a7df..1a70fb31 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -524,6 +524,22 @@ fn parse_patch_level(banner: &str) -> u32 { .unwrap_or(0) } +/// The upstream `X.Y.Z` a specific gamescope binary reports, or `None` if it cannot be run/parsed. +/// +/// Split from [`check_gamescope_version`] (which only ever probes the RESOLVED binary) because the +/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means +/// "leave the layer alone", not "assume old". +pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> { + let out = Command::new(bin).arg("--version").output().ok()?; + // Same stdout/stderr split as the version gate: builds disagree on where the banner goes. + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + parse_version(&text) +} + /// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks /// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); From 46390739d8aa58cb9fcd80765aa954808748acab Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 20:28:00 +0200 Subject: [PATCH 3/3] fix(pf-vdisplay): the box's OWN session unit needs the gamescope bind too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `launch_session` spawns a transient unit and can hand `systemd-run` the `BindReadOnlyPaths` directly, but a box that owns an autologin `gamescope-session-plus@.service` is RESTARTED IN PLACE instead — no `systemd-run`, so that path kept running Nobara's hardcoded `/usr/bin/gamescope` and the previous commit fixed only half the problem. Found on the box: after a reboot the host took the `ensure_box_gamescope_mode` path (the autologin unit was live) rather than the managed one. Deliver the same two fixes as a drop-in on that unit — the bind, and the WSI opt-out when the box's layer was built for a different gamescope — plus `PF_HZ`/`PF_HDR_ARGS`, which the wrapper reads and would otherwise default to 60 Hz. `daemon-reload` before the restart or systemd runs the old unit. Best-effort: a failure to write it must not block a restart that would otherwise work, and it is a no-op on a box already resolving to `/usr/bin/gamescope`. ⚠ REMOVED on restore, deliberately. Leaving it would put the patched build — and our HDR and cursor flags — under the user's ORDINARY game mode, which is exactly what `packaging/gamescope/README.md`'s "sits BESIDE the distro package" rule exists to prevent. The bind is ours only for as long as we are driving the session. `ensure_box_gamescope_mode` grows an `hdr` param to build those args; both call sites already had it in scope (`self.hdr`, and `create_managed_session`'s parameter). Gate: `scripts/xcheck.sh linux clippy` clean (0 warning/error lines), `cargo fmt` clean. --- .../src/vdisplay/linux/gamescope.rs | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 314ea07e..ed5c210d 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -328,7 +328,7 @@ impl VirtualDisplay for GamescopeDisplay { // client's resolution (the box is headless, so its game-mode mode is ours to set). // Reuse if it already matches (fast, no restart); otherwise relaunch the box's own // session at the client mode. Without this the client gets the box's default mode. - ensure_box_gamescope_mode(mode)? + ensure_box_gamescope_mode(mode, self.hdr)? } else { id.parse() .context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")? @@ -494,7 +494,7 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result std::path::PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/deck".to_string()); + std::path::Path::new(&home) + .join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf") +} + +/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and +/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS` +/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60). +/// +/// A no-op returning `Ok(false)` when there is nothing to redirect, so a box already running our +/// binary keeps a clean unit. +fn write_session_plus_dropin( + wrapper: &std::path::Path, + mode: Mode, + hdr: bool, + wsi_ok: bool, +) -> Result { + if gamescope_bin() == DISTRO_GAMESCOPE_PATH { + return Ok(false); + } + let path = session_plus_dropin_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?; + } + let body = format!( + "[Service]\n\ + BindReadOnlyPaths={wrapper}:{DISTRO_GAMESCOPE_PATH}\n\ + Environment=PF_HZ={hz}\n\ + Environment=\"PF_HDR_ARGS={hdr_args}\"\n\ + {wsi}", + wrapper = wrapper.display(), + hz = game_hz(mode.refresh_hz), + hdr_args = hdr_args(hdr) + .into_iter() + .chain(cursor_args()) + .collect::>() + .join(" "), + wsi = if wsi_ok { + String::new() + } else { + "Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string() + }, + ); + std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?; + Ok(true) +} + +/// Remove the box-session drop-in (restore-on-disconnect). Best-effort, mirroring +/// [`remove_steamos_dropin`]. +fn remove_session_plus_dropin() { + let _ = std::fs::remove_file(session_plus_dropin_path()); +} + /// Take over SteamOS's `gamescope-session.target` headless at the CLIENT's mode: write the shim + a /// drop-in carrying the mode, `daemon-reload`, then RESTART the target so `steam-launcher.service` /// brings Steam up in the fresh headless gamescope — and attach to its node. A same-mode reconnect @@ -1012,7 +1074,7 @@ fn create_managed_session_steamos(mode: Mode, hdr: bool) -> Result Result { +fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result { let target = (mode.width, mode.height); // Fast path: already at the client's resolution — just attach to the live node. if current_gamescope_output_size() == Some(target) { @@ -1084,6 +1146,26 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result { &format!("SCREEN_HEIGHT={}", mode.height), &format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)), ]); + // 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. + match write_gamescope_bin_wrapper() + .and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope())) + { + Ok(true) => { + tracing::info!( + bin = %gamescope_bin(), + %unit, + "gamescope: dropped in a bind over {DISTRO_GAMESCOPE_PATH} for the box's own \ + session unit — a session script that hardcodes that path (Nobara) gets the \ + patched build on this restart too" + ); + systemctl_user(&["daemon-reload"]); + } + Ok(false) => {} + // Best-effort: a box whose session already runs our binary loses nothing, and a failure + // here must not block a restart that would otherwise work. + Err(e) => tracing::warn!(error = %e, "gamescope: could not write the box-session drop-in"), + } systemctl_user(&["restart", &unit]); // Wait for the relaunched session to come up at the new size and publish its capture node. The // node appears when gamescope is up (well before Steam finishes booting); the caller's @@ -2257,6 +2339,12 @@ fn do_restore_tv_session() { } return; } + // Hand the box back its OWN gamescope before restarting its session: our bind drop-in exists + // to serve a punktfunk stream, and leaving it would silently put the patched build (plus our + // HDR/cursor flags) under the user's ordinary game mode — exactly the "sits beside the distro + // package" rule this whole design rests on. + remove_session_plus_dropin(); + systemctl_user(&["daemon-reload"]); for unit in units { let _ = Command::new("systemctl") .args(["--user", "start", &unit])