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
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:
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+113
@@ -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"
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+78
@@ -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)"
|
||||
+247
@@ -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 ||
|
||||
+142
@@ -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;
|
||||
+14
-10
@@ -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.
|
||||
@@ -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 "") + ''
|
||||
|
||||
Reference in New Issue
Block a user