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/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 53538899..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]) @@ -2413,6 +2501,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 +2593,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); 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"