diff --git a/flake.nix b/flake.nix index 160c2e89..22d75a10 100644 --- a/flake.nix +++ b/flake.nix @@ -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; } diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh index b2ac27ac..6d043bac 100644 --- a/packaging/bazzite/build-sysext.sh +++ b/packaging/bazzite/build-sysext.sh @@ -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" diff --git a/packaging/bazzite/host.env b/packaging/bazzite/host.env index 42699449..53165239 100644 --- a/packaging/bazzite/host.env +++ b/packaging/bazzite/host.env @@ -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 diff --git a/packaging/gamescope/PKGBUILD b/packaging/gamescope/PKGBUILD new file mode 100644 index 00000000..be0c2db0 --- /dev/null +++ b/packaging/gamescope/PKGBUILD @@ -0,0 +1,67 @@ +# Maintainer: unom +# +# 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" +} diff --git a/packaging/gamescope/README.md b/packaging/gamescope/README.md new file mode 100644 index 00000000..3a25da03 --- /dev/null +++ b/packaging/gamescope/README.md @@ -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 ` --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`. + +## 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. diff --git a/packaging/gamescope/build-punktfunk-gamescope.sh b/packaging/gamescope/build-punktfunk-gamescope.sh new file mode 100755 index 00000000..db0e8691 --- /dev/null +++ b/packaging/gamescope/build-punktfunk-gamescope.sh @@ -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 ] [--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" diff --git a/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch b/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch new file mode 100644 index 00000000..751e9889 --- /dev/null +++ b/packaging/gamescope/patches/0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch @@ -0,0 +1,244 @@ +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] 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 ¶ms) { + 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 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 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 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; 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 new file mode 100644 index 00000000..5cd187a7 --- /dev/null +++ b/packaging/gamescope/patches/0002-punktfunk-stamp-the-version-banner-with-pfhdr1.patch @@ -0,0 +1,37 @@ +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/punktfunk-gamescope.install b/packaging/gamescope/punktfunk-gamescope.install new file mode 100644 index 00000000..51d9e94c --- /dev/null +++ b/packaging/gamescope/punktfunk-gamescope.install @@ -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 +} diff --git a/packaging/nix/gamescope.nix b/packaging/nix/gamescope.nix new file mode 100644 index 00000000..040beec5 --- /dev/null +++ b/packaging/nix/gamescope.nix @@ -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"; + }; +}) diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 7a544f7f..55efeb62 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -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"; }; }; })