feat(gamescope): a build of gamescope whose capture output can be HDR

gamescope's built-in PipeWire node has always been SDR-only: it offers BGRx
and NV12, and `paint_pipewire()` hardcodes a Gamma-2.2 composite with the SDR
screenshot LUT set. So an HDR game reaches punktfunk already tone-mapped down,
and the whole gamescope backend streams 8-bit no matter what the client can
decode. Games CAN render HDR on a headless gamescope today — only the capture
half is missing.

Carry the two patches that close it (packaging/gamescope/patches): the node
additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 +
BT.2020 props, mapped to the same 10-bit capture texture the HDR AVIF
screenshot path already allocates, and `paint_pipewire()` composites into them
with the HDR screenshot LUTs + `EOTF_PQ` — which is exactly the in-tree
`bHDRScreenshot` branch. The new formats are listed LAST, so every existing
consumer keeps negotiating the 8-bit stream bit-for-bit. Offered upstream
against ValveSoftware/gamescope#2126.

Ship it as `punktfunk-gamescope`, beside the distro's own binary rather than
replacing it: a Bazzite sysext payload (`build-sysext.sh --gamescope`, which
refuses a binary without the marker), an Arch package, a nix derivation +
`services.punktfunk.host.gamescopeHdr`, and one distro-agnostic build script
the rest call.

