Merge pull request 'A gamescope session told every game its display was 60 Hz — and Fedora had no way to install the build that knows better' (#120) from worktree-gamescope-virtual-display into main
apple / swift (push) Successful in 1m37s
ci / web (push) Successful in 1m42s
ci / bun-nix (push) Successful in 29s
ci / docs-site (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 5m17s
deb / build-publish-client-arm64 (push) Successful in 1m48s
apple / screenshots (push) Successful in 6m27s
android / android (push) Successful in 9m56s
windows-host / package (push) Successful in 10m52s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m20s
arch / build-publish (push) Successful in 16m0s
windows-host / canary-manifest (push) Failing after 32s
deb / build-publish-host (push) Successful in 12m7s
ci / rust (push) Successful in 17m46s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
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 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m8s
docker / deploy-docs (push) Successful in 33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13m51s
nix / flake (push) Failing after 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s

Reviewed-on: #120
This commit was merged in pull request #120.
This commit is contained in:
2026-08-08 13:41:30 +00:00
16 changed files with 1099 additions and 36 deletions
+40
View File
@@ -320,6 +320,46 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
#
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
- uses: actions/cache@v4
id: gamescope
with:
path: gs-cache
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
- name: Build the patched gamescope
if: steps.gamescope.outputs.cache-hit != 'true'
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
# a half-built result impossible to ship.
run: |
set -x
apt-get update
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
apt-get build-dep -y gamescope || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
else
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
fi
- name: Build punktfunk-gamescope .deb
# Picked up by the publish loop below, which globs dist/*.deb.
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
else
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
fi
- name: Publish to the Gitea apt registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+34
View File
@@ -213,6 +213,40 @@ jobs:
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
# until it existed those users had no packaged route to the patched build at all, and a stock
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
#
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
fi
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
shopt -s nullglob
for rpm in dist/punktfunk-gamescope-*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
echo "uploading $rpm"
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
+50
View File
@@ -260,6 +260,18 @@ pub struct HostConfig {
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope
/// session offers its clients on top of the one it runs at, e.g. `60,90,120`.
///
/// A headless gamescope has no EDID, so it cannot work out what else its display could run at:
/// on a stock build it advertises exactly ONE rate and Steam's in-session display settings show
/// a single entry. Our `+pfhdr3` build takes this list (`--custom-refresh-rates`) and publishes
/// it, which is what puts real choices in that menu. The session's own rate is always included
/// whatever is set here, so this can only ever ADD options.
///
/// Empty (the default) = advertise only the negotiated rate. Ignored on a stock gamescope,
/// which has no flag to take it.
pub gamescope_refresh_rates: Vec<u32>,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -379,6 +391,12 @@ impl HostConfig {
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
// Unparseable entries are DROPPED rather than failing the host: this only ever widens a
// menu, and the session's own rate is added back unconditionally, so the worst a typo
// can cost is the extra option the operator wanted — never the session.
gamescope_refresh_rates: parse_refresh_rates(
val("PUNKTFUNK_GAMESCOPE_REFRESH_RATES").as_deref(),
),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
@@ -397,6 +415,20 @@ impl HostConfig {
}
}
/// `"60, 90,120"` → `[60, 90, 120]`, sorted and deduped. Junk entries and out-of-range rates are
/// skipped rather than rejected wholesale — see the call site for why. Pure + unit-tested.
fn parse_refresh_rates(raw: Option<&str>) -> Vec<u32> {
let mut out: Vec<u32> = raw
.unwrap_or_default()
.split(',')
.filter_map(|s| s.trim().parse::<u32>().ok())
.filter(|&hz| (1..=1000).contains(&hz))
.collect();
out.sort_unstable();
out.dedup();
out
}
impl HostConfig {
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
@@ -446,6 +478,24 @@ mod tests {
assert_eq!(c.game_fps(0), 0);
}
#[test]
fn refresh_rate_list_parses_and_tolerates_junk() {
assert_eq!(parse_refresh_rates(Some("60,90,120")), vec![60, 90, 120]);
// Spaces, unsorted input and duplicates all normalise.
assert_eq!(
parse_refresh_rates(Some(" 120, 60 ,90, 60")),
vec![60, 90, 120]
);
// Unset and empty are the default: advertise only the session's own rate.
assert!(parse_refresh_rates(None).is_empty());
assert!(parse_refresh_rates(Some("")).is_empty());
assert!(parse_refresh_rates(Some(" ")).is_empty());
// A typo costs its own entry, never the whole list — the knob only widens a menu.
assert_eq!(parse_refresh_rates(Some("60,abc,120")), vec![60, 120]);
// Out of range in both directions (0 is not a refresh rate; 1920 is a width).
assert_eq!(parse_refresh_rates(Some("0,60,1920")), vec![60]);
}
#[test]
fn audio_output_mode_parses_its_spellings() {
for (s, want) in [
@@ -27,6 +27,7 @@ mod heads;
mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{
@@ -1153,17 +1154,9 @@ fn gamescope_argvs() -> Vec<Vec<String>> {
/// also the final filter that separates a compositor from anything else [`gamescope_argvs`] let by.
fn current_gamescope_output_size() -> Option<(u32, u32)> {
gamescope_argvs().into_iter().find_map(|args| {
let flag = |names: &[&str]| -> Option<u32> {
args.iter().enumerate().find_map(|(i, a)| {
names
.contains(&a.as_str())
.then(|| args.get(i + 1).and_then(|v| v.parse().ok()))
.flatten()
})
};
match (
flag(&["-W", "--output-width"]),
flag(&["-H", "--output-height"]),
argv_u32(&args, &["-W", "--output-width"]),
argv_u32(&args, &["-H", "--output-height"]),
) {
(Some(w), Some(h)) => Some((w, h)),
_ => None,
@@ -1171,6 +1164,104 @@ fn current_gamescope_output_size() -> Option<(u32, u32)> {
})
}
/// The numeric value following the first of `names` present in `argv`. Pure + unit-tested — it is
/// the shared reader behind both the output-size probe above and the mode verification below.
fn argv_u32(argv: &[String], names: &[&str]) -> Option<u32> {
argv.iter().enumerate().find_map(|(i, a)| {
names
.contains(&a.as_str())
.then(|| argv.get(i + 1).and_then(|v| v.parse().ok()))
.flatten()
})
}
/// Did the MODE we asked an indirectly-spawned session for actually reach its gamescope?
///
/// [`verify_managed_spawn_flags`] answers the same question for the capability flags and REFUSES
/// the session when they are missing, because the retry then resolves a different (correct) plan.
/// The mode has no such recovery: relaunching would hand the session the exact same environment and
/// lose it the same way, so refusing would only loop. It is not silent either, though — and it used
/// to be, in the way that costs the most:
///
/// `--nested-refresh` is the ONLY refresh a headless gamescope has. `CHeadlessBackend::Init`
/// assigns `g_nOutputRefresh = g_nNestedRefresh`, defaulting to **60 Hz** when the flag is absent,
/// and that one number is what the session composites at, what `vblankmanager` paces to, and what
/// Steam and every game are told the display runs at. It reaches a `gamescope-session-plus` only
/// through the `GAMESCOPE_BIN` wrapper — which the session script is free to lose (a `sessions.d`
/// file sourced with `set -a` can reassign `GAMESCOPE_BIN`; one that sets `GAMESCOPECMD` outright
/// skips the whole builder). When that happened the stream still ran, still looked right, and still
/// showed the client's own fps counter at the negotiated rate — because the encode loop repeats the
/// held frame — while the game underneath was capped to 60. Field report 2026-08-08.
///
/// So: warn, name the numbers, and carry on. Same "any running gamescope carrying it" rule as the
/// flag check, and the same silence when `/proc` cannot be read.
fn warn_if_mode_lost(mode: Mode, want_hz: u32) {
let argvs = gamescope_argvs();
let lost = mode_mismatch(mode.width, mode.height, want_hz, &argvs);
if lost.is_empty() {
return;
}
tracing::warn!(
lost = %lost.join(", "),
"gamescope: the session did not start at the mode we asked for — the session script \
dropped GAMESCOPE_BIN / SCREEN_WIDTH / SCREEN_HEIGHT. A headless gamescope reports \
`--nested-refresh` as its ONE refresh rate (60 Hz when the flag never arrives), so games \
and Steam will believe the display runs at that rate however fast the stream is. Install \
punktfunk-gamescope, or check /etc/gamescope-session-plus/sessions.d/ for a file that \
overrides GAMESCOPE_BIN or sets GAMESCOPECMD"
);
}
/// Which parts of the requested mode no running gamescope was started with, as human-readable
/// `asked=…, got=…` fragments. Empty when it matches — or when there is nothing to compare against,
/// which is the same fail-open rule [`missing_flags`] has and for the same reason. Pure +
/// unit-tested.
fn mode_mismatch(want_w: u32, want_h: u32, want_hz: u32, argvs: &[Vec<String>]) -> Vec<String> {
if argvs.is_empty() {
return Vec::new();
}
let mut lost = Vec::new();
let sizes: Vec<(u32, u32)> = argvs
.iter()
.filter_map(|a| {
Some((
argv_u32(a, &["-W", "--output-width"])?,
argv_u32(a, &["-H", "--output-height"])?,
))
})
.collect();
// No gamescope carries an output size at all → we cannot tell ours apart from a nested one;
// stay quiet rather than warn on every box that runs a second gamescope.
if !sizes.is_empty() && !sizes.contains(&(want_w, want_h)) {
lost.push(format!(
"resolution asked={want_w}x{want_h}, got={}",
sizes
.iter()
.map(|(w, h)| format!("{w}x{h}"))
.collect::<Vec<_>>()
.join("/")
));
}
let rates: Vec<u32> = argvs
.iter()
.filter_map(|a| argv_u32(a, &["-r", "--nested-refresh"]))
.collect();
if !rates.contains(&want_hz) {
lost.push(match rates.as_slice() {
// The flag is absent everywhere — the exact shape that silently yields 60 Hz.
[] => format!(
"refresh asked={want_hz}Hz, got=no --nested-refresh at all (gamescope defaults to \
60Hz headless)"
),
got => format!(
"refresh asked={want_hz}Hz, got={}Hz",
got.iter().map(u32::to_string).collect::<Vec<_>>().join("/")
),
});
}
lost
}
/// Did the flags we passed an INDIRECTLY-spawned session actually reach its gamescope?
///
/// The bare spawn builds argv itself and cannot lose them. The two managed modes can: a
@@ -2337,12 +2428,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
let wrapper = write_gamescope_bin_wrapper()?;
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
let hz = mode.refresh_hz.max(1);
// The two rates are deliberately different when the frame limiter is set. CUSTOM_REFRESH_RATES
// generates the mode the session ADVERTISES, which must stay the client's — that is what makes
// games see the real refresh instead of the box's EDID. PF_HZ becomes `--nested-refresh`, the
// rate the game is clamped to, and is the only one the limiter touches. Identical when it's
// unset, which is the default.
// ONE rate reaches gamescope, and it is `--nested-refresh` (via the wrapper's `PF_HZ`). On the
// headless backend that flag IS the output refresh — `CHeadlessBackend::Init` assigns
// `g_nOutputRefresh = g_nNestedRefresh` — so it is simultaneously the rate the session
// composites at, the rate `vblankmanager` paces to, and the rate Steam and every game are told
// the display runs at. When the frame limiter (`PUNKTFUNK_MAX_FPS`) is set they all drop
// together; that is the trade the knob is, and it is off by default.
//
// `CUSTOM_REFRESH_RATES` below does NOT do this, whatever its name suggests: it is the *set* of
// rates the session may offer, and `gamescope-session-plus` gates it on the binary having
// `--custom-refresh-rates`, which no upstream gamescope has ever had. On a stock gamescope it
// is inert (it was a silent no-op for years); on our `+pfhdr3` build it is what puts more than
// one entry in Steam's refresh menu. Either way it cannot fix a wrong `--nested-refresh`.
let game = game_hz(mode.refresh_hz);
// The advertised SET, which always contains the rate we actually run at.
let offered = {
let mut r = pf_host_config::config().gamescope_refresh_rates.clone();
if !r.contains(&hz) {
r.push(hz);
}
r.sort_unstable();
r.dedup();
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
};
let start_unit = || -> Result<()> {
let status = Command::new("systemd-run")
.args(["--user", "--collect", &format!("--unit={unit_name}")])
@@ -2366,7 +2474,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
))
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
.arg("--setenv=DRM_MODE=cvt")
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={offered}"))
.arg("--")
.arg(SESSION_PLUS_BIN)
.arg(client)
@@ -2394,6 +2502,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
stop_session(unit_name);
return Err(e);
}
// Loud, but not fatal — see [`warn_if_mode_lost`] for why this one warns where the
// capability flags above refuse.
warn_if_mode_lost(mode, game);
return Ok(id);
}
if Instant::now() >= deadline {
@@ -2526,7 +2637,15 @@ fn add_bare_gamescope_args(
if grab_cursor {
command.arg("--force-grab-cursor");
}
for arg in hdr_args(hdr).into_iter().chain(cursor_args()) {
// `-r` above is what this headless session will REPORT as its refresh (the headless backend
// assigns `g_nOutputRefresh = g_nNestedRefresh`), so it is already correct here. This adds the
// rest of the SET the in-session UI may offer — the bare spawn passes it directly, with none of
// the session-script indirection the managed path has to route it through.
for arg in hdr_args(hdr)
.into_iter()
.chain(cursor_args())
.chain(refresh_rate_args(hz))
{
command.arg(arg);
}
command.args(["--xwayland-count", "1", "--"]);
@@ -2571,11 +2690,48 @@ fn hdr_args(hdr: bool) -> Vec<String> {
/// host-side (it costs the host a full-frame pass, and on the zero-CSC encode source it cannot be
/// done at all). Empty on a stock gamescope, which is exactly the old behaviour.
fn cursor_args() -> Vec<String> {
let mut args = Vec::new();
if gamescope_can_composite_cursor() {
vec!["--pipewire-composite-cursor".to_string()]
} else {
Vec::new()
args.push("--pipewire-composite-cursor".to_string());
}
// The external overlay (mangoapp — the Deck UI's fps/frametime readout, patch level 4+). Unlike
// the cursor there is no host-side fallback: the host cannot reconstruct another process's
// overlay window, so without this the layer is simply absent from every gamescope stream.
if gamescope_can_composite_external_overlay() {
args.push("--pipewire-composite-external-overlay".to_string());
}
args
}
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
/// HEADLESS session may offer its clients.
///
/// Without it a headless connector advertises exactly one rate, so Steam's in-session display
/// settings show a single entry and a game reads the display as that one number. `session_hz` is
/// always in the list — it is the mode the session actually runs at, and an advertised set that
/// excluded it would be a lie in the other direction.
///
/// The operator can widen the set (`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`) so the in-session
/// UI offers real choices; unset, we advertise the one rate we run at, which is what the client
/// asked for.
fn refresh_rate_args(session_hz: u32) -> Vec<String> {
if !gamescope_can_offer_refresh_rates() {
return Vec::new();
}
let mut rates = pf_host_config::config().gamescope_refresh_rates.clone();
if !rates.contains(&session_hz) {
rates.push(session_hz);
}
rates.sort_unstable();
rates.dedup();
vec![
"--custom-refresh-rates".to_string(),
rates
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(","),
]
}
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
@@ -2717,7 +2873,7 @@ mod tests {
use super::{
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
is_steam_launch, missing_flags, nested_wrapper_script, sentinel_advanced,
is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced,
shape_dedicated_command,
};
@@ -2949,6 +3105,63 @@ mod tests {
assert!(!cgroup_is_punktfunk_owned(""));
}
/// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh
/// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the
/// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 —
/// the exact shape of the 2026-08-08 field report, and invisible without this.
#[test]
fn mode_mismatch_names_what_the_session_actually_got() {
let argv = |s: &str| -> Vec<String> { s.split(' ').map(str::to_string).collect() };
// The good case: our own managed spawn, carrying everything we asked for.
let ok = vec![argv(
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --nested-refresh 120 --steam",
)];
assert!(mode_mismatch(1920, 1080, 120, &ok).is_empty());
// THE field case: the wrapper was dropped, so there is no `--nested-refresh` anywhere and
// gamescope silently ran its 60 Hz default. Size still landed (SCREEN_WIDTH survived).
let lost = vec![argv(
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --steam",
)];
let got = mode_mismatch(1920, 1080, 120, &lost);
assert_eq!(got.len(), 1, "only the refresh is wrong: {got:?}");
assert!(got[0].contains("asked=120Hz"), "{got:?}");
assert!(got[0].contains("no --nested-refresh at all"), "{got:?}");
// A wrong rate is reported with the number it actually got, not just "missing".
let wrong = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh 60")];
let got = mode_mismatch(1920, 1080, 120, &wrong);
assert_eq!(got.len(), 1);
assert!(got[0].contains("got=60Hz"), "{got:?}");
// Resolution lost too (SCREEN_WIDTH/HEIGHT dropped as well) — both are named.
let both = vec![argv("gamescope -W 1280 -H 720")];
assert_eq!(mode_mismatch(1920, 1080, 120, &both).len(), 2);
// Fail OPEN, exactly like `missing_flags`: nothing to compare against says nothing. A box
// with a second gamescope that carries no output size must not produce a false alarm.
assert!(mode_mismatch(1920, 1080, 120, &[]).is_empty());
// ANY running gamescope carrying the mode satisfies it — a Deck commonly runs a nested one
// beside the session, and demanding that every gamescope match would reject a good session.
let two = vec![
argv("gamescope -W 1280 -H 800 --nested-refresh 60"),
argv("gamescope -W 1920 -H 1080 --nested-refresh 120"),
];
assert!(mode_mismatch(1920, 1080, 120, &two).is_empty());
// The long spellings are read too.
let long = vec![argv(
"gamescope --output-width 1920 --output-height 1080 --nested-refresh 120",
)];
assert!(mode_mismatch(1920, 1080, 120, &long).is_empty());
// A flag with no value after it must not panic or read past the end.
let truncated = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh")];
assert_eq!(mode_mismatch(1920, 1080, 120, &truncated).len(), 1);
}
/// The silent-cursor guard: a managed session that ignored `GAMESCOPE_BIN` / the PATH shim runs
/// a stock gamescope, and the host — already told the compositor would paint the pointer —
/// paints none either. Only a compositor we can SEE, missing a flag we can NAME, may fail.
@@ -449,6 +449,34 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
gamescope_patch_level() >= 2 && !flags_lost()
}
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
/// offer (`--custom-refresh-rates`)?
///
/// Below this level a headless gamescope advertises **one** rate — whatever `--nested-refresh`
/// resolved to, or its own 60 Hz default — and no resolution list at all, because its connector
/// returns empty spans from `GetModes()`/`GetValidDynamicRefreshRates()` and reports an INTERNAL
/// screen (which makes `update_mode_atoms` delete the mode-list atom outright). So on a stock
/// gamescope, Steam's in-session display settings show exactly one refresh rate and no
/// resolutions, and games read the display as 60 Hz whatever the client negotiated.
///
/// `gamescope-session-plus` has probed for this flag for years (`CUSTOM_REFRESH_RATES` is gated on
/// `gamescope --help` mentioning it) — upstream simply never had it, so the env var it plumbs was
/// a no-op everywhere.
pub(crate) fn gamescope_can_offer_refresh_rates() -> bool {
gamescope_patch_level() >= 3 && !flags_lost()
}
/// Can the resolved gamescope paint the EXTERNAL OVERLAY — mangoapp, the Deck-UI fps/frametime
/// readout — into its PipeWire node (`--pipewire-composite-external-overlay`)?
///
/// `paint_pipewire` has never referenced that layer on any upstream version, so a client whose
/// only view of the session is the node sees the overlay it just enabled simply not appear.
/// Unlike the cursor there is no host-side substitute: the host cannot reconstruct someone else's
/// overlay window.
pub(crate) fn gamescope_can_composite_external_overlay() -> bool {
gamescope_patch_level() >= 4 && !flags_lost()
}
/// Has a spawn been observed where our flags did NOT reach the gamescope process?
///
/// The binary probe above answers "can it", which is all the bare spawn needs — there we build
+2 -1
View File
@@ -134,7 +134,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. |
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
| `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` | e.g. `60,90,120` *(default: just the session's own rate)* | Extra refresh rates a gamescope session **offers** in its in-session display settings. A headless gamescope has no EDID, so it cannot work out what else the display could run at — without this it advertises exactly one rate and Steam's refresh menu has a single entry. The rate the session actually runs at is always included, so this can only add options. Needs the `punktfunk-gamescope` build (`+pfhdr3`); ignored on a stock gamescope, which has no flag to take it. |
| `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1``4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. |
## Gamepads
+19
View File
@@ -161,6 +161,9 @@ To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch
own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one.
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
Punktfunk repo the host comes from.
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
- **Arch** — the `punktfunk-gamescope` package.
- **SteamOS (Steam Deck installer)** — built and wired automatically by
`scripts/steamdeck/install.sh` / `update.sh`.
@@ -200,6 +203,22 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged
at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this
only bites if you've pinned an old one.
- **On a stock gamescope, Gaming Mode reports the wrong refresh rate — and offers no resolutions.**
A headless gamescope has no EDID, and upstream's headless connector advertises no display modes
and no refresh rates at all. Steam's in-session display settings then show a single refresh entry
and an empty resolution list, and that one entry is whatever the session was launched with — or
**60 Hz** if the launch flag went missing. Games that pace themselves to the display will hold
themselves there, even though the stream is running at your client's full rate (the client's own
fps counter keeps reading correctly, because the encoder repeats held frames — so the counter is
not the thing to trust here; an in-game fps readout is). `punktfunk-gamescope` publishes the real
mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that
menu. If the host log says *"the session did not start at the mode we asked for"*, a file in
`/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`.
- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp,
which gamescope draws as an *external overlay* — a layer upstream's capture composite has never
included on any version, so on a stock gamescope you can turn the overlay on and it simply will
not appear in the stream. There is no host-side substitute: the host cannot reconstruct another
process's overlay window. `punktfunk-gamescope` paints it into the capture stream.
- **The cursor comes from the compositor when it can, and from the host otherwise.** A stock
gamescope leaves the pointer out of its captured image, so the host reads it separately and draws
it into every frame — a full pass over the picture, and the fastest encode source cannot blend at
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Package an already-built punktfunk-gamescope binary as a .deb, for the Gitea apt registry.
#
# Counterpart to ../gamescope/build-gamescope-rpm.sh, and the same argument: the binary is a
# ~10-minute meson build of an unrelated tree that CI does once and caches, so this repacks rather
# than rebuilds. The Arch package (../gamescope/PKGBUILD) is the one recipe that builds from source,
# because that is what makepkg is for.
#
# Installed as /usr/bin/punktfunk-gamescope — it does NOT replace the distro's gamescope, and does
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
#
# Usage:
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
set -euo pipefail
BINARY=""
DEB_ARCH=""
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
PKG="punktfunk-gamescope"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
# The marker is the host's whole capability probe: a binary that lost the patches installs fine and
# then silently streams SDR, cursorless, at a 60 Hz-advertising session. Refuse to package it.
BANNER="$("$BINARY" --version 2>&1 | head -1)"
case "$BANNER" in
*'+pfhdr'*) ;;
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
echo " banner: $BANNER" >&2
exit 1 ;;
esac
# Derive the version from the binary when the caller did not pass one — it is the only source that
# cannot drift from what is actually in the package.
if [ -z "${VERSION:-}" ]; then
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
VERSION="${UPSTREAM}.${PFHDR}"
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# mktemp gives 0700; the package root has to be world-readable or `dpkg-deb -c` shows the tree as
# root-only and some tooling refuses it.
chmod 0755 "$STAGE"
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
mkdir -p "$STAGE/DEBIAN"
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
# the package honest about the Ubuntu release it was compiled on: gamescope links a broad set
# (wlroots, SDL, libliftoff, vulkan, xwayland's libs), and hand-listing them would rot.
DEPS=""
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
# dpkg-shlibdeps insists on running from a package root with a debian/ dir.
mkdir -p "$STAGE/debian"
: > "$STAGE/debian/control"
( cd "$STAGE" && dpkg-shlibdeps -O --ignore-missing-info usr/bin/punktfunk-gamescope 2>/dev/null ) \
> "$STAGE/.shlibdeps" || true
DEPS="$(sed -n 's/^shlibs:Depends=//p' "$STAGE/.shlibdeps" | head -1)"
rm -rf "$STAGE/debian" "$STAGE/.shlibdeps"
fi
[ -n "$DEPS" ] || echo "WARNING: dpkg-shlibdeps produced no Depends — packaging without them" >&2
{
echo "Package: $PKG"
echo "Version: $VERSION"
echo "Architecture: $DEB_ARCH"
echo "Maintainer: unom <packages@unom.io>"
echo "Section: utils"
echo "Priority: optional"
[ -n "$DEPS" ] && echo "Depends: $DEPS"
# Not a hard dependency in either direction: the host works without this binary (SDR,
# host-composited cursor), and someone may want the binary for their own capture consumer.
echo "Recommends: punktfunk-host"
echo "Homepage: https://git.unom.io/unom/punktfunk"
echo "Description: gamescope with punktfunk's PipeWire capture patches"
echo " gamescope built from the upstream revision punktfunk pins, plus the patches in"
echo " packaging/gamescope/patches:"
echo " ."
echo " * 10-bit BT.2020/PQ capture formats, so an HDR game reaches a capture consumer as HDR"
echo " instead of pre-tonemapped SDR."
echo " * --pipewire-composite-cursor: the pointer is painted into the capture stream, so a"
echo " consumer with no cursor of its own gets one and the host stops blending one in."
echo " * A headless session advertises its real mode and refresh rates (and"
echo " --custom-refresh-rates), so Steam and games see the resolution and refresh the stream"
echo " actually runs at instead of an unnamed 60 Hz panel."
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
echo " into the capture stream, so the fps/stats readout is visible remotely."
echo " ."
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
} > "$STAGE/DEBIAN/control"
mkdir -p dist
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
dpkg-deb --build --root-owner-group "$STAGE" "$OUT"
echo "==> wrote $OUT"
echo " banner: $BANNER"
+1 -1
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
pkgver="${_gsver}.pfhdr2"
pkgver="${_gsver}.pfhdr4"
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
+18 -2
View File
@@ -13,7 +13,21 @@ The patches here add the missing half, and nothing else. See
|---|---|---|
| `0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch` | Offer SPA `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to `DRM_FORMAT_XRGB2101010`/`XBGR2101010`, and composite them with `g_ScreenshotColorMgmtLutsHDR` + `EOTF_PQ` | **Yes** — offered against [gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126) |
| `0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch` | `--pipewire-composite-cursor` (off by default): paint the pointer into the capture stream, using the same `MouseCursor::paint` call the scanout composite uses | **Yes** — independently useful to any consumer with no cursor of its own |
| `0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No**ours only, retired when the two above land upstream |
| `0003-headless-advertise-the-virtual-display-s-mode-and-re.patch` | Give `CHeadlessConnector` a real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `GAMESCOPE_SCREEN_TYPE_EXTERNAL` so `update_mode_atoms` publishes the list, and add `--custom-refresh-rates` | **Yes**a headless session that cannot report its own mode is a plain bug |
| `0004-pipewire-optionally-composite-the-external-overlay-i.patch` | `--pipewire-composite-external-overlay` (off by default): paint the external overlay layer (mangoapp — the fps/stats readout) into the capture stream | **Yes** — same shape as the cursor patch, same argument |
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
### Why the headless patch matters
A headless gamescope is how a streaming host gives a game a display: the caller passes the
client's exact mode and expects the session to run at it. It *does* — but it never told anyone.
`CHeadlessConnector` returned an empty span from both `GetModes()` and
`GetValidDynamicRefreshRates()` and reported `GAMESCOPE_SCREEN_TYPE_INTERNAL`, so
`update_mode_atoms()` **deleted** `GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL` (no resolution list) and
`wlserver_send_gamescope_control()` fell through to a **one-entry** refresh list built from
`g_nOutputRefresh` (no refresh list). With `-r` absent that entry is `Init()`'s 60 Hz default, so a
client on a 120 Hz panel was told its display was 60 Hz — and games capped themselves to it. Field
report 2026-08-08: "gamescope only shows 60hz and there's no other option".
### Why the cursor patch matters more than it looks
@@ -40,6 +54,8 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|---|---|
| `+pfhdr1` | 10-bit BT.2020/PQ capture formats |
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
@@ -142,7 +158,7 @@ Note what is NOT in that table: the `.deb`. Debian/Ubuntu boxes build it by hand
## Verifying the patch on a box (P0 exit)
```sh
punktfunk-gamescope --version # must contain +pfhdr2
punktfunk-gamescope --version # must contain +pfhdr4
punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \
--hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube &
pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Package an already-built punktfunk-gamescope binary as an RPM, for the Gitea RPM registry.
#
# WHY this exists: before it, the only ways to get punktfunk-gamescope were the Bazzite/Atomic
# sysext, the Arch package, the SteamOS installer, a NixOS option — or building gamescope from
# source yourself. A traditional Fedora-family box (Nobara, plain Fedora, Nobara-derived HTPCs)
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
# a session that told every game the display was 60 Hz.
#
# The binary is NOT built here; CI builds it once per Fedora major and caches it
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
# rebuilding.
#
# Usage:
# bash packaging/gamescope/build-gamescope-rpm.sh \
# --binary gs-cache/punktfunk-gamescope \
# [--version 3.16.25] [--release 1] [--outdir dist]
#
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
set -euo pipefail
BINARY=""
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
VERSION=""
RELEASE="1"
OUTDIR="dist"
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
# Derive the version from the binary itself when not told: it is the only source that cannot drift
# from what is actually being packaged. `gamescope version 3.16.25-1-g8c676c3+pfhdr4 (gcc …)` →
# `3.16.25` + the marker. RPM versions may not contain `-`, hence the trailing `.pfhdrN` form.
BANNER="$("$BINARY" --version 2>&1 | head -1)"
case "$BANNER" in
*'+pfhdr'*) ;;
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
echo " banner: $BANNER" >&2
exit 1 ;;
esac
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
if [ -z "$VERSION" ]; then
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
VERSION="${UPSTREAM}.${PFHDR}"
fi
echo "==> packaging $BINARY as punktfunk-gamescope-${VERSION}-${RELEASE}"
echo " banner: $BANNER"
TOP="$(mktemp -d)"
trap 'rm -rf "$TOP"' EXIT
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
mkdir -p "$OUTDIR"
rpmbuild \
--define "_topdir $TOP" \
--define "pf_version $VERSION" \
--define "pf_release $RELEASE" \
-bb packaging/gamescope/punktfunk-gamescope.spec
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} "$OUTDIR/" \;
echo "==> wrote $(find "$OUTDIR" -name 'punktfunk-gamescope-*.rpm' -newer "$TOP" -print -quit 2>/dev/null || echo "$OUTDIR"/punktfunk-gamescope-*.rpm)"
@@ -0,0 +1,247 @@
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 13:13:09 +0000
Subject: [PATCH] headless: advertise the virtual display's mode and refresh
rates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A headless gamescope is how a remote-desktop/streaming host gives a game a
display: the caller passes the client's exact mode with -W/-H/-r and expects
the session to run at it. It does composite at that rate — but it never told
anyone. CHeadlessConnector returned an empty span from both GetModes() and
GetValidDynamicRefreshRates(), and reported GAMESCOPE_SCREEN_TYPE_INTERNAL.
Both halves of that are visible to clients:
* update_mode_atoms() takes the INTERNAL branch, which DELETES
GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL — so there is no resolution list.
* wlserver_send_gamescope_control() finds no valid dynamic refresh rates and
falls through to a one-entry list built from g_nOutputRefresh — so there is
no refresh list either, just whatever the session happens to run at. With
-r absent that is the 60 Hz default from Init(), and a client on a 120 Hz
panel is told its display is 60 Hz and caps itself accordingly.
Populate both from the mode Init() has already resolved, and report EXTERNAL:
a virtual display is not a built-in panel, and INTERNAL is what suppressed the
mode list in the first place. GetConnector() follows so a lookup by type cannot
contradict the connector's own answer.
--custom-refresh-rates lists the rates the display may switch between, for a
backend that has no EDID to derive them from (gamescope-session-plus already
passes this env through, gated on the flag existing). The running rate is always
included, so the advertised set can never exclude the mode in use.
Only the headless backend changes; every other backend derives its modes from a
real connector and is untouched.
---
src/Backends/HeadlessBackend.cpp | 66 ++++++++++++++++++++++++++++++--
src/main.cpp | 41 ++++++++++++++++++++
2 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/src/Backends/HeadlessBackend.cpp b/src/Backends/HeadlessBackend.cpp
index 8e45400..3168c88 100644
--- a/src/Backends/HeadlessBackend.cpp
+++ b/src/Backends/HeadlessBackend.cpp
@@ -3,8 +3,13 @@
#include "wlserver.hpp"
#include "refresh_rate.h"
+#include <algorithm>
+#include <vector>
+
extern int g_nPreferredOutputWidth;
extern int g_nPreferredOutputHeight;
+// `--custom-refresh-rates` (main.cpp): the rates this virtual display may switch between.
+extern std::vector<uint32_t> g_customRefreshRates;
namespace gamescope
{
@@ -18,9 +23,16 @@ namespace gamescope
{
}
+ // A headless gamescope IS a virtual display: its mode is whatever the caller asked
+ // for (`-W`/`-H`/`-r`), never a fixed built-in panel. Reporting INTERNAL made
+ // `update_mode_atoms` DELETE GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL and set the
+ // GAMESCOPE_CONTROL_DISPLAY_FLAG_INTERNAL_DISPLAY flag, so a client driving this
+ // session was offered no resolutions at all — and, with the empty rate list below,
+ // no refresh rates either. EXTERNAL is both the honest answer and the one that lets
+ // the mode list reach Steam.
virtual gamescope::GamescopeScreenType GetScreenType() const override
{
- return GAMESCOPE_SCREEN_TYPE_INTERNAL;
+ return GAMESCOPE_SCREEN_TYPE_EXTERNAL;
}
virtual GamescopePanelOrientation GetCurrentOrientation() const override
{
@@ -44,7 +56,7 @@ namespace gamescope
}
virtual std::span<const BackendMode> GetModes() const override
{
- return std::span<const BackendMode>{};
+ return m_Modes;
}
virtual bool SupportsVRR() const override
@@ -58,7 +70,7 @@ namespace gamescope
}
virtual std::span<const uint32_t> GetValidDynamicRefreshRates() const override
{
- return std::span<const uint32_t>{};
+ return m_ValidDynamicRefreshRates;
}
virtual void GetNativeColorimetry(
@@ -90,8 +102,42 @@ namespace gamescope
return 0;
}
+ // Publish the mode this session was actually started with, plus every refresh rate it
+ // is allowed to switch between. Called once from CHeadlessBackend::Init(), after the
+ // -W/-H/-r defaults are resolved, because that is the first point at which the answer
+ // exists — and it has to exist before the first `gamescope_control` bind, which is
+ // what hands Steam the list.
+ void SetVirtualMode( uint32_t uWidth, uint32_t uHeight, uint32_t uRefreshHz,
+ std::span<const uint32_t> uOfferRatesHz )
+ {
+ m_ValidDynamicRefreshRates.clear();
+ m_Modes.clear();
+
+ auto AddRate = [ this ]( uint32_t uRate )
+ {
+ if ( !uRate )
+ return;
+ if ( std::find( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end(), uRate )
+ == m_ValidDynamicRefreshRates.end() )
+ m_ValidDynamicRefreshRates.push_back( uRate );
+ };
+
+ for ( uint32_t uRate : uOfferRatesHz )
+ AddRate( uRate );
+ // The rate we are running at is always offerable, whatever the caller listed —
+ // otherwise Steam is handed a set that excludes the mode it is looking at.
+ AddRate( uRefreshHz );
+
+ std::sort( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end() );
+
+ for ( uint32_t uRate : m_ValidDynamicRefreshRates )
+ m_Modes.push_back( BackendMode{ uWidth, uHeight, uRate } );
+ }
+
private:
BackendConnectorHDRInfo m_HDRInfo{};
+ std::vector<BackendMode> m_Modes;
+ std::vector<uint32_t> m_ValidDynamicRefreshRates;
};
class CHeadlessBackend final : public CBaseBackend
@@ -125,6 +171,16 @@ namespace gamescope
if ( g_nOutputRefresh == 0 )
g_nOutputRefresh = ConvertHztomHz( 60 );
+ // Hand the connector the resolved mode. Until this existed the headless connector
+ // advertised NOTHING — no modes, no dynamic refresh rates — so `wlserver`'s
+ // `active_display_info` fell through to a one-entry list built from g_nOutputRefresh
+ // and every client concluded the display was a 60 Hz panel it could not change.
+ m_Connector.SetVirtualMode(
+ uint32_t( g_nOutputWidth ),
+ uint32_t( g_nOutputHeight ),
+ ConvertmHzToHz( uint32_t( g_nOutputRefresh ) ),
+ g_customRefreshRates );
+
if ( !vulkan_init( vulkan_get_instance(), VK_NULL_HANDLE ) )
{
return false;
@@ -200,7 +256,9 @@ namespace gamescope
}
virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override
{
- if ( eScreenType == GAMESCOPE_SCREEN_TYPE_INTERNAL )
+ // Must agree with CHeadlessConnector::GetScreenType() — a lookup by type that
+ // contradicted the connector's own answer would hand callers the wrong screen.
+ if ( eScreenType == GAMESCOPE_SCREEN_TYPE_EXTERNAL )
return &m_Connector;
return nullptr;
diff --git a/src/main.cpp b/src/main.cpp
index 1eb35b3..2c4fb50 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -92,6 +92,7 @@ const struct option *gamescope_options = (struct option[]){
{ "prefer-output", required_argument, nullptr, 'O' },
{ "default-touch-mode", required_argument, nullptr, 0 },
{ "generate-drm-mode", required_argument, nullptr, 0 },
+ { "custom-refresh-rates", required_argument, nullptr, 0 },
{ "immediate-flips", no_argument, nullptr, 0 },
{ "framerate-limit", required_argument, nullptr, 0 },
@@ -232,6 +233,7 @@ const char usage[] =
" -O, --prefer-output list of connectors in order of preference (ex: DP-1,DP-2,DP-3,HDMI-A-1)\n"
" --default-touch-mode 0: hover, 1: left, 2: right, 3: middle, 4: passthrough\n"
" --generate-drm-mode DRM mode generation algorithm (cvt, fixed)\n"
+ " --custom-refresh-rates comma-separated refresh rates (Hz) this display may switch between, eg. 60,90,120 (headless only)\n"
" --immediate-flips Enable immediate flips, may result in tearing\n"
"\n"
#if HAVE_OPENVR
@@ -297,6 +299,10 @@ int g_nNestedHeight = 0;
int g_nNestedRefresh = 0;
int g_nNestedUnfocusedRefresh = 0;
int g_nNestedDisplayIndex = 0;
+// `--custom-refresh-rates`, in Hz. Consumed by the headless backend, which has no EDID to
+// derive a mode list from and so cannot answer "what else could this display run at" on its
+// own. Empty = offer only the rate the session was started at.
+std::vector<uint32_t> g_customRefreshRates;
uint32_t g_nOutputWidth = 0;
uint32_t g_nOutputHeight = 0;
@@ -447,6 +453,39 @@ static enum gamescope::GamescopeBackend parse_backend_name(const char *str)
}
}
+// `--custom-refresh-rates 60,90,120` -> { 60, 90, 120 }. Anything unparseable is a hard error,
+// exactly like every other option value: silently dropping a rate would leave a display
+// advertising a list the caller never asked for, which is worse than not starting.
+static std::vector<uint32_t> parse_refresh_rate_list(const char *str)
+{
+ std::vector<uint32_t> rates;
+ std::string_view svRest{ str };
+ while ( !svRest.empty() )
+ {
+ const size_t nComma = svRest.find( ',' );
+ std::string_view svTok = svRest.substr( 0, nComma );
+ svRest = nComma == std::string_view::npos ? std::string_view{} : svRest.substr( nComma + 1 );
+
+ // `Parse` is `std::from_chars`, which rejects leading blanks outright — trim so that a
+ // perfectly ordinary "60, 90, 120" is not an error.
+ while ( !svTok.empty() && svTok.front() == ' ' )
+ svTok.remove_prefix( 1 );
+ while ( !svTok.empty() && svTok.back() == ' ' )
+ svTok.remove_suffix( 1 );
+
+ std::optional<uint32_t> oRate = gamescope::Parse<uint32_t>( svTok );
+ // 1000 Hz is not a limit anyone will meet; it is there so a typo'd "1920" cannot become a
+ // refresh rate that every consumer then has to sanity-check for us.
+ if ( !oRate || *oRate == 0 || *oRate > 1000 )
+ {
+ fprintf( stderr, "gamescope: invalid value for --custom-refresh-rates: %s\n", str );
+ exit( 1 );
+ }
+ rates.push_back( *oRate );
+ }
+ return rates;
+}
+
static int parse_integer(const char *str, const char *optionName)
{
auto result = gamescope::Parse<int>(str);
@@ -800,6 +839,8 @@ int main(int argc, char **argv)
gamescope::cv_touch_click_mode = (gamescope::TouchClickMode) parse_integer( optarg, opt_name );
} else if (strcmp(opt_name, "generate-drm-mode") == 0) {
g_eGamescopeModeGeneration = parse_gamescope_mode_generation( optarg );
+ } else if (strcmp(opt_name, "custom-refresh-rates") == 0) {
+ g_customRefreshRates = parse_refresh_rate_list( optarg );
} else if (strcmp(opt_name, "force-orientation") == 0) {
g_DesiredInternalOrientation = force_orientation( optarg );
} else if (strcmp(opt_name, "sharpness") == 0 ||
@@ -0,0 +1,142 @@
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 13:17:14 +0000
Subject: [PATCH] pipewire: optionally composite the external overlay into the
capture stream
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
paint_pipewire() is a separate, reduced composite from the scanout one. It
paints the focus window, the override window and — since 3.16.23 — the Steam
overlay, but it has never referenced externalOverlayWindow. That layer is
mangoapp: the fps / frametime / stats readout the Deck UI turns on.
On a real handheld the omission is invisible, because the person who enabled
the overlay is looking at the panel it is drawn on. For a consumer whose ONLY
view of the session is this node — a remote-desktop or streaming host — the
overlay simply does not exist: the user turns it on, sees nothing, and there is
nothing they can configure to change that.
Add --pipewire-composite-external-overlay, off by default for the same reason
--pipewire-composite-cursor is: the node has never carried this layer, and a
consumer showing the stream to the same person already looking at the screen
would get two of them.
Two details worth naming:
- The overlay's commit id joins the repaint test. Its numbers change every
frame precisely while the picture behind them is static, which is exactly
the case the existing focus/override-only test skips — without this the
stream would show a frozen overlay.
- It is painted WITHOUT NoScale, unlike paint_all. There the overlay is
already sized to the output; here currentOutputWidth/Height are the capture
size, so a stream captured at another resolution needs it scaled to match.
Notifications are deliberately left out: unlike a performance overlay, they are
not something the viewer asked to see on that screen.
---
src/main.cpp | 3 +++
src/steamcompmgr.cpp | 39 +++++++++++++++++++++++++++++++++++++++
2 files changed, 42 insertions(+)
diff --git a/src/main.cpp b/src/main.cpp
index 2c4fb50..b406caf 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -143,6 +143,7 @@ const struct option *gamescope_options = (struct option[]){
{ "disable-color-management", no_argument, nullptr, 0 },
{ "sdr-gamut-wideness", required_argument, nullptr, 0 },
{ "pipewire-composite-cursor", no_argument, nullptr, 0 },
+ { "pipewire-composite-external-overlay", no_argument, nullptr, 0 },
{ "hdr-enabled", no_argument, nullptr, 0 },
{ "hdr-sdr-content-nits", required_argument, nullptr, 0 },
{ "hdr-itm-enabled", no_argument, nullptr, 0 },
@@ -208,6 +209,8 @@ const char usage[] =
" --cursor-scale-height if specified, sets a base output height to linearly scale the cursor against.\n"
" --virtual-connector-strategy Specifies how we should make virtual connectors.\n"
" --pipewire-composite-cursor composite the cursor into the PipeWire capture stream (off by default: the node has never carried it, and a consumer that draws its own would get two)\n"
+ " --pipewire-composite-external-overlay\n"
+ " composite the external overlay layer (mangoapp) into the PipeWire capture stream (off by default, like the cursor)\n"
" --hdr-enabled enable HDR output (needs Gamescope WSI layer enabled for support from clients)\n"
" 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
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
"default: the node has never carried the pointer, and a consumer that draws its own would get "
"two." };
+gamescope::ConVar<bool> cv_pipewire_composite_external_overlay{ "pipewire_composite_external_overlay", false,
+ "Composite the external overlay layer (mangoapp — the performance overlay) into the PipeWire "
+ "capture stream (--pipewire-composite-external-overlay). Off by default, like the cursor: the "
+ "node has never carried it, and a consumer showing the stream to the same person already "
+ "looking at the screen would get two." };
+
static void paint_pipewire()
{
static struct pipewire_buffer *s_pPipewireBuffer = nullptr;
@@ -2440,14 +2446,31 @@ static void paint_pipewire()
}
}
+ // The external overlay — mangoapp, i.e. the fps/frametime/stats readout. `paint_all` draws
+ // it on the scanout composite; this reduced one never referenced it, so a consumer whose
+ // ONLY view of the session is this node could not see the overlay the user had turned on.
+ // Gated on the focus-appid for the same reason the Steam overlay above is: a consumer that
+ // asked for one specific app is asking for that app, not for the session's chrome.
+ static uint64_t s_ulLastExternalOverlayCommitId = 0;
+ steamcompmgr_win_t *pExternalOverlay = nullptr;
+ if ( cv_pipewire_composite_external_overlay && !ulFocusAppId &&
+ pFocus->externalOverlayWindow && pFocus->externalOverlayWindow->opacity )
+ pExternalOverlay = pFocus->externalOverlayWindow;
+ // Its commit id has to join the repaint test below, or the overlay would freeze at whatever
+ // it read when the game last presented — the numbers on it change every frame precisely
+ // WHILE the picture behind them is static, which is the case the test would otherwise skip.
+ const uint64_t ulExternalOverlayCommitId = window_last_done_commit_id( pExternalOverlay );
+
if ( ulFocusCommitId == s_ulLastFocusCommitId &&
ulOverrideCommitId == s_ulLastOverrideCommitId &&
+ ulExternalOverlayCommitId == s_ulLastExternalOverlayCommitId &&
bDrawCursor == s_bLastCursorDrawn &&
nCursorX == s_nLastCursorX && nCursorY == s_nLastCursorY )
return;
s_ulLastFocusCommitId = ulFocusCommitId;
s_ulLastOverrideCommitId = ulOverrideCommitId;
+ s_ulLastExternalOverlayCommitId = ulExternalOverlayCommitId;
s_bLastCursorDrawn = bDrawCursor;
s_nLastCursorX = nCursorX;
s_nLastCursorY = nCursorY;
@@ -2475,6 +2498,16 @@ static void paint_pipewire()
( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
}
+ // Deliberately WITHOUT `NoScale`, which is what the scanout composite uses: there the
+ // overlay is already sized to the output, while here `currentOutputWidth/Height` are the
+ // capture size, and a stream captured at anything other than the session's own resolution
+ // would otherwise get the overlay at the wrong size in the corner.
+ if ( pExternalOverlay )
+ {
+ paint_window( pExternalOverlay, pExternalOverlay, &frameInfo, nullptr, PaintWindowFlag::NoFilter |
+ ( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
+ }
+
// 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)
cv_pipewire_composite_cursor = true;
#else
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
+#endif
+ } else if (strcmp(opt_name, "pipewire-composite-external-overlay") == 0) {
+#if HAVE_PIPEWIRE
+ cv_pipewire_composite_external_overlay = true;
+#else
+ fprintf( stderr, "gamescope: --pipewire-composite-external-overlay ignored (built without PipeWire)\n" );
#endif
} else if (strcmp(opt_name, "hdr-enabled") == 0 || strcmp(opt_name, "hdr-enable") == 0) {
cv_hdr_enabled = true;
@@ -1,6 +1,6 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
Date: Tue, 28 Jul 2026 15:42:01 +0200
Date: Sat, 8 Aug 2026 13:17:37 +0000
Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
@@ -8,25 +8,27 @@ Content-Transfer-Encoding: 8bit
punktfunk decides a session's shape before the virtual display exists — the
bit depth in the Welcome (irrevocable; a PQ stream on an 8-bit encoder is a
hard error), and whether it must composite the cursor host-side before the
encoder is even opened. Both answers therefore have to be static properties of
the resolved binary rather than something negotiated later.
hard error), whether it must composite the cursor host-side before the encoder
is even opened, and which flags the spawn has to carry. All of those have to be
static properties of the resolved binary rather than something negotiated later.
The number is a monotonic patch-set revision, so one probe answers both:
The number is a monotonic patch-set revision, so one probe answers all of them:
+pfhdr1 10-bit BT.2020/PQ capture formats
+pfhdr2 …and --pipewire-composite-cursor
+pfhdr3 …and the headless connector advertises its mode + --custom-refresh-rates
+pfhdr4 …and --pipewire-composite-external-overlay
NOT for upstream: drop this once the functional patches land there and plain
version floors answer the same questions.
---
src/meson.build | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
src/meson.build | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/meson.build b/src/meson.build
index 662f752..af48d01 100644
index 662f752..12fd38a 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -177,7 +177,12 @@ compiler_version = cc.version()
@@ -177,7 +177,14 @@ compiler_version = cc.version()
vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+']
vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
@@ -36,7 +38,9 @@ index 662f752..af48d01 100644
+# punktfunk/1 Welcome is irrevocable). The number is a monotonic PATCH-SET revision:
+# +pfhdr1 — 10-bit BT.2020/PQ capture formats on the PipeWire node
+# +pfhdr2 — …and `--pipewire-composite-cursor`
+version_tag = vcs_tag + '+pfhdr2' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
+# +pfhdr4 — …and `--pipewire-composite-external-overlay`
+version_tag = vcs_tag + '+pfhdr4' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
@@ -0,0 +1,78 @@
# punktfunk-gamescope — gamescope carrying punktfunk's capture patches, installed under its own
# name so it sits BESIDE the distro's gamescope (Game Mode keeps using that one).
#
# This spec REPACKS a binary that was already built, rather than building gamescope itself: the
# build is a ~10-minute meson compile of an unrelated tree that CI already does once per Fedora
# major and caches (.gitea/workflows/rpm.yml). Rebuilding it inside rpmbuild would double that for
# no gain, and would need network access in the build root to fetch the upstream tree.
#
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
#
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
Name: punktfunk-gamescope
Version: %{pf_version}
Release: %{pf_release}%{?dist}
Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor, overlay, virtual-display modes)
# gamescope is BSD-2-Clause; our patches are contributed under the same terms.
License: BSD-2-Clause
URL: https://git.unom.io/unom/punktfunk
Source0: punktfunk-gamescope
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
# sessions punktfunk-host starts itself resolve this one (PUNKTFUNK_GAMESCOPE_BIN >
# punktfunk-gamescope > gamescope).
#
# The runtime library Requires are auto-generated by rpmbuild from the binary's ELF NEEDED entries,
# which is exactly right here: this binary is soname-coupled to the Fedora major it was compiled
# on, and the generated Requires are what stop it installing on the wrong one.
Recommends: punktfunk-host
# Nothing is compiled here, so there is no debuginfo to extract; without this rpmbuild fails
# looking for sources it was never given.
%global debug_package %{nil}
%description
gamescope built from the upstream revision punktfunk pins, plus the patches in
packaging/gamescope/patches:
* 10-bit BT.2020/PQ capture formats on the PipeWire node, so an HDR game reaches a capture
consumer as HDR instead of pre-tonemapped SDR.
* --pipewire-composite-cursor: paint the pointer into the capture stream, so a consumer with no
cursor of its own gets one and the host stops blending one in, which frees the encoder's
fastest zero-copy source.
* A headless session advertises its real mode and refresh rates (and --custom-refresh-rates), so
Steam and games see the resolution and refresh the stream actually runs at instead of an
unnamed 60 Hz panel.
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
stream, so the fps/stats readout is visible to someone watching remotely.
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
%prep
# Nothing to unpack: Source0 IS the binary.
%build
# Nothing to build — see the header.
%install
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
%check
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
#
# Executed in the build root, which is the same container the binary was compiled in — if that ever
# stops being true this check is the thing that notices.
%{buildroot}%{_bindir}/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' || {
echo "punktfunk-gamescope: the +pfhdr marker is missing the patches did not take" >&2
exit 1
}
%files
%{_bindir}/punktfunk-gamescope
%changelog
# Generated per build; see the git history for the patch set's own changes.
+1 -1
View File
@@ -63,7 +63,7 @@ unwrapped.overrideAttrs (old: {
);
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
# banner would read `+pfhdr2 (gcc …)` with no version at all — which the host's diagnostic
# banner would read `+pfhdrN (gcc …)` with no version at all — which the host's diagnostic
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
# Substituting the real version in keeps `--version` honest AND keeps our marker.
postPatch = (old.postPatch or "") + ''