Nobara could never use the patched gamescope — and the RPM it was told to install was unsigned #144

Merged
enricobuehler merged 3 commits from worktree-gamescope-pin-bump-nobara into main 2026-08-09 18:54:00 +00:00
13 changed files with 296 additions and 41 deletions
+25
View File
@@ -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 }}
@@ -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<Virtual
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
own game-mode session)"
);
let node_id = ensure_box_gamescope_mode(mode)?;
let node_id = ensure_box_gamescope_mode(mode, hdr)?;
point_injector_at_eis();
return Ok(VirtualOutput {
node_id,
@@ -923,6 +923,68 @@ fn remove_steamos_dropin() {
let _ = std::fs::remove_file(steamos_dropin_path());
}
/// Drop-in for the box's OWN autologin `gamescope-session-plus@*.service`.
///
/// The transient-unit path ([`launch_session`]) can pass `BindReadOnlyPaths` straight to
/// `systemd-run`, but a box that owns an autologin session is RESTARTED in place instead — no
/// `systemd-run`, so the bind has to arrive as a drop-in or Nobara's hardcoded
/// `/usr/bin/gamescope` wins there too (see [`DISTRO_GAMESCOPE_PATH`]).
///
/// `zz-` so it sorts last, matching the SteamOS drop-in convention above.
fn session_plus_dropin_path() -> 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<bool> {
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::<Vec<_>>()
.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<VirtualOutput
/// box's own unit (rather than spawning a competing one) avoids the autologin-respawn fight the old
/// MANAGED path hit. A headless box has no physical panel, so its game-mode resolution is ours to set;
/// Steam restarts only on an actual resolution CHANGE.
fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
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<u32> {
&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<std::path::PathBuf> {
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<String> {
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 <client>` 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::<Vec<_>>().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.
@@ -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);
+1 -1
View File
@@ -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.
#
+8 -2
View File
@@ -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"
+11 -4
View File
@@ -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
@@ -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
@@ -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<const struct spa_pod *> build_format_params(struct spa_pod_bu
@@ -166,6 +209,23 @@ static std::vector<const struct spa_pod *> 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<const struct spa_pod *> 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<const struct spa_pod *> 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()
@@ -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<CVulkanTexture> 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<CVulkanTexture>{ 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;
@@ -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<bool> 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" );
@@ -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?= <enrico.buehler@unom.io>
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
+8
View File
@@ -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,
+19 -3
View File
@@ -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"