The second patch stamps `+pfhdr1` into the `--version` banner. That is not
cosmetic: punktfunk fixes a session's bit depth in the Welcome, before the
display exists and with no way to take it back, so its capability answer has to
be a static property of the binary it will spawn.
This commit is contained in:
2026-07-28 18:01:20 +02:00
parent 28c50d1c5b
commit d6818263ce
11 changed files with 693 additions and 2 deletions
+10
View File
@@ -61,6 +61,15 @@
inherit version;
# `.hook` + `.fetchBunDeps` (bun2nix v2 API) — see packages.nix.
bun2nix = bun2nix.packages.${system}.default;
}
// {
# gamescope + our `pipewire-hdr` patches, so the gamescope backend can stream HDR.
# Kept OUT of packages.nix (a Rust/crane file) — it is a C++ meson override with a
# disjoint dependency set — and out of `checks` below, because it rebuilds gamescope
# from source and would make `nix flake check` an hour long.
punktfunk-gamescope = pkgs.callPackage ./packaging/nix/gamescope.nix {
patchDir = ./packaging/gamescope/patches;
};
};
in
{
@@ -76,6 +85,7 @@
punktfunk-web
punktfunk-scripting
punktfunk-tray
punktfunk-gamescope
;
default = pf.punktfunk-host;
}
+21 -1
View File
@@ -24,18 +24,27 @@
#
# Usage:
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
# [--gamescope path/to/punktfunk-gamescope] \
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
#
# --gamescope folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
# /usr/bin/punktfunk-gamescope, which is what lets the gamescope backend stream 10-bit BT.2020 PQ.
# It is NOT built here: it is a C++ meson build with gamescope's whole dependency set, so CI builds
# it in the same Fedora container beforehand (`bash packaging/gamescope/build-punktfunk-gamescope.sh
# --destdir stage --prefix /usr`) and passes the resulting binary in. Omit it and the image is
# exactly what it was — the host then stays SDR on that backend, by design.
#
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
# versioned filenames and punktfunk-sysext installs to the fixed name.
set -euo pipefail
VERSION_ID="" OUT="" RPMS=()
VERSION_ID="" OUT="" GAMESCOPE="" RPMS=()
while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${2:?}"; shift 2 ;;
--gamescope) GAMESCOPE="${2:?}"; shift 2 ;;
*) RPMS+=("$1"); shift ;;
esac
done
@@ -78,6 +87,17 @@ if [ -d "$STAGE/etc" ]; then
fi
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
# The HDR-capable gamescope, when one was built (see --gamescope in the header). Verified by its
# banner marker rather than trusted by filename: an unpatched gamescope shipped under this name
# would make the host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that
# back mid-session.
if [ -n "$GAMESCOPE" ]; then
[ -x "$GAMESCOPE" ] || { echo "no such executable: $GAMESCOPE" >&2; exit 1; }
"$GAMESCOPE" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GAMESCOPE has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
install -Dm0755 "$GAMESCOPE" "$STAGE/usr/bin/punktfunk-gamescope"
fi
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
+10
View File
@@ -38,6 +38,16 @@ PUNKTFUNK_GAMESCOPE_ATTACH=1
# ON BY DEFAULT on Bazzite/SteamOS (the host detects the platform); set =0 to disable it:
# PUNKTFUNK_SESSION_WATCH=0
#
# HDR (10-bit BT.2020 PQ) on the gamescope path. Needs `punktfunk-gamescope` — gamescope plus the
# patch that teaches its capture node the 10-bit formats — which the sysext ships alongside the
# host; it does NOT replace the system gamescope. Off by default while the feature soaks; a host
# without the patched binary stays SDR either way. `punktfunk-host hdr-probe` says what it found.
# PUNKTFUNK_GAMESCOPE_HDR=1
#
# On an HDR session, how bright SDR content (desktop, Steam overlay, SDR games) is inside the PQ
# container. gamescope's default is 400 nits; raise it if whites look dim on your TV.
# PUNKTFUNK_GAMESCOPE_SDR_NITS=400
#
# HEADLESS GAME MODE: on a box with no display attached, Bazzite's "Switch to Game Mode" normally
# crashes (gamescope's DRM backend has no panel to drive). The host package ships
# /etc/gamescope-session-plus/sessions.d/steam, which auto-falls-back to gamescope's HEADLESS backend
+67
View File
@@ -0,0 +1,67 @@
# Maintainer: unom <packages@unom.io>
#
# gamescope + punktfunk's `pipewire-hdr` patches, installed as `punktfunk-gamescope`.
#
# Standalone on purpose (its own pkgbase, not part of ../arch/PKGBUILD): it is an OPTIONAL
# companion — punktfunk works without it, just SDR on the gamescope backend — and it is a C++
# meson build with a completely different dependency set from the Rust workspace. Installing it
# does not touch the distro's own `gamescope`; the host resolves `PUNKTFUNK_GAMESCOPE_BIN` >
# `punktfunk-gamescope` > `gamescope`, so the mere presence of this package is what enables HDR.
#
# makepkg -si
#
# See ../README.md (packaging/gamescope/README.md) for what the patches do and why.
pkgname=punktfunk-gamescope
# `_gsver` is the upstream gamescope this is built from; `pkgrel` moves when only our patches do.
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
pkgver="${_gsver}.pfhdr1"
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
url="https://git.unom.io/unom/punktfunk"
license=('BSD-2-Clause')
# NOT a `provides`/`conflicts` of gamescope: this ships a differently-named binary and is meant to
# sit BESIDE the distro package (a Steam Deck's gaming session keeps using its own gamescope).
optdepends=('punktfunk-host: the streaming host that uses it')
# Mirrors Arch's own `gamescope` package. Keep in step with it when that one moves — this builds
# the same tree, so a dependency it grows is one this needs too.
depends=('glslang' 'libcap' 'libdrm' 'libinput' 'libx11' 'libxcomposite' 'libxdamage'
'libxext' 'libxkbcommon' 'libxmu' 'libxrender' 'libxres' 'libxtst' 'libxxf86vm'
'libavif' 'libdecor' 'hwdata' 'luajit' 'pipewire' 'seatd' 'sdl2-compat'
'vulkan-icd-loader' 'wayland' 'xcb-util-errors' 'xcb-util-wm' 'xorg-xwayland')
makedepends=('git' 'meson' 'ninja' 'cmake' 'glm' 'vulkan-headers' 'wayland-protocols'
'benchmark' 'libxcursor')
# The submodules are pinned by the rev, so the git source is reproducible; `#commit=` makes
# makepkg check it out and `--recurse-submodules` pulls wlroots/vkroots/libliftoff/… with it.
source=("git+https://github.com/ValveSoftware/gamescope.git#commit=${_gsrev}"
'0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch'
'0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch')
# SKIP on the git source: the commit hash above IS the integrity check.
sha256sums=('SKIP' 'SKIP' 'SKIP')
options=('!lto' '!debug')
install="${pkgname}.install"
prepare() {
cd gamescope
git submodule update --init --recursive --depth 1
# `git am` (not `patch`): the patches are format-patch output, and applying them as commits keeps
# `git describe --dirty` from stamping a `+` into the version banner on top of our `+pfhdr1`.
git -c user.name=punktfunk -c user.email=packages@unom.io \
am "${srcdir}"/000*.patch
}
build() {
arch-meson gamescope build \
--buildtype=release \
-Dpipewire=enabled
meson compile -C build
}
package() {
# ONLY the compositor, under our own name: a full `meson install` would also lay down
# gamescopectl / gamescopereaper / gamescopestream and the WSI layer, colliding file-for-file
# with the distro's `gamescope` package. The host only ever execs the compositor.
install -Dm755 build/src/gamescope "${pkgdir}/usr/bin/punktfunk-gamescope"
install -Dm644 gamescope/LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+84
View File
@@ -0,0 +1,84 @@
# `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](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 |
## 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 `<gamescope> --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`.
Bump the number (`+pfhdr2`, …) only if the patch's **wire** behaviour changes; the host's probe
accepts any `+pfhdr<N>`.
## 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.
```sh
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:
```sh
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 --backend headless -W 1920 -H 1080 -r 60 \
--hdr-enabled --hdr-debug-force-support -- 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.
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Build gamescope + punktfunk's `pipewire-hdr` patches and install it as `punktfunk-gamescope`.
#
# The ONE build recipe every packaging path calls (Arch PKGBUILD, the Bazzite sysext image, an
# operator building by hand); the nix derivation expresses the same thing declaratively. It never
# touches the distro's own `gamescope` — the binary lands under a different name, and the host's
# resolution order (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope) picks it up.
#
# Usage:
# bash build-punktfunk-gamescope.sh [--rev <git-rev>] [--prefix /usr] [--destdir DIR]
# [--srcdir DIR] [--jobs N] [--no-setcap]
#
# --rev gamescope commit/tag to build (default: the pin below)
# --prefix install prefix (default /usr)
# --destdir staging root for packaging (default: install straight into --prefix)
# --srcdir reuse an existing gamescope checkout instead of cloning
# --no-setcap skip CAP_SYS_NICE (always skipped when --destdir is set — a package sets it)
#
# Needs: git, meson >= 0.58, ninja, a C++20 compiler, and gamescope's own build deps. Those vary
# by distro; the packaging files list them (packaging/gamescope/PKGBUILD for Arch). gamescope
# vendors wlroots / libliftoff / vkroots / libdisplay-info / SPIRV-Headers / reshade as git
# SUBMODULES (plus two meson wraps for glm + stb), so both the clone and the build want network
# access unless the checkout already carries them — hence `--recurse-submodules` below.
set -euo pipefail
# The pinned upstream. Bump together with the patches (they are `git am`-able and rebase cheaply —
# two files, mirroring code that already exists in-tree; see README.md).
GAMESCOPE_REV="8c676c399c761e4540587f61004c957993d12fea"
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
while [ $# -gt 0 ]; do
case "$1" in
--rev) REV="${2:?}"; shift 2 ;;
--prefix) PREFIX="${2:?}"; shift 2 ;;
--destdir) DESTDIR="${2:?}"; shift 2 ;;
--srcdir) SRCDIR="${2:?}"; shift 2 ;;
--jobs) JOBS="${2:?}"; shift 2 ;;
--no-setcap) SETCAP=0; shift ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
# A staged install is a package build: the package manager owns file capabilities.
[ -n "$DESTDIR" ] && SETCAP=0
for tool in git meson ninja; do
command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; }
done
HERE="$(cd "$(dirname "$0")" && pwd)"
PATCHES=("$HERE"/patches/*.patch)
[ -e "${PATCHES[0]}" ] || { echo "no patches found in $HERE/patches" >&2; exit 1; }
WORK=""
if [ -z "$SRCDIR" ]; then
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
SRCDIR="$WORK/gamescope"
echo "==> cloning gamescope @ ${REV:0:12}"
git clone --recurse-submodules "$GAMESCOPE_REPO" "$SRCDIR"
git -C "$SRCDIR" checkout --recurse-submodules "$REV"
fi
# `git am` needs an identity and a clean tree; both are ours to provide in a throwaway checkout.
# Idempotent: a checkout that already carries the marker is left alone, so a re-run of this script
# against --srcdir does not fail on an already-applied patch.
if grep -q '+pfhdr' "$SRCDIR/src/meson.build"; then
echo "==> patches already applied in $SRCDIR"
else
echo "==> applying punktfunk patches"
git -C "$SRCDIR" \
-c user.name=punktfunk -c user.email=packages@unom.io \
am "${PATCHES[@]}"
fi
BUILD="$SRCDIR/build-punktfunk"
echo "==> configuring"
meson setup "$BUILD" "$SRCDIR" \
--prefix="$PREFIX" \
--buildtype=release \
-Dpipewire=enabled
echo "==> building"
ninja -C "$BUILD" ${JOBS:+-j "$JOBS"}
# Install ONLY the compositor, under our own name. gamescope's `ninja install` would also drop
# gamescopectl/gamescopereaper/gamescopestream + the WSI layer into the prefix, colliding with the
# distro's gamescope package — and we need none of them: the host only ever execs the compositor.
BIN="$BUILD/src/gamescope"
[ -x "$BIN" ] || { echo "build produced no $BIN" >&2; exit 1; }
DEST="${DESTDIR}${PREFIX}/bin/punktfunk-gamescope"
echo "==> installing $DEST"
install -Dm755 "$BIN" "$DEST"
if [ "$SETCAP" = 1 ] && command -v setcap >/dev/null; then
# gamescope raises its own scheduling priority; without CAP_SYS_NICE it still runs, just noisier
# and with worse frame pacing. Best-effort — needs root, and a package sets it declaratively.
setcap 'CAP_SYS_NICE=eip' "$DEST" 2>/dev/null \
|| echo "note: could not setcap CAP_SYS_NICE on $DEST (run as root, or let the package do it)"
fi
echo "==> done: $("$DEST" --version 2>&1 | head -1)"
echo " the banner above must contain +pfhdr — that marker is how the host detects HDR support"
@@ -0,0 +1,244 @@
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 13:53:41 +0200
Subject: [PATCH] pipewire: offer 10-bit BT.2020 PQ capture formats (HDR
streams)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
gamescope's built-in PipeWire node has always been SDR: build_format_params()
offers BGRx and NV12 only, and paint_pipewire() hardcodes a Gamma-2.2 composite
with the SDR screenshot LUT set, so an HDR game reaches every consumer already
tone-mapped down. Screen-capture consumers that CAN carry HDR — Steam Remote
Play (#2126), other remote-play hosts — have no way to ask for it.
Offer SPA xRGB_210LE / xBGR_210LE alongside the existing formats, carrying
MANDATORY SMPTE ST.2084 (PQ) + BT.2020 colorimetry properties, and map them to
DRM_FORMAT_XRGB2101010 / XBGR2101010 — the same 10-bit capture texture the HDR
AVIF screenshot path already allocates. When such a format is negotiated,
paint_pipewire() switches to g_ScreenshotColorMgmtLutsHDR and EOTF_PQ: exactly
the bHDRScreenshot branch, which is a fixed BT.2020+PQ target whose LUT set is
built unconditionally for every input EOTF, so SDR content lands in the PQ
container at --hdr-sdr-content-nits and HDR content passes through.
The new formats are listed last, so every consumer that negotiates the 8-bit
stream today keeps negotiating it bit-for-bit; the MANDATORY colorimetry props
mean a consumer cannot land on 10-bit without agreeing to PQ/BT.2020. Unlike
bHDRScreenshot the switch does not depend on the focused app being HDR — a
stream's colorimetry is fixed for the life of the negotiation and must not
follow what the app happens to render.
Works on the headless backend as well as a real connector: no HDR display is
involved anywhere in the LUT set.
---
src/pipewire.cpp | 99 ++++++++++++++++++++++++++++++++------------
src/steamcompmgr.cpp | 21 ++++++++--
2 files changed, 91 insertions(+), 29 deletions(-)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index 76b3ea8..0cf71d2 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -18,6 +18,25 @@
static LogScope pwr_log("pipewire");
+// 10-bit HDR capture.
+//
+// The packed 10-bit RGB SPA formats (xRGB_210LE / xBGR_210LE) and the SMPTE ST.2084 (PQ)
+// transfer function all predate PipeWire 1.0 by a wide margin; the guard only exists so the
+// file still compiles against an ancient libspa, in which case the stream offers exactly the
+// 8-bit formats it always did.
+#if PW_CHECK_VERSION(1, 0, 0)
+#define GAMESCOPE_PIPEWIRE_HDR 1
+#else
+#define GAMESCOPE_PIPEWIRE_HDR 0
+#endif
+
+#if GAMESCOPE_PIPEWIRE_HDR
+static bool is_hdr_video_format(uint32_t format)
+{
+ return format == SPA_VIDEO_FORMAT_xRGB_210LE || format == SPA_VIDEO_FORMAT_xBGR_210LE;
+}
+#endif
+
static struct pipewire_state pipewire_state = { .stream_node_id = SPA_ID_INVALID };
static int nudgePipe[2] = { -1, -1 };
@@ -94,6 +113,37 @@ static void calculate_capture_size()
s_nCaptureHeight = SPA_ROUND_UP_N(s_nCaptureHeight, kCaptureAlign);
}
+// The colorimetry properties that go with `format`, appended into an already-open EnumFormat
+// object. NV12 keeps offering the BT.601/BT.709 matrix+range choice it always did; the 10-bit
+// packed-RGB formats carry a FIXED, MANDATORY BT.2020 + SMPTE ST.2084 (PQ) description, because
+// that is exactly what paint_pipewire() encodes into them (the HDR screenshot LUT set — a fixed
+// PQ container, independent of what the focused app renders). MANDATORY means a consumer that
+// does not speak PQ/BT.2020 cannot land on these formats by accident: it fails to intersect this
+// pod and falls back to the 8-bit ones listed before it.
+static void add_format_color_params(struct spa_pod_builder *builder, spa_video_format format)
+{
+ if (format == SPA_VIDEO_FORMAT_NV12) {
+ spa_pod_builder_add(builder,
+ SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
+ SPA_VIDEO_COLOR_MATRIX_BT601,
+ SPA_VIDEO_COLOR_MATRIX_BT601,
+ SPA_VIDEO_COLOR_MATRIX_BT709),
+ SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
+ SPA_VIDEO_COLOR_RANGE_16_235,
+ SPA_VIDEO_COLOR_RANGE_16_235,
+ SPA_VIDEO_COLOR_RANGE_0_255),
+ 0);
+ }
+#if GAMESCOPE_PIPEWIRE_HDR
+ else if (is_hdr_video_format(format)) {
+ spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_transferFunction, SPA_POD_PROP_FLAG_MANDATORY);
+ spa_pod_builder_id(builder, SPA_VIDEO_TRANSFER_SMPTE2084);
+ spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_colorPrimaries, SPA_POD_PROP_FLAG_MANDATORY);
+ spa_pod_builder_id(builder, SPA_VIDEO_COLOR_PRIMARIES_BT2020);
+ }
+#endif
+}
+
static void build_format_params(struct spa_pod_builder *builder, spa_video_format format, std::vector<const struct spa_pod *> &params) {
struct spa_rectangle size = SPA_RECTANGLE(s_nCaptureWidth, s_nCaptureHeight);
struct spa_rectangle min_requested_size = { 0, 0 };
@@ -112,18 +162,7 @@ static void build_format_params(struct spa_pod_builder *builder, spa_video_forma
SPA_FORMAT_VIDEO_requested_size, SPA_POD_CHOICE_RANGE_Rectangle( &min_requested_size, &min_requested_size, &max_requested_size ),
SPA_FORMAT_VIDEO_gamescope_focus_appid, SPA_POD_CHOICE_RANGE_Long( 0ll, INT64_MIN, INT64_MAX ),
0);
- if (format == SPA_VIDEO_FORMAT_NV12) {
- spa_pod_builder_add(builder,
- SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
- SPA_VIDEO_COLOR_MATRIX_BT601,
- SPA_VIDEO_COLOR_MATRIX_BT601,
- SPA_VIDEO_COLOR_MATRIX_BT709),
- SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
- SPA_VIDEO_COLOR_RANGE_16_235,
- SPA_VIDEO_COLOR_RANGE_16_235,
- SPA_VIDEO_COLOR_RANGE_0_255),
- 0);
- }
+ add_format_color_params(builder, format);
spa_pod_builder_prop(builder, SPA_FORMAT_VIDEO_modifier, SPA_POD_PROP_FLAG_MANDATORY);
spa_pod_builder_push_choice(builder, &choice_frame, SPA_CHOICE_Enum, 0);
spa_pod_builder_long(builder, modifier); // default
@@ -141,18 +180,7 @@ static void build_format_params(struct spa_pod_builder *builder, spa_video_forma
SPA_FORMAT_VIDEO_requested_size, SPA_POD_CHOICE_RANGE_Rectangle( &min_requested_size, &min_requested_size, &max_requested_size ),
SPA_FORMAT_VIDEO_gamescope_focus_appid, SPA_POD_CHOICE_RANGE_Long( 0ll, INT64_MIN, INT64_MAX ),
0);
- if (format == SPA_VIDEO_FORMAT_NV12) {
- spa_pod_builder_add(builder,
- SPA_FORMAT_VIDEO_colorMatrix, SPA_POD_CHOICE_ENUM_Id(3,
- SPA_VIDEO_COLOR_MATRIX_BT601,
- SPA_VIDEO_COLOR_MATRIX_BT601,
- SPA_VIDEO_COLOR_MATRIX_BT709),
- SPA_FORMAT_VIDEO_colorRange, SPA_POD_CHOICE_ENUM_Id(3,
- SPA_VIDEO_COLOR_RANGE_16_235,
- SPA_VIDEO_COLOR_RANGE_16_235,
- SPA_VIDEO_COLOR_RANGE_0_255),
- 0);
- }
+ add_format_color_params(builder, format);
params.push_back((const struct spa_pod *) spa_pod_builder_pop(builder, &obj_frame));
// for (auto& param : params)
@@ -166,6 +194,14 @@ static std::vector<const struct spa_pod *> build_format_params(struct spa_pod_bu
build_format_params(builder, SPA_VIDEO_FORMAT_BGRx, params);
build_format_params(builder, SPA_VIDEO_FORMAT_NV12, params);
+#if GAMESCOPE_PIPEWIRE_HDR
+ // Listed LAST, on purpose: PipeWire intersects in offer order, so every consumer that
+ // negotiates today's 8-bit stream keeps negotiating it bit-for-bit. Only a consumer that
+ // asks for a 10-bit format by name — and accepts the MANDATORY BT.2020 + PQ colorimetry
+ // above — ever reaches these.
+ build_format_params(builder, SPA_VIDEO_FORMAT_xRGB_210LE, params);
+ build_format_params(builder, SPA_VIDEO_FORMAT_xBGR_210LE, params);
+#endif
return params;
}
@@ -288,7 +324,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
if (s_nCaptureWidth != state->video_info.size.width || s_nCaptureHeight != state->video_info.size.height) {
pwr_log.debugf("renegotiating stream params (size: %dx%d)", s_nCaptureWidth, s_nCaptureHeight);
- uint8_t buf[4096];
+ uint8_t buf[8192];
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
int ret = pw_stream_update_params(state->stream, format_params.data(), format_params.size());
@@ -412,6 +448,12 @@ static void stream_handle_param_changed(void *data, uint32_t id, const struct sp
state->video_info.size.width, state->video_info.size.height,
s_nRequestedWidth, s_nRequestedHeight,
state->video_info.format, state->shm_stride, shm_size, state->dmabuf);
+
+#if GAMESCOPE_PIPEWIRE_HDR
+ if (is_hdr_video_format(state->video_info.format)) {
+ pwr_log.infof("negotiated a 10-bit stream — the composite is encoded as BT.2020 + PQ (HDR10)");
+ }
+#endif
}
static void randname(char *buf)
@@ -450,6 +492,11 @@ uint32_t spa_format_to_drm(uint32_t spa_format)
switch (spa_format)
{
case SPA_VIDEO_FORMAT_NV12: return DRM_FORMAT_NV12;
+#if GAMESCOPE_PIPEWIRE_HDR
+ // Same texture machinery the HDR AVIF screenshot path already uses.
+ case SPA_VIDEO_FORMAT_xRGB_210LE: return DRM_FORMAT_XRGB2101010;
+ case SPA_VIDEO_FORMAT_xBGR_210LE: return DRM_FORMAT_XBGR2101010;
+#endif
default:
case SPA_VIDEO_FORMAT_BGR: return DRM_FORMAT_XRGB8888;
}
@@ -715,7 +762,7 @@ bool init_pipewire(void)
s_nOutputHeight = g_nOutputHeight;
calculate_capture_size();
- uint8_t buf[4096];
+ uint8_t buf[8192];
struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
std::vector<const struct spa_pod *> format_params = build_format_params(&builder);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index ff9ae1f..01b2abf 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -2335,17 +2335,32 @@ static void paint_pipewire()
if ( !s_pPipewireBuffer || !s_pPipewireBuffer->texture )
return;
+ // The negotiated stream format decides the composite's colorimetry. A 10-bit target (the
+ // xRGB_210LE / xBGR_210LE offer, whose SPA props say BT.2020 + PQ) gets the same fixed
+ // BT.2020/PQ container the HDR AVIF screenshot path uses; everything else keeps the SDR
+ // Gamma-2.2 composite it always had.
+ //
+ // Deliberately NOT conditional on the focused app being HDR, unlike bHDRScreenshot: the
+ // stream's colorimetry is fixed for the lifetime of the negotiation, so it must not follow
+ // what the app happens to render. g_ScreenshotColorMgmtLutsHDR is built for EVERY input EOTF
+ // (see update_screenshot_color_mgmt), so SDR content lands in the PQ container at
+ // --hdr-sdr-content-nits, HDR content passes through, and mixed scenes composite correctly.
+ const uint32_t uStreamDrmFormat = s_pPipewireBuffer->texture->drmFormat();
+ const bool bHDRStream = uStreamDrmFormat == DRM_FORMAT_XRGB2101010 ||
+ uStreamDrmFormat == DRM_FORMAT_XBGR2101010;
+
struct FrameInfo_t frameInfo = {};
frameInfo.applyOutputColorMgmt = true;
- frameInfo.outputEncodingEOTF = EOTF_Gamma22;
+ frameInfo.outputEncodingEOTF = bHDRStream ? EOTF_PQ : EOTF_Gamma22;
frameInfo.allowVRR = false;
frameInfo.bFadingOut = false;
// Apply screenshot-style color management.
for ( uint32_t nInputEOTF = 0; nInputEOTF < EOTF_Count; nInputEOTF++ )
{
- frameInfo.lut3D[nInputEOTF] = g_ScreenshotColorMgmtLuts[nInputEOTF].vk_lut3d;
- frameInfo.shaperLut[nInputEOTF] = g_ScreenshotColorMgmtLuts[nInputEOTF].vk_lut1d;
+ auto& luts = bHDRStream ? g_ScreenshotColorMgmtLutsHDR : g_ScreenshotColorMgmtLuts;
+ frameInfo.lut3D[nInputEOTF] = luts[nInputEOTF].vk_lut3d;
+ frameInfo.shaperLut[nInputEOTF] = luts[nInputEOTF].vk_lut1d;
}
const uint64_t ulFocusAppId = s_pPipewireBuffer->gamescope_info.focus_appid;
@@ -0,0 +1,37 @@
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 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)
@@ -0,0 +1,10 @@
# gamescope raises its own scheduling priority; without CAP_SYS_NICE it still runs, just with
# worse frame pacing and a warning per launch. Same capability Arch's own `gamescope` package sets
# — set here too so our binary behaves identically to the one it stands beside.
post_install() {
setcap 'CAP_SYS_NICE=eip' usr/bin/punktfunk-gamescope 2>/dev/null || true
}
post_upgrade() {
post_install
}
+72
View File
@@ -0,0 +1,72 @@
# `punktfunk-gamescope` — nixpkgs' gamescope carrying punktfunk's `pipewire-hdr` patches, exposed
# under its own name so it sits BESIDE the system gamescope instead of replacing it.
#
# An override rather than a from-scratch derivation on purpose: gamescope vendors wlroots,
# vkroots, libliftoff, libdisplay-info, SPIRV-Headers and reshade as git submodules plus two meson
# wraps, and nixpkgs already solves all of that. What we add is two patches and a rename.
#
# `gamescope` in nixpkgs is a wrapper (it wires the WSI layer + capabilities); the buildable
# derivation is `gamescope.unwrapped` — patching the wrapper would be a no-op, so this asserts on
# it rather than silently shipping an unpatched binary.
#
# Version drift: the patches are applied to whatever gamescope your nixpkgs pins, NOT to the
# commit `packaging/gamescope/build-punktfunk-gamescope.sh` names. Both hunks sit in code that has
# been stable across the 3.16 series (`src/pipewire.cpp`'s format builders, `paint_pipewire()` in
# `src/steamcompmgr.cpp`), so this normally just works — and when it does not, the build fails
# loudly at `patchPhase` rather than producing a gamescope that quietly cannot do HDR.
{
lib,
gamescope,
patchDir,
}:
let
unwrapped =
gamescope.unwrapped or (throw ''
punktfunk-gamescope needs `gamescope.unwrapped` (the buildable derivation behind nixpkgs'
gamescope wrapper) and this nixpkgs does not expose it. Update nixpkgs, or build the
compositor with packaging/gamescope/build-punktfunk-gamescope.sh instead.
'');
in
unwrapped.overrideAttrs (old: {
pname = "punktfunk-gamescope";
patches = (old.patches or [ ]) ++ [
"${patchDir}/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch"
"${patchDir}/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch"
];
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
# banner would read `+pfhdr1 (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 "") + ''
substituteInPlace src/meson.build \
--replace-fail \
"vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()" \
"vcs_tag = '${old.version}'"
'';
# Ship ONLY the compositor, renamed. Everything else nixpkgs installs (gamescopectl,
# gamescopereaper, gamescopestream, the WSI layer, .desktop files) belongs to the real gamescope
# package — duplicating it here would put two of each on PATH. The host only execs the
# compositor.
postInstall = (old.postInstall or "") + ''
find $out -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} +
find $out/bin -mindepth 1 ! -name gamescope -delete
mv $out/bin/gamescope $out/bin/punktfunk-gamescope
'';
# `gamescope --version` exits non-zero on some builds; the grep is the real assertion.
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
$out/bin/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' \
|| { echo "punktfunk-gamescope: the +pfhdr marker is missing the patches did not take"; exit 1; }
runHook postInstallCheck
'';
meta = (old.meta or { }) // {
description = "gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming";
mainProgram = "punktfunk-gamescope";
};
})
+35 -1
View File
@@ -163,6 +163,34 @@ in
firewall still streams (it just adds ~2.5 s at session start).
'';
};
gamescopeHdr = mkOption {
type = types.bool;
default = false;
description = ''
Put `punktfunk-gamescope` gamescope carrying punktfunk's `pipewire-hdr` patches on
the host service's PATH and turn HDR on for the gamescope backend
(`PUNKTFUNK_GAMESCOPE_HDR=1`), so a 10-bit-capable client can stream true HDR10
(BT.2020 PQ) off a gamescope virtual output.
It does NOT replace the system's `gamescope`: the binary has its own name and the host
prefers it only for the sessions it spawns itself. Costs a gamescope build from source.
Off by default while the feature soaks (design §4 rollout); the host also stays SDR on
its own if the patched binary is somehow absent, so this is a policy switch, not a
promise.
'';
};
gamescopePackage = mkOption {
type = types.package;
default = self.packages.${system}.punktfunk-gamescope;
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-gamescope";
description = ''
The patched gamescope used when `gamescopeHdr = true`. Override to build it from a
different nixpkgs (the patches apply to whatever gamescope that nixpkgs pins).
'';
};
};
client = {
@@ -324,7 +352,10 @@ in
pkgs.bash
pkgs.coreutils
pkgs.pipewire
];
]
# The HDR-capable gamescope, if enabled. On PATH rather than pinned through
# PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins.
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage;
serviceConfig = {
ExecStart =
"${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream";
@@ -334,6 +365,9 @@ in
(optional (cfg.host.settings != { }) "${hostSettingsFile}")
++ (optional (cfg.host.environmentFile != null) "-${toString cfg.host.environmentFile}");
};
# Layered UNDER `settings`/`environmentFile` (systemd's Environment= loses to a later
# EnvironmentFile), so an explicit `PUNKTFUNK_GAMESCOPE_HDR` in either still wins.
environment = mkIf cfg.host.gamescopeHdr { PUNKTFUNK_GAMESCOPE_HDR = "1"; };
};
})