Files
punktfunk/packaging/gamescope/README.md
T
enricobuehler fd648aa776 feat(gamescope): put the cursor in the node, so a session can be zero-copy
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/<pid>/cmdline` check if it ever bites.
2026-07-28 18:03:30 +02:00

5.6 KiB

punktfunk-gamescope — gamescope with 10-bit HDR PipeWire capture

Upstream gamescope's built-in PipeWire node is SDR-only: build_format_params() offers BGRx and NV12, and paint_pipewire() hardcodes a Gamma-2.2 composite with the SDR screenshot LUT set. An HDR game therefore reaches every capture consumer already tone-mapped down — which is why the punktfunk gamescope backend has always streamed 8-bit, even though games can render HDR on a headless gamescope today (--hdr-enabled --hdr-debug-force-support).

The patches here add the missing half, and nothing else. See punktfunk-planning/design/gamescope-hdr-virtual-output.md for the full design.

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
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

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 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 <gamescope> --version once per boot and reads the revision — see gamescope_patch_level() in crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs.

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/<pid>/cmdline check if that ever bites.

Which binary the host runs

Resolution order, applied identically by the bare spawn, the GAMESCOPE_BIN wrapper (gamescope-session-plus) and the SteamOS PATH shim:

  1. PUNKTFUNK_GAMESCOPE_BIN — absolute path override
  2. punktfunk-gamescope on PATH
  3. gamescope

So installing this build under the name punktfunk-gamescope is enough; nothing replaces the distro's gamescope.

Building

Pinned upstream: 8c676c39 (master, 2026-07-27 — tags through 3.16.25). The patches apply cleanly to that commit; they touch src/pipewire.cpp, src/steamcompmgr.cpp and src/meson.build only.

git clone https://github.com/ValveSoftware/gamescope.git
cd gamescope
git checkout 8c676c39
git submodule update --init --recursive          # or let meson fetch the subprojects
git am /path/to/punktfunk/packaging/gamescope/patches/*.patch

meson setup build/ --prefix=/usr -Dpipewire=enabled
ninja -C build/
# install as punktfunk-gamescope, NOT as gamescope
install -Dm755 build/src/gamescope /usr/bin/punktfunk-gamescope

gamescope needs CAP_SYS_NICE for its realtime priority; the distro packages set it on their own binary. Mirror it if you install ours system-wide:

setcap 'CAP_SYS_NICE=eip' /usr/bin/punktfunk-gamescope

Verifying the patch on a box (P0 exit)

punktfunk-gamescope --version                    # must contain +pfhdr2
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

The stream is only 10-bit once a consumer asks for it: the formats are listed last, so any consumer that negotiates the 8-bit stream today keeps negotiating it bit-for-bit.

Rebase policy

The functional patch is two files and mirrors code that already exists in-tree (the HDR AVIF screenshot path), so it rebases cheaply. We pin the gamescope commit we ship; when upstream takes it, both patches are dropped and the host's capability probe becomes a plain version floor.