Merge pull request 'Nobara could never use the patched gamescope — and the RPM it was told to install was unsigned' (#144) from worktree-gamescope-pin-bump-nobara into main
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Failing after 2m37s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 2m27s
deb / build-publish-client-arm64 (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish (push) Successful in 3m37s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / screenshots (push) Successful in 5m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / deploy-docs (push) Skipped
arch / build-publish (push) Successful in 9m55s
deb / build-publish-host (push) Successful in 6m53s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 11m23s
flatpak / build-publish (push) Successful in 9m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
windows-host / package (push) Successful in 18m53s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
nix / flake (push) Failing after 17m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m59s
android / android (push) Failing after 2m37s
ci / bun-nix (push) Successful in 17s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 2m27s
deb / build-publish-client-arm64 (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish (push) Successful in 3m37s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
apple / screenshots (push) Successful in 5m43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 41s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
docker / deploy-docs (push) Skipped
arch / build-publish (push) Successful in 9m55s
deb / build-publish-host (push) Successful in 6m53s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 11m23s
flatpak / build-publish (push) Successful in 9m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 8m57s
windows-host / package (push) Successful in 18m53s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
nix / flake (push) Failing after 17m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m26s
Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-10
@@ -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()
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+2
-2
@@ -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" );
|
||||
|
||||
+7
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user