From fd648aa776d7b0844417d9a2d84a869fb3897743 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 15:47:31 +0200 Subject: [PATCH] feat(gamescope): put the cursor in the node, so a session can be zero-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout, and `paint_pipewire()` composites a separate, reduced frame that never includes it. So punktfunk has always reconstructed it from XFixes and blended it in host-side. That blend is what has been blocking the zero-CSC encode path, and the cost is larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC) source for any session with `cursor_blend`, because that front end is fixed-function and has no blend stage. `cursor_blend_for` sets it unconditionally for gamescope. Net effect: a gamescope session paid a full-frame colour-conversion pass per frame, forever, for a pointer. So put the cursor where it belongs. A second carried gamescope patch adds `--pipewire-composite-cursor` (off by default — the node has never carried it, and a consumer that draws its own would get two), painting it with the same `MouseCursor::paint` call the scanout composite uses. It scales for free: paint_pipewire has already set `currentOutputWidth/Height` to the capture size, which is what that function scales against. The repaint test grows the cursor's state beside the commit ids — a pointer-only move produces no commit, so without it the composited cursor would freeze on a static screen while the real one moved, and a cursor that became hidden would never be erased. Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe answers every capability the session must know before it is planned (level 1 = HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the `gamescope_cursor` resolver both consult it through one helper, because they have to agree: the reader without the blend is a wasted X11 connection, the blend without the reader is a stream with no pointer, and both together with a gamescope that paints its own would draw two. ⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this shares a dependency with the HDR flags: a session that ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails loudly there (negotiation timeout + SDR latch); a missing cursor would be silent. Noted in packaging/gamescope/README.md as worth a post-spawn `/proc//cmdline` check if it ever bites. --- crates/pf-vdisplay/src/lib.rs | 33 +++- .../src/vdisplay/linux/gamescope.rs | 39 ++++- .../src/vdisplay/linux/gamescope/discovery.rs | 113 +++++++++--- crates/punktfunk-host/src/native/stream.rs | 17 +- crates/punktfunk-host/src/session_plan.rs | 50 +++++- docs/releases/v0.21.0.md | 1 + packaging/gamescope/README.md | 46 +++-- ...lly-composite-the-cursor-into-the-ca.patch | 163 ++++++++++++++++++ ...stamp-the-version-banner-with-pfhdr1.patch | 37 ---- ...stamp-the-version-banner-with-pfhdrN.patch | 42 +++++ 10 files changed, 453 insertions(+), 88 deletions(-) create mode 100644 packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch delete mode 100644 packaging/gamescope/patches/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch create mode 100644 packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 95b82459..ff0d9557 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -534,10 +534,41 @@ pub fn gamescope_splash_client() -> anyhow::Result<()> { /// /// Always `false` off Linux. pub fn gamescope_hdr_available() -> bool { + gamescope_ours_and( + #[cfg(target_os = "linux")] + gamescope::gamescope_hdr_capable, + ) +} + +/// Will a gamescope session paint the pointer INTO its PipeWire node, so the host does NOT have to +/// reconstruct it from XFixes and blend it into every frame? +/// +/// That blend is not free: it forces the encode path onto its compute colour-conversion arm, +/// because the zero-copy RGB-direct source hands the captured buffer to a fixed-function front end +/// with no blend stage. So a `true` here is what lets a gamescope session skip a full-frame pass +/// per frame — and, like the HDR answer, it must be settled BEFORE the session is planned +/// (`SessionPlan::cursor_blend` feeds the encoder open). +/// +/// Same two terms as [`gamescope_hdr_available`], for the same reasons: the resolved binary +/// carries the patch, and this host is the one spawning it. +pub fn gamescope_composites_cursor() -> bool { + gamescope_ours_and( + #[cfg(target_os = "linux")] + gamescope::gamescope_can_composite_cursor, + ) +} + +/// The shared half of the two capability answers above: the session must be one this host +/// **spawns** (an attach inherits whatever flags someone else's session was started with, so it +/// can promise nothing), and then `probe` — which asks the resolved binary — must agree. +/// +/// A host-managed `gamescope-session-plus` / SteamOS session counts as a spawn: we own its +/// `GAMESCOPE_BIN` wrapper (or PATH shim), so the flags are ours. +fn gamescope_ours_and(#[cfg(target_os = "linux")] probe: fn() -> bool) -> bool { #[cfg(target_os = "linux")] { let attaching = with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some()); - !attaching && gamescope::gamescope_hdr_capable() + !attaching && probe() } #[cfg(not(target_os = "linux"))] false diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 4e2ac10a..805159d5 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -28,8 +28,8 @@ use discovery::{ gamescope_node_present, poll_managed_node, wait_for_node, }; pub(crate) use discovery::{ - game_session_exited, gamescope_hdr_capable, is_available, steam_appid_from_launch, - wait_for_steam_game_exit, SteamGameWatch, + game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available, + steam_appid_from_launch, wait_for_steam_game_exit, SteamGameWatch, }; pub(crate) use splash::run as splash_run; @@ -869,7 +869,11 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re hz = mode.refresh_hz.max(1), // Read (unquoted) by the PATH shim — empty for an SDR session. Quoted HERE because a // systemd `Environment=` value with spaces must be, or only the first flag survives. - hdr_args = hdr_args(hdr).join(" "), + hdr_args = hdr_args(hdr) + .into_iter() + .chain(cursor_args()) + .collect::>() + .join(" "), ); std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display())) } @@ -2068,8 +2072,16 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul .arg(format!("--setenv=SCREEN_WIDTH={}", mode.width)) .arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height)) .arg(format!("--setenv=PF_HZ={hz}")) - // Read (unquoted) by the GAMESCOPE_BIN wrapper — empty for an SDR session. - .arg(format!("--setenv=PF_HDR_ARGS={}", hdr_args(hdr).join(" "))) + // Read (unquoted) by the GAMESCOPE_BIN wrapper — empty for a stock-gamescope SDR + // session, and carrying the cursor flag whenever the binary supports it. + .arg(format!( + "--setenv=PF_HDR_ARGS={}", + hdr_args(hdr) + .into_iter() + .chain(cursor_args()) + .collect::>() + .join(" ") + )) .arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display())) .arg("--setenv=DRM_MODE=cvt") .arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}")) @@ -2214,7 +2226,7 @@ fn add_bare_gamescope_args( if grab_cursor { command.arg("--force-grab-cursor"); } - for arg in hdr_args(hdr) { + for arg in hdr_args(hdr).into_iter().chain(cursor_args()) { command.arg(arg); } command.args(["--xwayland-count", "1", "--"]); @@ -2251,6 +2263,21 @@ fn hdr_args(hdr: bool) -> Vec { args } +/// `--pipewire-composite-cursor` when the resolved gamescope has it (patch level 2+). Paired with +/// [`crate::gamescope_composites_cursor`], which is what tells the host to STOP compositing the +/// pointer itself — the two must agree, so both read the same probe. +/// +/// Passed on every session, HDR or not: a cursor in the node is strictly better than one blended +/// 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 { + if gamescope_can_composite_cursor() { + vec!["--pipewire-composite-cursor".to_string()] + } else { + Vec::new() + } +} + /// Spawn `gamescope --backend headless -W w -H h -r hz -- `. The app comes from /// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real /// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session). diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 2cb48d35..bfb386fc 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -388,22 +388,28 @@ fn which_in_path(name: &str) -> Option { None } -/// Does the resolved gamescope offer 10-bit BT.2020/PQ formats on its PipeWire node — i.e. can a -/// session on this host stream true HDR10 off a gamescope virtual output? +/// The punktfunk patch-set revision the resolved gamescope carries — the `+pfhdr` marker our +/// build stamps into the `--version` banner (`packaging/gamescope/README.md`). `None` for a stock +/// gamescope. /// -/// **A static, binary-identity answer, cached for the process.** It has to be: punktfunk fixes a -/// session's bit depth in the Welcome, *before* the display exists, and the Welcome is -/// irrevocable (PQ frames handed to an 8-bit encoder are a deliberate hard error). Optimistic -/// "spawn it and see" would strand the session. So we ask the binary, once, and believe it. +/// **A static, binary-identity answer, cached for the process**, and it has to be: punktfunk +/// fixes a session's shape before the display exists — the bit depth in the Welcome, which is +/// irrevocable (PQ frames handed to an 8-bit encoder are a deliberate hard error), and whether +/// the host must composite the cursor itself, which is decided before the encoder is opened. +/// Optimistic "spawn it and see" would strand the session either way. So we ask the binary once +/// and believe it. /// -/// The answer is the `+pfhdr` marker our build stamps into the `--version` banner (see -/// `packaging/gamescope/README.md`). When upstream takes the patch this becomes a plain version -/// floor, exactly like [`MIN_GAMESCOPE_OVERLAY`]. -pub(crate) fn gamescope_hdr_capable() -> bool { - static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); - *CAP.get_or_init(|| { +/// Monotonic, so one probe answers every capability: +/// * `1` — 10-bit BT.2020/PQ capture formats ([`gamescope_hdr_capable`]); +/// * `2` — …and `--pipewire-composite-cursor` ([`gamescope_can_composite_cursor`]). +/// +/// When upstream takes the functional patches this becomes a plain version floor, exactly like +/// [`MIN_GAMESCOPE_OVERLAY`]. +fn gamescope_patch_level() -> u32 { + static LEVEL: std::sync::OnceLock = std::sync::OnceLock::new(); + *LEVEL.get_or_init(|| { let Ok(out) = Command::new(gamescope_bin()).arg("--version").output() else { - return false; + return 0; }; // The banner goes to stderr on some builds, stdout on others (same as the version gate). let text = format!( @@ -411,28 +417,59 @@ pub(crate) fn gamescope_hdr_capable() -> bool { String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); - let capable = text.contains(PFHDR_MARKER); - if capable { + let level = parse_patch_level(&text); + if level > 0 { tracing::info!( bin = %gamescope_bin(), - "gamescope offers 10-bit BT.2020/PQ capture — HDR sessions are available" + level, + "gamescope carries the punktfunk patch set — HDR capture, and (level 2+) the \ + cursor composited into the capture stream" ); } else { tracing::debug!( bin = %gamescope_bin(), "gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \ - (install punktfunk-gamescope for HDR)" + with a host-composited cursor (install punktfunk-gamescope for HDR)" ); } - capable + level }) } -/// The marker `packaging/gamescope/patches/0002-*` stamps into the `--version` banner. Matched as -/// a PREFIX (`+pfhdr1`, `+pfhdr2`, …): the number tracks the patch's wire behaviour, and every -/// revision of it still offers 10-bit PQ capture. +/// Does the resolved gamescope offer 10-bit BT.2020/PQ formats on its PipeWire node — i.e. can a +/// session on this host stream true HDR10 off a gamescope virtual output? +pub(crate) fn gamescope_hdr_capable() -> bool { + gamescope_patch_level() >= 1 +} + +/// Can the resolved gamescope paint the pointer INTO its PipeWire node +/// (`--pipewire-composite-cursor`)? When it can, the host stops reconstructing the cursor from +/// XFixes and blending it in — which is what frees the session to take the encoder's zero-CSC +/// RGB-direct source, since that front end has no blend stage. +pub(crate) fn gamescope_can_composite_cursor() -> bool { + gamescope_patch_level() >= 2 +} + +/// The marker `packaging/gamescope/patches/0003-*` stamps into the `--version` banner, followed by +/// the patch-set revision (`+pfhdr1`, `+pfhdr2`, …). const PFHDR_MARKER: &str = "+pfhdr"; +/// The `+pfhdr` revision in a `--version` banner, or `0` for a stock gamescope. Split out pure +/// because everything downstream is a `>=` on it: read it too low and a capable box silently loses +/// HDR; too high and the host promises a cursor nobody paints. +fn parse_patch_level(banner: &str) -> u32 { + banner + .find(PFHDR_MARKER) + .map(|i| &banner[i + PFHDR_MARKER.len()..]) + .map(|tail| { + tail.chars() + .take_while(char::is_ascii_digit) + .collect::() + }) + .and_then(|digits| digits.parse().ok()) + .unwrap_or(0) +} + /// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks /// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); @@ -501,7 +538,39 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> { #[cfg(test)] mod tests { - use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY}; + use super::{ + parse_patch_level, parse_version, steam_appid_from_launch, MIN_GAMESCOPE, + MIN_GAMESCOPE_OVERLAY, + }; + + /// The `+pfhdr` probe decides, before anything is spawned, whether a session may negotiate + /// HDR (level 1) and whether the host must composite the pointer itself (level 2). Both are + /// irreversible once the session is planned, so the parse has to be exact in both directions: + /// too low silently costs a capable box its HDR, too high promises a cursor nobody paints. + #[test] + fn patch_level_parses_the_marker_and_nothing_else() { + // The real banner shape: `git describe` output, our marker, then the compiler. + assert_eq!( + parse_patch_level("gamescope version 3.16.25-1-g8c676c3+pfhdr2 (gcc 15.2.0)"), + 2 + ); + assert_eq!( + parse_patch_level("gamescope version 3.16.25+pfhdr1 (clang 20)"), + 1 + ); + // A stock gamescope — every capability off. + assert_eq!( + parse_patch_level("gamescope version 3.16.25 (gcc 15.2.0)"), + 0 + ); + assert_eq!(parse_patch_level(""), 0); + // Multi-digit revisions must not truncate to their first digit. + assert_eq!(parse_patch_level("3.16.25+pfhdr10 (gcc)"), 10); + // A marker with no number is not a capability claim. + assert_eq!(parse_patch_level("3.16.25+pfhdr (gcc)"), 0); + // The version triple must never be mistaken for the level. + assert_eq!(parse_patch_level("gamescope version 3.16.25"), 0); + } #[test] fn parses_steam_appid_from_launch() { diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 9ad74396..3325c87f 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1047,9 +1047,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option bool { } #[cfg(not(target_os = "windows"))] { - cursor_forward || gamescope + cursor_forward || gamescope_needs_host_cursor(gamescope) + } +} + +/// Does a gamescope session still need the HOST to composite its pointer? +/// +/// It always did: gamescope keeps the cursor on a hardware plane for scanout and never painted it +/// into its PipeWire node, so the host read it from XFixes and blended it into every frame. Our +/// carried patch (level 2+, `--pipewire-composite-cursor`) puts it in the node instead — and then +/// the host must NOT blend, or the pointer is drawn twice. +/// +/// This is worth more than saving a blend. A session that composites forces the encoder onto its +/// compute colour-conversion arm, because the zero-copy RGB-direct source hands the captured +/// buffer to a fixed-function front end that has no blend stage. So a gamescope session with the +/// cursor in the node is the first one that can be genuinely zero-copy end to end. +#[cfg(not(target_os = "windows"))] +fn gamescope_needs_host_cursor(gamescope: bool) -> bool { + gamescope && !pf_vdisplay::gamescope_composites_cursor() +} + +/// Should this session attach the XFixes cursor reader — i.e. is this a gamescope session whose +/// pointer the host still has to source and composite itself? The `SessionPlan::gamescope_cursor` +/// resolver, kept beside [`cursor_blend_for`] because the two must give the same answer: attaching +/// the reader without the blend wastes an X11 connection, and blending without it streams no +/// pointer at all. +pub(crate) fn gamescope_cursor_for(gamescope: bool) -> bool { + #[cfg(target_os = "windows")] + { + let _ = gamescope; + false + } + #[cfg(not(target_os = "windows"))] + { + gamescope_needs_host_cursor(gamescope) } } diff --git a/docs/releases/v0.21.0.md b/docs/releases/v0.21.0.md index cc43d089..33c02c95 100644 --- a/docs/releases/v0.21.0.md +++ b/docs/releases/v0.21.0.md @@ -47,6 +47,7 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the - **Vulkan Video learned 10-bit**, which is what keeps AMD/Intel HDR on the good path: an HDR session opens a 10-bit video profile (HEVC Main10 / AV1 Main at 10 bits) with a `G10X6…3PACK16` picture and DPB, headers carrying the depth and the BT.2020/PQ CICP triplet — an SPS `bit_depth_*_minus8 = 2` for HEVC, `high_bitdepth` plus the matching sequence-header OBU bits for AV1 — and a new `rgb2yuv10.comp`: the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all. - **GameStream advertises its 10-bit codec bits per codec.** `ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10` — a blanket omission on the theory that the GameStream AV1 path was unconfirmed, even though the SDR baseline has always offered AV1 Main8, so the depth was never the uncertain part. Each 10-bit bit is now gated on that codec's own `can_encode_10bit` probe AND the baseline already advertising it, and the RTSP honor degrades a session whose NEGOTIATED codec can't carry 10 bits rather than labelling an 8-bit stream PQ. `host_hdr_capable` became codec-agnostic to match (any 10-bit-capable codec makes the host HDR-capable; which one a session gets is the session's question). - **The Vulkan encode backend is now capability-probed per codec AND depth**, mirroring what the direct-SDK NVENC path already does with its GUID probe. `vkGetPhysicalDeviceVideoCapabilitiesKHR` is asked against the very profile the session open will build, so the dispatcher's prediction cannot disagree with reality: a device that can encode 10-bit keeps the Vulkan path, one that can't routes to libav VAAPI *before* burning a failed session open, and `can_encode_10bit` reports the union of what VAAPI and Vulkan Video can do rather than VAAPI's answer alone (which was under-reporting 10-bit on hardware that could do it). +- **A gamescope session can now be zero-copy end to end.** gamescope keeps the pointer out of its PipeWire node (it lives on a hardware plane for scanout), so the host always reconstructed it from XFixes and blended it into every frame — and *that blend* is what forced the encode path onto its compute colour-conversion arm, since the zero-copy RGB-direct source hands the captured buffer to a fixed-function front end with no blend stage. A second carried gamescope patch adds `--pipewire-composite-cursor`, which paints it in using the same `MouseCursor::paint` call the scanout composite uses; the repaint test grows the cursor's state alongside the commit ids, so a pointer-only move still produces a frame and a hidden cursor is erased. The host reads a monotonic `+pfhdr` patch level from the `--version` banner — one probe now answering both "can it do HDR" and "does it paint the cursor" — and stops attaching the XFixes reader and blending when the answer is yes. - **The zero-CSC RGB-direct (EFC) source works in HDR too.** The `VK_VALVE_video_encode_rgb_conversion` probe now asks for the BT.2020 model and the captured 10-bit packed-RGB format instead of assuming BT.709/BGRA, and the session selects the matching model — so an HDR session with no pointer to composite (the GameStream desktop mirror) hands the captured buffer straight to the encoder's fixed-function front end and runs no host CSC at all. Sessions that DO composite a pointer keep the compute CSC, as before: the EFC cannot blend. - **NVIDIA gained a zero-copy HDR leg**, and the VAAPI fallback needed no new encoder code. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill. - **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere. diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md index 3a25da03..8aa0b89f 100644 --- a/packaging/gamescope/README.md +++ b/packaging/gamescope/README.md @@ -12,19 +12,43 @@ The patches here add the missing half, and nothing else. See | Patch | What | Upstream? | |---|---|---| | `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-punktfunk-stamp-the-version-banner-with-pfhdr1.patch` | Append `+pfhdr1` to the `--version` banner | **No** — ours only, retired when patch 1 lands upstream | +| `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` to the `--version` banner | **No** — ours only, retired when the two above land upstream | + +### Why the cursor patch matters more than it looks + +gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout — +so punktfunk has always reconstructed it from XFixes and blended it into every frame host-side. +That blend is what forces the encode path onto its compute colour-conversion arm: the zero-copy +RGB-direct encode source (`VK_VALVE_video_encode_rgb_conversion`) hands the captured buffer to a +fixed-function front end with no blend stage. Painting the cursor into the node removes the reason +for the blend, and with it a full-frame pass per frame — a gamescope session becomes the first one +that can be genuinely zero-copy end to end. ## Why the marker exists -punktfunk decides a session's bit depth at handshake time, **before** the virtual display -exists, and the Welcome is irrevocable (a PQ stream handed to an 8-bit encoder is a deliberate -hard error). The capability answer must therefore be a static property of the resolved binary, -not an optimistic negotiation. The host runs ` --version` once per boot and looks for -`+pfhdr` in the banner — see `gamescope_hdr_capable()` in -`crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs`. +punktfunk decides a session's shape **before** the virtual display exists: the bit depth at +handshake time (irrevocable — a PQ stream handed to an 8-bit encoder is a deliberate hard error), +and whether the host must composite the cursor before the encoder is even opened. Both answers +must therefore be static properties of the resolved binary, not optimistic negotiations. The host +runs ` --version` once per boot and reads the revision — see `gamescope_patch_level()` +in `crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs`. -Bump the number (`+pfhdr2`, …) only if the patch's **wire** behaviour changes; the host's probe -accepts any `+pfhdr`. +The number is a **monotonic patch-set revision**, so one probe answers every capability: + +| Level | Adds | +|---|---| +| `+pfhdr1` | 10-bit BT.2020/PQ capture formats | +| `+pfhdr2` | …and `--pipewire-composite-cursor` | + +Bump it whenever a patch adds or changes something the host must know about before it spawns. + +⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the +SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the +session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR +formats nor the cursor flag. HDR fails loudly there (the capture negotiation times out and latches +an SDR downgrade); a missing cursor would be silent. Worth a post-spawn `/proc//cmdline` check +if that ever bites. ## Which binary the host runs @@ -67,9 +91,9 @@ setcap 'CAP_SYS_NICE=eip' /usr/bin/punktfunk-gamescope ## Verifying the patch on a box (P0 exit) ```sh -punktfunk-gamescope --version # must contain +pfhdr1 +punktfunk-gamescope --version # must contain +pfhdr2 punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \ - --hdr-enabled --hdr-debug-force-support -- vkcube & + --hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube & pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE ``` diff --git a/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch b/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch new file mode 100644 index 00000000..32f2b1ce --- /dev/null +++ b/packaging/gamescope/patches/0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch @@ -0,0 +1,163 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Enrico=20B=C3=BChler?= +Date: Tue, 28 Jul 2026 15:41:45 +0200 +Subject: [PATCH] pipewire: optionally composite the cursor into the capture + stream +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +gamescope's PipeWire node has never carried the pointer: it lives on a hardware +plane for scanout, and `paint_pipewire()` composites a separate, reduced frame +that never includes it. That is the right default — a remote-play consumer that +draws its own cursor would end up with two — but it leaves a consumer with no +cursor of its own nothing to fall back to, because the node is all it gets. +Such a consumer has to reconstruct the pointer out of band (XFixes plus a +host-side blend), which forces a full-frame colour-conversion pass it would +otherwise not need. + +Add `--pipewire-composite-cursor` (convar `pipewire_composite_cursor`, off by +default) to paint it in, using the same guards and the same MouseCursor::paint +call the scanout composite uses. It scales correctly for free: paint_pipewire +has already set currentOutputWidth/Height to the capture size, which is what +MouseCursor::paint scales against. + +The repaint test grows the cursor's state alongside the commit ids. A +pointer-only move produces no new commit, so without it the composited cursor +would freeze on a static screen while the real one moved — and a cursor that +became hidden would never be erased. `undirty()` runs before the test, exactly +as the scanout path does, so the test and the paint agree on +`ShouldDrawCursor()`. + +The cursor is taken from the global focus rather than the stream's: the pointer +is one device with one position whatever the stream shows, and the +focus-appid branch hands back a plain focus_t, which carries no cursor. +--- + src/main.cpp | 2 ++ + src/steamcompmgr.cpp | 63 +++++++++++++++++++++++++++++++++++++++++++- + 2 files changed, 64 insertions(+), 1 deletion(-) + +diff --git a/src/main.cpp b/src/main.cpp +index 9fc54f0..1eb35b3 100644 +--- a/src/main.cpp ++++ b/src/main.cpp +@@ -141,6 +141,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 }, + { "hdr-enabled", no_argument, nullptr, 0 }, + { "hdr-sdr-content-nits", required_argument, nullptr, 0 }, + { "hdr-itm-enabled", no_argument, nullptr, 0 }, +@@ -205,6 +206,7 @@ const char usage[] = + " --force-windows-fullscreen force windows inside of gamescope to be the size of the nested display (fullscreen)\n" + " --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" + " --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 01b2abf..5c65420 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -2316,6 +2316,13 @@ static void update_touch_scaling( const struct FrameInfo_t *frameInfo ) + } + + #if HAVE_PIPEWIRE ++// Declared here rather than beside the other `cv_paint_*_plane` knobs, which sit BELOW ++// paint_pipewire and so are not in scope for it. ++gamescope::ConVar cv_pipewire_composite_cursor{ "pipewire_composite_cursor", false, ++ "Composite the cursor into the PipeWire capture stream (--pipewire-composite-cursor). Off by " ++ "default: the node has never carried the pointer, and a consumer that draws its own would get " ++ "two." }; ++ + static void paint_pipewire() + { + static struct pipewire_buffer *s_pPipewireBuffer = nullptr; +@@ -2402,16 +2409,48 @@ static void paint_pipewire() + // If the commits are the same as they were last time, don't repaint and don't push a new buffer on the stream. + static uint64_t s_ulLastFocusCommitId = 0; + static uint64_t s_ulLastOverrideCommitId = 0; ++ static bool s_bLastCursorDrawn = false; ++ static int s_nLastCursorX = 0; ++ static int s_nLastCursorY = 0; + + uint64_t ulFocusCommitId = window_last_done_commit_id( pFocus->focusWindow ); + uint64_t ulOverrideCommitId = window_last_done_commit_id( pFocus->overrideWindow ); + ++ // With the cursor composited in, the pointer becomes part of what this stream shows — so its ++ // state has to join the repaint test, or it would freeze on a static screen while the real ++ // one moves (and a hidden cursor would never be erased). `undirty()` first, exactly as the ++ // scanout composite does: it refreshes the texture and the grabbed/hidden state that ++ // `ShouldDrawCursor()` reads, so both the test and the paint below see the same answer. ++ // ++ // The cursor comes from the GLOBAL focus, not `pFocus`: the pointer is one device with one ++ // position whatever this stream is showing, and the focus-appid branch above hands back a ++ // plain `focus_t`, which carries no cursor at all. ++ global_focus_t *pGlobalFocus = cv_pipewire_composite_cursor ? GetCurrentFocus() : nullptr; ++ MouseCursor *pCursor = pGlobalFocus ? pGlobalFocus->cursor : nullptr; ++ bool bDrawCursor = false; ++ int nCursorX = 0, nCursorY = 0; ++ if ( pCursor ) ++ { ++ pCursor->undirty(); ++ bDrawCursor = ShouldDrawCursor(); ++ if ( bDrawCursor ) ++ { ++ nCursorX = pCursor->x(); ++ nCursorY = pCursor->y(); ++ } ++ } ++ + if ( ulFocusCommitId == s_ulLastFocusCommitId && +- ulOverrideCommitId == s_ulLastOverrideCommitId ) ++ ulOverrideCommitId == s_ulLastOverrideCommitId && ++ bDrawCursor == s_bLastCursorDrawn && ++ nCursorX == s_nLastCursorX && nCursorY == s_nLastCursorY ) + return; + + s_ulLastFocusCommitId = ulFocusCommitId; + s_ulLastOverrideCommitId = ulOverrideCommitId; ++ s_bLastCursorDrawn = bDrawCursor; ++ s_nLastCursorX = nCursorX; ++ s_nLastCursorY = nCursorY; + + uint32_t uWidth = s_pPipewireBuffer->texture->width(); + uint32_t uHeight = s_pPipewireBuffer->texture->height(); +@@ -2436,6 +2475,22 @@ static void paint_pipewire() + ( 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 ++ // its own (and no way to obtain one, because this node is all it gets) has to ask for it. ++ // ++ // Painted at THIS stream's scale: `currentOutputWidth/Height` were set to the capture size ++ // above, which is exactly what MouseCursor::paint scales against. Already `undirty()`ed by ++ // the repaint test. ++ if ( bDrawCursor ) ++ { ++ pCursor->paint( ++ pFocus->focusWindow, ++ pFocus->focusWindow == pFocus->overrideWindow ? nullptr : pFocus->overrideWindow, ++ &frameInfo ); ++ } ++ + gamescope::Rc pRGBTexture = s_pPipewireBuffer->texture->isYcbcr() + ? vulkan_acquire_capture_texture( uWidth, uHeight, false, DRM_FORMAT_XRGB2101010 ) + : gamescope::Rc{ s_pPipewireBuffer->texture }; +@@ -8397,6 +8452,12 @@ steamcompmgr_main(int argc, char **argv) + g_FadeOutDuration = atoi(optarg); + } else if (strcmp(opt_name, "force-windows-fullscreen") == 0) { + bForceWindowsFullscreen = true; ++ } else if (strcmp(opt_name, "pipewire-composite-cursor") == 0) { ++#if HAVE_PIPEWIRE ++ cv_pipewire_composite_cursor = true; ++#else ++ fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" ); ++#endif + } else if (strcmp(opt_name, "hdr-enabled") == 0 || strcmp(opt_name, "hdr-enable") == 0) { + cv_hdr_enabled = true; + } else if (strcmp(opt_name, "hdr-debug-force-support") == 0) { diff --git a/packaging/gamescope/patches/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch b/packaging/gamescope/patches/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch deleted file mode 100644 index 5cd187a7..00000000 --- a/packaging/gamescope/patches/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch +++ /dev/null @@ -1,37 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: =?UTF-8?q?Enrico=20B=C3=BChler?= -Date: Tue, 28 Jul 2026 13:53:41 +0200 -Subject: [PATCH] punktfunk: stamp the version banner with +pfhdr1 -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -punktfunk decides a session's bit depth before the virtual display exists (the -Welcome is irrevocable — a PQ stream on an 8-bit encoder is a hard error), so -its capability answer has to be a static property of the resolved binary rather -than an optimistic negotiation. Marking the banner lets it probe with -`gamescope --version`. - -NOT for upstream: drop this commit once pipewire-hdr lands there and a plain -version floor answers the same question. ---- - src/meson.build | 6 +++++- - 1 file changed, 5 insertions(+), 1 deletion(-) - -diff --git a/src/meson.build b/src/meson.build -index 662f752..b68e72c 100644 ---- a/src/meson.build -+++ b/src/meson.build -@@ -177,7 +177,11 @@ compiler_version = cc.version() - - vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+'] - vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip() --version_tag = vcs_tag + ' (' + compiler_name + ' ' + compiler_version + ')' -+# punktfunk carries a pipewire-hdr patch on top of upstream; stamp the banner so a host can probe -+# "does this gamescope offer 10-bit PQ capture?" from `gamescope --version` alone, before it has -+# to commit a session to 10-bit (the Welcome is irrevocable). Bump the number when the patch's -+# wire behaviour changes. -+version_tag = vcs_tag + '+pfhdr1' + ' (' + compiler_name + ' ' + compiler_version + ')' - - gamescope_version_conf = configuration_data() - gamescope_version_conf.set('VCS_TAG', version_tag) diff --git a/packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch b/packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch new file mode 100644 index 00000000..d2c9d9ce --- /dev/null +++ b/packaging/gamescope/patches/0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch @@ -0,0 +1,42 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Enrico=20B=C3=BChler?= +Date: Tue, 28 Jul 2026 15:42:01 +0200 +Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +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. + +The number is a monotonic patch-set revision, so one probe answers both: + +pfhdr1 10-bit BT.2020/PQ capture formats + +pfhdr2 …and --pipewire-composite-cursor + +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(-) + +diff --git a/src/meson.build b/src/meson.build +index 662f752..af48d01 100644 +--- a/src/meson.build ++++ b/src/meson.build +@@ -177,7 +177,12 @@ compiler_version = cc.version() + + vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+'] + vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip() +-version_tag = vcs_tag + ' (' + compiler_name + ' ' + compiler_version + ')' ++# punktfunk carries patches on top of upstream; stamp the banner so a host can probe what this ++# binary can do from `gamescope --version` alone, before it has to commit a session to it (the ++# 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 + ')' + + gamescope_version_conf = configuration_data() + gamescope_version_conf.set('VCS_TAG', version_tag)