HDR was a stop bright, in-game HDR was unreachable on every distro, and SDR shipped untagged #210

Merged
enricobuehler merged 5 commits from worktree-hdr-colour-anchor-fixes into main 2026-08-13 21:50:13 +00:00
19 changed files with 548 additions and 117 deletions
+5 -3
View File
@@ -517,15 +517,17 @@ jobs:
bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
--extra-fallback libdisplay-info
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only route to an HDR10 swapchain for a nested game.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
# actually produced a working compositor.
- name: Build the .deb
run: |
gs-cache/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
gs-cache/usr/bin/punktfunk-gamescope --version
bash packaging/debian/build-gamescope-deb.sh --stage gs-cache
- name: Publish to the Gitea apt registry
env:
+10 -6
View File
@@ -293,7 +293,11 @@ jobs:
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
# The WHOLE staged tree, not just the binary: it also carries the Vulkan WSI layer built
# beside the compositor, which is the only thing that can give a nested game an HDR10
# swapchain. Caching the tree rather than a file per artifact is what keeps the next
# addition from needing a change here as well as in every packaging script.
mkdir -p gs-cache && cp -a gs-stage/. gs-cache/
else
# Warn only, even on a tag — the hard gate is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
@@ -311,9 +315,9 @@ jobs:
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
- name: Package punktfunk-gamescope as an RPM
run: |
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
bash packaging/gamescope/build-gamescope-rpm.sh \
--binary gs-cache/punktfunk-gamescope \
--stage gs-cache \
--release "$PF_RELEASE"
else
# Warn only — see the note on the build step. The gate is the last step of this job.
@@ -372,9 +376,9 @@ jobs:
# whose runtime libs are missing from this container must cost the image its HDR, not the
# image itself.
gs=()
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope gs-cache/punktfunk-gamescope)
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
if [ -x gs-cache/usr/bin/punktfunk-gamescope ] && gs-cache/usr/bin/punktfunk-gamescope --version >/dev/null 2>&1; then
gs=(--gamescope-stage gs-cache)
echo "folding in $(gs-cache/usr/bin/punktfunk-gamescope --version 2>&1 | head -1)"
else
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
@@ -56,17 +56,27 @@ enum WindowedPresentMode: String, Sendable {
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
/// brighter highlights into the panel's headroom. This is the missing anchor that made the old HDR path
/// render "way too bright" (no `edrMetadata` no reference-white anchoring); a LARGER value renders
/// dimmer. Matches the host's standard PQ reference white.
/// dimmer.
///
/// This is one half of a pair: the host has to map SDR content into the PQ container at the SAME
/// luminance, and pins it to 203 in `pf-vdisplay`'s `SDR_REFERENCE_WHITE_NITS`. When they disagree
/// every pixel is off by the ratio a gamescope host left on gamescope's own 400-nit default put
/// the stream nearly a stop bright, which read as a glaring, over-saturated Steam UI and washed-out
/// HDR game content at the same time. Change one end without the other and that gap re-opens.
private let hdrReferenceWhiteNits: Float = 203.0
/// PUNKTFUNK_SDR_COLORSPACE=srgb A/B hatch for the SDR layer's colour tag. Today the SDR layer
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
/// stream is displayed with the panel's native primaries mild oversaturation on every P3 Mac.
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
/// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
private let sdrColorspaceOverride: CGColorSpace? = {
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
/// The SDR layer's colour tag. `colorspace = nil` means NO colour matching: the BT.709-encoded
/// stream is handed to the compositor untagged and drawn in the display's native space. That is
/// mild oversaturation on a P3 Mac or iPad, and on a tvOS display composited for HDR it also lifts
/// the black floor the 2026-08-13 field report of greys where blacks should be, which arrived
/// with the client's own HDR switch already OFF, so no other stage had tagged those pixels either.
/// Tagging lets CoreAnimation colour-match into whatever the output actually is, which is the
/// strictly correct rendering, so it is now the default.
///
/// `PUNKTFUNK_SDR_COLORSPACE=none` restores the old untagged look the A/B lever if a panel
/// regresses, or for anyone who preferred the more vivid rendition.
private let sdrColorspace: CGColorSpace? = {
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] != "none" else {
return nil
}
return CGColorSpace(name: CGColorSpace.sRGB)
@@ -425,6 +435,14 @@ public final class MetalVideoPresenter {
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
/// `configure` call is ordered before the thread starts, so it doesn't race).
private var hdrActive = false
/// Has `configureColor` run even once? `hdrActive` starts `false`, so a session that is SDR from
/// the first frame matches the initial state and used to fall straight through `configure`'s
/// guard the layer then kept `make()`'s bare config, which never assigns a colour space, and
/// the SDR stream presented untagged for the whole session. That also made
/// `PUNKTFUNK_SDR_COLORSPACE` dead code on exactly the sessions it was meant to fix, so an
/// operator A/B-ing it in the field saw nothing change. Same-state calls after the first are
/// still no-ops, which is what the guard is for.
private var didConfigureColor = false
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
/// its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
/// derived from the staged display headroom at the top of every `render`.
@@ -592,13 +610,16 @@ public final class MetalVideoPresenter {
stagingLock.lock()
let passthrough = stagedDisplayHeadroom > 1.0
stagingLock.unlock()
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
guard !didConfigureColor || hdr != hdrActive
|| (hdr && passthrough != hdrPassthroughActive)
else { return }
hdrActive = hdr
hdrPassthroughActive = passthrough
#else
guard hdr != hdrActive else { return }
guard !didConfigureColor || hdr != hdrActive else { return }
hdrActive = hdr
#endif
didConfigureColor = true
configureColor(hdr: hdr)
}
@@ -628,9 +649,10 @@ public final class MetalVideoPresenter {
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
} else {
// SDR-composited display: PQ would render untone-mapped (blown out) the
// pf_frag_hdr_tv shader tone-maps to SDR instead.
// pf_frag_hdr_tv shader tone-maps to SDR instead. Its output is BT.709, so it
// carries the same SDR tag as a genuinely SDR session.
layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil
layer.colorspace = sdrColorspace
}
#else
layer.pixelFormat = .rgba16Float
@@ -641,12 +663,11 @@ public final class MetalVideoPresenter {
layer.edrMetadata = makeEDR(lastHdrMeta)
#endif
} else {
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
// colour matching on macOS (the panel's native primaries the long-proven look,
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
// for correct colour matching instead (A/B pending see sdrColorspaceOverride).
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable, tagged so CoreAnimation
// colour-matches it into the output rather than drawing it in the panel's native
// space (see sdrColorspace; PUNKTFUNK_SDR_COLORSPACE=none restores untagged).
layer.pixelFormat = .bgra8Unorm
layer.colorspace = sdrColorspaceOverride
layer.colorspace = sdrColorspace
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil
@@ -758,10 +758,18 @@ public final class StreamViewController: StreamViewControllerBase {
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
/// stop() teardown clears it.
///
/// Gated on the STREAM being HDR (`connection.isHDR`), not just on the user's HDR setting.
/// The criteria below hardcode BT.2020 + ST.2084 PQ, so without that check an ordinary SDR
/// session drove an HDR-capable TV into PQ output which is a standard way to raise the black
/// floor, since the Apple TV switches HDMI to limited-range levels in its HDR modes and a set
/// configured for full-range then renders code 16 as grey. Layout re-runs this, so a session
/// that flips to HDR mid-stream still picks the mode up on the next pass.
private func applyDisplayCriteriaIfNeeded() {
guard let manager = view.window?.avDisplayManager, let connection,
manager.preferredDisplayCriteria == nil,
SessionSettings.current.hdrEnabled
SessionSettings.current.hdrEnabled,
connection.isHDR
else { return }
let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
+4 -2
View File
@@ -262,10 +262,12 @@ pub struct HostConfig {
/// spawn flags included.
pub gamescope_hdr: bool,
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`).
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
/// `None` = 203 nits, BT.2408 reference white, which is what our clients decode against —
/// NOT gamescope's own default of 400, which sits nearly a stop above it. See `pf-vdisplay`'s
/// `SDR_REFERENCE_WHITE_NITS` for why the host pins this rather than letting it float.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
@@ -1237,7 +1237,7 @@ fn write_session_plus_dropin(
wrapper: &std::path::Path,
mode: Mode,
hdr: bool,
wsi_ok: bool,
wsi: WsiPlan,
) -> Result<bool> {
let Some(bind) = arm_session_bind(wrapper) else {
remove_session_plus_dropin();
@@ -1260,11 +1260,7 @@ fn write_session_plus_dropin(
.chain(cursor_args())
.collect::<Vec<_>>()
.join(" "),
wsi = if wsi_ok {
String::new()
} else {
wsi_off_unit_lines()
},
wsi = wsi.unit_lines(),
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
Ok(true)
@@ -1579,7 +1575,7 @@ fn ensure_box_gamescope_mode(mode: Mode, hdr: bool) -> Result<u32> {
// Same two fixes the transient path gets, but this unit is the BOX's own — they have to arrive
// as a drop-in, and `daemon-reload` before the restart or systemd runs the old unit.
let mut bound = match write_gamescope_bin_wrapper()
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, wsi_layer_matches_our_gamescope()))
.and_then(|w| write_session_plus_dropin(&w, mode, hdr, WsiPlan::resolve()))
{
Ok(true) => {
// Record it BEFORE the restart, and persist it: from this instant the box's OWN
@@ -4215,25 +4211,103 @@ const WSI_OFF_ENV: [(&str, &str); 2] = [
("ENABLE_GAMESCOPE_WSI", "0"),
];
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
fn wsi_off_setenv_args() -> Vec<String> {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
/// Our own WSI layer's implicit-layer manifest, laid down beside the compositor by
/// `packaging/gamescope/build-punktfunk-gamescope.sh`.
///
/// It is built from the SAME source tree at the SAME rev as `punktfunk-gamescope`, so the layer and
/// the compositor cannot disagree about `gamescope_swapchain` — which is what makes every "is the
/// distro's layer close enough to ours?" guess unnecessary. It carries its own layer name and its
/// own `enable_environment`, so it coexists with the distro's rather than replacing it.
const OUR_WSI_LAYER_DIR_DEFAULT: &str = "/usr/lib/punktfunk/vulkan/implicit_layer.d";
const OUR_WSI_LAYER_MANIFEST_NAME: &str = "punktfunk_gamescope_wsi.json";
/// Where our layer's manifest directory is. FHS by default, because that is where every distro
/// package puts it; `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` overrides for a store with no `/usr` to
/// speak of — on NixOS the layer lives inside the gamescope derivation and the module points this
/// at it, the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
fn our_wsi_layer_dir() -> String {
std::env::var("PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| OUR_WSI_LAYER_DIR_DEFAULT.to_string())
}
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn wsi_off_unit_lines() -> String {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
/// Which Vulkan WSI layer a session we spawn should run with. Three states, decided ONCE per
/// launch because [`WsiPlan::resolve`] can spawn `--version` probes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum WsiPlan {
/// Our own matching layer is installed: enable it, suppress the distro's. Games get HDR.
Ours,
/// No layer of ours, and the distro's version triple matches the gamescope we run, so it is
/// probably built against the same protocol. Leave the box exactly as it is.
DistroKept,
/// No layer of ours, and the distro's cannot be trusted. Disable it — a mismatched layer kills
/// every Vulkan client — and accept that no game in this session can get an HDR10 swapchain.
DistroDisabled,
}
impl WsiPlan {
/// ⚠️ Spawns up to two `gamescope --version` probes in the fallback arms, so resolve once and
/// pass the result around rather than calling this per use site.
fn resolve() -> Self {
let manifest = std::path::Path::new(&our_wsi_layer_dir()).join(OUR_WSI_LAYER_MANIFEST_NAME);
if manifest.is_file() {
Self::Ours
} else if wsi_layer_matches_our_gamescope() {
Self::DistroKept
} else {
Self::DistroDisabled
}
}
/// The environment this plan needs, as `(name, value)` pairs.
fn env(self) -> Vec<(&'static str, String)> {
match self {
// `VK_ADD_IMPLICIT_LAYER_PATH` ADDS to the loader's implicit-layer search (loader
// 1.3.234+), so the box's own layer directories keep working; the distro's gamescope
// layer is then switched off by name through its own variables, leaving exactly one
// gamescope WSI layer live — ours.
Self::Ours => vec![
("VK_ADD_IMPLICIT_LAYER_PATH", our_wsi_layer_dir()),
("PUNKTFUNK_GAMESCOPE_WSI", "1".to_string()),
("DISABLE_GAMESCOPE_WSI", "1".to_string()),
("ENABLE_GAMESCOPE_WSI", "0".to_string()),
],
Self::DistroKept => Vec::new(),
Self::DistroDisabled => WSI_OFF_ENV
.iter()
.map(|(name, value)| (*name, (*value).to_string()))
.collect(),
}
}
/// As `systemd-run` arguments, for the transient unit.
fn setenv_args(self) -> Vec<String> {
self.env()
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// As unit-file lines, for the box-session drop-in. Trailing newline included, so whatever the
/// body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn unit_lines(self) -> String {
self.env()
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// ⚠️ **Fallback only** — reached from [`WsiPlan::resolve`] just when our own layer is absent (a
/// `punktfunk-gamescope` package older than the one that started shipping it). It is a guess, and a
/// guess in BOTH directions: a distro at the same upstream tag that patched the protocol compares
/// EQUAL and keeps a layer that will kill every Vulkan client, while a distro at a different tag
/// with a byte-identical protocol compares unequal and loses HDR for nothing. Do not build anything
/// new on it; ship the layer instead, which is what [`WsiPlan::Ours`] does.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
/// run our own build. When the two disagree the compositor rejects the client's
/// `swapchain_feedback` ("message too short") and **kills every Vulkan client** — Steam never
@@ -4244,8 +4318,13 @@ fn wsi_off_unit_lines() -> String {
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
/// metadata) — far cheaper than a client that cannot start.
/// Disabling it costs the layer's extras (XWayland bypass, present-mode control) and, on an HDR
/// session, **HDR for games**. The layer is the ONLY route to an HDR10 swapchain under gamescope:
/// gamescope advertises no runtime colour-management protocol for a Mesa/NVIDIA WSI to negotiate
/// through, so with the layer inactive a client never gets a Surface-state block and lands on
/// `hdr10_format=None` (measured on a Deck OLED — see the Gamescope WSI notes in the flatpak
/// manifest). Still cheaper than a client that cannot start at all, but not free, which is why
/// [`launch_session`] says so out loud when it fires on an HDR session.
///
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
fn wsi_layer_matches_our_gamescope() -> bool {
@@ -4304,16 +4383,30 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
let wsi = WsiPlan::resolve();
if wsi == WsiPlan::DistroDisabled {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
Steam's own UI is not one, so what you see is a game that runs with sound and input \
on a black screen, with no other symptom."
than the one we run, and no punktfunk layer is installed to use instead disabling \
it for this session (DISABLE_GAMESCOPE_WSI=1, which the session script cannot clobber \
the way it clobbers ENABLE_GAMESCOPE_WSI). Left enabled it rejects the client's \
swapchain_feedback and every Vulkan client dies; Steam's own UI is not one, so what \
you see is a game that runs with sound and input on a black screen, with no other \
symptom. Upgrading the punktfunk-gamescope package fixes this properly it ships a \
layer built from the same tree as the compositor."
);
// The HDR decisions are made independently — `hdr_args` never consults the layer plan — so
// without this an HDR session launches advertising HDR while having made game HDR
// unreachable in the same breath, and nothing anywhere says so.
if hdr {
tracing::warn!(
"gamescope: this session negotiated HDR, but with the WSI layer disabled no game \
in it can get an HDR10 swapchain that layer is the only route to one. The \
stream itself stays HDR (the capture really is PQ/BT.2020, and Steam's UI and the \
desktop ride the same container), so what breaks is GAME HDR specifically: a \
title told to render HDR renders it into an SDR swapchain and looks washed out."
);
}
}
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
let mut cmd = Command::new("systemd-run");
@@ -4321,10 +4414,8 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
for arg in bind.map(SessionBind::run_args).unwrap_or_default() {
cmd.arg(arg);
}
if !wsi_ok {
for arg in wsi_off_setenv_args() {
cmd.arg(arg);
}
for arg in wsi.setenv_args() {
cmd.arg(arg);
}
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
@@ -4559,23 +4650,38 @@ fn add_bare_gamescope_args(
/// fine meanwhile.)
/// * `--hdr-sdr-content-nits` maps SDR content into the PQ container. Everything that is not an
/// HDR game — the desktop, the Steam overlay, an SDR title — rides through it, so it decides
/// how bright "white" lands on the client's panel. Only passed when the operator set the knob;
/// otherwise gamescope's own default (400) applies.
/// how bright "white" lands on the client's panel. We always pass it, because the two ends have
/// to agree on where diffuse white sits and gamescope's own default does not match ours — see
/// [`SDR_REFERENCE_WHITE_NITS`].
fn hdr_args(hdr: bool) -> Vec<String> {
if !hdr {
return Vec::new();
}
let mut args = vec![
let nits = pf_host_config::config()
.gamescope_sdr_nits
.unwrap_or(SDR_REFERENCE_WHITE_NITS);
vec![
"--hdr-enabled".to_string(),
"--hdr-debug-force-support".to_string(),
];
if let Some(nits) = pf_host_config::config().gamescope_sdr_nits {
args.push("--hdr-sdr-content-nits".to_string());
args.push(nits.to_string());
}
args
"--hdr-sdr-content-nits".to_string(),
nits.to_string(),
]
}
/// Where diffuse white sits, in nits, for SDR content carried inside an HDR session's PQ container.
///
/// 203 is BT.2408 "HDR Reference White", and it is the value every first-party client anchors to:
/// the Apple presenter hands exactly 203 to `CAEDRMetadata.hdr10(opticalOutputScale:)`. gamescope's
/// own default is 400, so leaving the flag off put the host nearly a stop (400/203 ≈ 1.97×) above
/// what the client decodes against — the 2026-08-13 field report where Steam's Big Picture UI read
/// as glaring and over-saturated on an iPad while HDR game content came out washed out. Both are
/// the same error: the UI lands above SDR white, and the client's tone-mapper then works from a
/// reference point the host never used, flattening the content around it.
///
/// This is the anchor, not a taste knob — `PUNKTFUNK_GAMESCOPE_SDR_NITS` is still there for an
/// operator who wants a brighter or dimmer desktop, and moving it away from 203 re-opens the gap.
const SDR_REFERENCE_WHITE_NITS: u32 = 203;
/// `--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.
@@ -4778,12 +4884,12 @@ mod tests {
any_output_size_is, cgroup_is_punktfunk_owned, cgroup_under_user_manager,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
dm_survives_masked_unit, game_hz, gamescope_output_size, hdr_args, is_steam_launch,
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, our_wsi_layer_dir,
plan_bind, release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
wsi_off_setenv_args, wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan,
BoxOutputSize, DmHelperError, SessionBind, TakeoverState, AUTOLOGIN_MASKED,
DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN,
WSI_OFF_ENV, X11_SOCKET_DIR,
};
fn argv(s: &str) -> Vec<String> {
@@ -5611,8 +5717,8 @@ mod tests {
);
// Both spellings reach both launch paths, and neither may lose the other.
let args = wsi_off_setenv_args();
let lines = wsi_off_unit_lines();
let args = WsiPlan::DistroDisabled.setenv_args();
let lines = WsiPlan::DistroDisabled.unit_lines();
for (name, value) in WSI_OFF_ENV {
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
assert!(
@@ -5625,4 +5731,31 @@ mod tests {
// lines above it rely on the same contract and the order has changed before.
assert!(lines.ends_with('\n'));
}
/// The whole point of shipping our own layer is that BOTH halves happen in one session: ours is
/// switched on AND the distro's is forced off. Enabling ours while leaving theirs live would
/// put two gamescope WSI layers in the loader's implicit set, and dropping ours while forcing
/// theirs off is just the old no-game-HDR behaviour wearing a new name — so assert the pair,
/// not either half.
#[test]
fn our_own_layer_is_enabled_and_the_distro_one_forced_off_together() {
let env = WsiPlan::Ours.env();
let get = |k: &str| {
env.iter()
.find(|(name, _)| *name == k)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("{k} missing from the Ours plan"))
};
assert_eq!(get("VK_ADD_IMPLICIT_LAYER_PATH"), our_wsi_layer_dir());
assert_eq!(get("PUNKTFUNK_GAMESCOPE_WSI"), "1");
// The clobber-proof one, for exactly the reason the test above states.
assert_eq!(get("DISABLE_GAMESCOPE_WSI"), "1");
assert_eq!(get("ENABLE_GAMESCOPE_WSI"), "0");
// `DistroKept` must stay genuinely inert: it is the arm that runs on a box we decided not
// to touch, so a stray variable there would change behaviour we promised not to change.
assert!(WsiPlan::DistroKept.env().is_empty());
assert!(WsiPlan::DistroKept.unit_lines().is_empty());
}
}
+2 -1
View File
@@ -93,8 +93,9 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
| `PUNKTFUNK_GAMESCOPE_APP` | command | For an ad-hoc bare-gamescope session, the nested command to run (e.g. `vkcube`). |
| `PUNKTFUNK_GAMESCOPE_HDR` | `1` · `0` *(default on)* | Allow HDR (10-bit BT.2020 PQ) sessions on the gamescope backend. Needs the `punktfunk-gamescope` build — see [HDR on gamescope](/docs/gamescope#hdr-on-gamescope); without the build, sessions stream SDR. Set `0` to force SDR. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = gamescope's own default of 400. |
| `PUNKTFUNK_GAMESCOPE_SDR_NITS` | e.g. `400` | On an HDR gamescope session, the luminance SDR content (desktop, Steam overlay, SDR games) is mapped to inside the PQ container. Unset = 203, BT.2408 reference white, which is what our clients decode against (gamescope's own default of 400 sits nearly a stop above it). |
| `PUNKTFUNK_GAMESCOPE_BIN` | path | Force a specific gamescope binary for the sessions the host spawns. Unset = prefer `punktfunk-gamescope` on `PATH`, then `gamescope`. |
| `PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR` | path | Directory holding our Vulkan WSI layer's manifest — the layer that lets a game nested under gamescope get an HDR10 swapchain. Unset = `/usr/lib/punktfunk/vulkan/implicit_layer.d`, where every distro package installs it. The NixOS module sets this for you, since the layer lives inside the gamescope derivation there. If no manifest is found the host leaves the system's own layer alone and games stay SDR. |
| `PUNKTFUNK_SESSION_WATCH` | `1` · `0` | Follow a Gaming ↔ Desktop switch **mid-stream** (rebuild the backend in place, no reconnect). **On by default** on Bazzite/SteamOS; set `0` to disable. |
| `PUNKTFUNK_GAMESCOPE_GRAB_CURSOR` | `1` | Add `--force-grab-cursor` to a bare gamescope session the host spawns **to run an app or game** (never the empty keep-alive session), forcing relative-mouse capture so FPS mouselook works over the injected pointer. **Off by default** — relative mode breaks absolute-pointer titles and menus, so turn it on per host. |
| `PUNKTFUNK_GAMESCOPE_SPLASH` | `1` · `0` *(default on)* | Run the built-in splash client inside each bare gamescope session the host spawns. **Leave it on**: gamescope only produces capture buffers once something paints, and a Steam launch paints nothing for its whole bootstrap — without the splash a fresh session starves and times out. `0` is a debugging escape hatch. |
+5 -2
View File
@@ -85,8 +85,11 @@ happens. Staying on attach also leaves the stream with no cursor;
[HDR on gamescope](/docs/gamescope#hdr-on-gamescope) has the fix for that half.
SDR content rides the same PQ container — the desktop, the Steam overlay, an SDR game — mapped in at
`PUNKTFUNK_GAMESCOPE_SDR_NITS` (gamescope's own default is 400). That is the knob when white looks
too bright or too dim on your TV.
`PUNKTFUNK_GAMESCOPE_SDR_NITS`, which defaults to **203 nits**. That is BT.2408 reference white, and
it is the level our clients decode against, so the two ends agree out of the box. gamescope's own
default is 400, nearly a stop brighter; hosts that let it float showed a glaring, over-saturated
Steam UI and washed-out HDR game content on the same stream. Move the knob if you want a brighter or
dimmer desktop, but be aware that moving it re-opens that gap.
### Linux + GNOME
+4
View File
@@ -69,6 +69,10 @@
# from source and would make `nix flake check` an hour long.
punktfunk-gamescope = pkgs.callPackage ./packaging/nix/gamescope.nix {
patchDir = ./packaging/gamescope/patches;
# Shared verbatim with build-punktfunk-gamescope.sh, which is the whole reason it is a
# file: the FHS packages and the Nix store must rename the WSI layer identically, or
# the host looks for a layer name that only one of them produces.
manifestRewriter = ./packaging/gamescope/rewrite-wsi-layer-manifest.py;
};
};
in
+9 -1
View File
@@ -77,7 +77,15 @@ if [ -n "$GAMESCOPE" ]; then
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
# The package carries the Vulkan WSI layer alongside the compositor and the extraction above takes
# the whole `usr`, so this is an assertion rather than a step — but a silent one is exactly how
# this went wrong before: an image with the compositor and no layer streams HDR while every game
# inside it renders SDR, and nothing anywhere says why.
for f in usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so \
usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json; do
[ -f "$STAGE/$f" ] || { echo "$GAMESCOPE has no $f — no game HDR without it" >&2; exit 1; }
done
echo "folded in $("$GS_BIN" --version 2>&1 | head -1) + its WSI layer"
fi
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
+27 -10
View File
@@ -24,15 +24,20 @@
#
# Usage:
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
# [--gamescope path/to/punktfunk-gamescope] \
# [--gamescope-stage path/to/gamescope-destdir] \
# 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
# --gamescope-stage 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.
# --destdir stage --prefix /usr`) and passes that DESTDIR in. Omit it and the image is exactly what
# it was — the host then stays SDR on that backend, by design.
#
# A directory rather than the binary, because the tree also carries the Vulkan WSI layer built beside
# the compositor. That layer is the only route to an HDR10 swapchain for a game nested under
# gamescope, so an image with the compositor and without it would stream HDR while every game in it
# rendered SDR.
#
# 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
@@ -44,7 +49,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${2:?}"; shift 2 ;;
--gamescope) GAMESCOPE="${2:?}"; shift 2 ;;
--gamescope-stage) GAMESCOPE="${2:?}"; shift 2 ;;
*) RPMS+=("$1"); shift ;;
esac
done
@@ -87,15 +92,27 @@ 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
# The HDR-capable gamescope, when one was built (see --gamescope-stage 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"
GS_BIN="$GAMESCOPE/usr/bin/punktfunk-gamescope"
GS_LAYER_SO="$GAMESCOPE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
GS_LAYER_JSON="$GAMESCOPE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$GS_BIN" ] || { echo "no such executable: $GS_BIN" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GS_BIN has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
# Fatal for the same reason the marker check is: an image carrying the compositor without its
# layer streams HDR while every game inside it renders SDR, and says nothing about why.
for f in "$GS_LAYER_SO" "$GS_LAYER_JSON"; do
[ -f "$f" ] || { echo "$f missing — the gamescope stage has no WSI layer" >&2; exit 1; }
done
install -Dm0755 "$GS_BIN" "$STAGE/usr/bin/punktfunk-gamescope"
install -Dm0755 "$GS_LAYER_SO" \
"$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$GS_LAYER_JSON" \
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
+28 -5
View File
@@ -10,24 +10,38 @@
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
#
# `--stage` is the DESTDIR build-punktfunk-gamescope.sh wrote, not a single binary: that tree carries
# the compositor AND the Vulkan WSI layer built beside it, and a game nested under gamescope gets its
# HDR10 swapchain from that layer or from nowhere. Taking the whole tree is what stops the next file
# in the package needing a new flag in every packaging script.
#
# Usage:
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
# --stage gs-cache [--arch amd64]
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
set -euo pipefail
BINARY=""
SRC_STAGE=""
DEB_ARCH=""
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--stage) SRC_STAGE="${2:?--stage needs a path}"; shift 2 ;;
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -n "$SRC_STAGE" ] || { echo "ERROR: --stage is required" >&2; exit 2; }
# The layout build-punktfunk-gamescope.sh writes under its --destdir/--prefix.
BINARY="$SRC_STAGE/usr/bin/punktfunk-gamescope"
LAYER_SO="$SRC_STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_JSON="$SRC_STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
# Hard, not best-effort: a package carrying the compositor without its layer looks perfectly healthy
# and then silently denies every game an HDR10 swapchain.
for f in "$LAYER_SO" "$LAYER_JSON"; do
[ -f "$f" ] || { echo "ERROR: $f missing from the stage — no game HDR without it" >&2; exit 1; }
done
PKG="punktfunk-gamescope"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
@@ -60,6 +74,12 @@ trap 'rm -rf "$STAGE"' EXIT
# root-only and some tooling refuses it.
chmod 0755 "$STAGE"
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
# /usr/lib/punktfunk, not a multiarch triplet dir: the layer manifest carries that absolute path
# baked in at build time, so the two have to agree. Nothing links the .so by soname — the Vulkan
# loader dlopens it by exactly that path — so multiarch has no say here.
install -Dm0755 "$LAYER_SO" "$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$LAYER_JSON" \
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
mkdir -p "$STAGE/DEBIAN"
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
@@ -103,7 +123,10 @@ fi
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
echo " into the capture stream, so the fps/stats readout is visible remotely."
echo " ."
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
echo " Installed as /usr/bin/punktfunk-gamescope, with its matching Vulkan WSI layer under"
echo " /usr/lib/punktfunk. The layer has its own name and its own enable variable, so it sits"
echo " beside your gamescope package's rather than replacing it; your system gamescope is"
echo " untouched."
} > "$STAGE/DEBIAN/control"
mkdir -p dist
+13 -3
View File
@@ -98,10 +98,20 @@ 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.
# The compositor, under our own name: a full `meson install` would also lay down
# gamescopectl / gamescopereaper / gamescopestream, colliding file-for-file with the distro's
# `gamescope` package. The host only ever execs the compositor.
install -Dm755 "${srcdir}/stage/usr/bin/punktfunk-gamescope" \
"${pkgdir}/usr/bin/punktfunk-gamescope"
# ...and the WSI layer built beside it. NOT a collision: the build script renamed the layer and
# rehomed it under /usr/lib/punktfunk, so it sits alongside the distro's rather than over it, and
# the host enables whichever it means to per session. Without this file no game in a punktfunk
# gamescope session can get an HDR10 swapchain at all — that layer is the only route to one.
install -Dm755 "${srcdir}/stage/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so" \
"${pkgdir}/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm644 "${srcdir}/stage/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json" \
"${pkgdir}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
install -Dm644 gamescope/LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+22 -5
View File
@@ -7,19 +7,24 @@
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
# a session that told every game the display was 60 Hz.
#
# The binary is NOT built here; CI builds it once per Fedora major and caches it
# Nothing is BUILT here; CI builds once per Fedora major and caches the staged tree
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
# rebuilding.
#
# `--stage` is the DESTDIR that build-punktfunk-gamescope.sh wrote, not a single binary: that tree
# carries the compositor AND the WSI layer built beside it, and a game gets an HDR10 swapchain from
# that layer or from nowhere. Taking the whole tree rather than a file per artifact is deliberate —
# it is what stops the next file added to the package needing a new flag in four packaging scripts.
#
# Usage:
# bash packaging/gamescope/build-gamescope-rpm.sh \
# --binary gs-cache/punktfunk-gamescope \
# --stage gs-cache \
# [--version 3.16.25] [--release 1] [--outdir dist]
#
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
set -euo pipefail
BINARY=""
STAGE=""
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
VERSION=""
@@ -28,7 +33,7 @@ OUTDIR="dist"
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
--stage) STAGE="${2:?--stage needs a path}"; shift 2 ;;
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
@@ -36,8 +41,18 @@ while [ $# -gt 0 ]; do
esac
done
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
[ -n "$STAGE" ] || { echo "ERROR: --stage is required" >&2; exit 2; }
# The layout build-punktfunk-gamescope.sh writes under its --destdir/--prefix.
BINARY="$STAGE/usr/bin/punktfunk-gamescope"
LAYER_SO="$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_JSON="$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
# Hard, not best-effort. A package that carries the compositor without its layer looks completely
# healthy and then silently denies every game an HDR10 swapchain — the failure this whole change
# exists to end. Better to fail the packaging step than to ship that quietly again.
for f in "$LAYER_SO" "$LAYER_JSON"; do
[ -f "$f" ] || { echo "ERROR: $f missing from the stage — no game HDR without it" >&2; exit 1; }
done
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR"
@@ -66,6 +81,8 @@ TOP="$(mktemp -d)"
trap 'rm -rf "$TOP"' EXIT
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
install -m0755 "$LAYER_SO" "$TOP/SOURCES/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -m0644 "$LAYER_JSON" "$TOP/SOURCES/punktfunk_gamescope_wsi.json"
mkdir -p "$OUTDIR"
rpmbuild \
@@ -90,10 +90,14 @@ echo "==> configuring"
# test suite is not our job either way.
# -Denable_openvr_support the VR integration pulls the openvr submodule + its build for a
# code path a headless capture session never enters.
# -Denable_gamescope_wsi_layer the WSI layer is a SEPARATE artifact the distro's gamescope
# package already installs; ours must not collide with it.
# (The layer the nested games load is that one — it is version-
# independent of the compositor binary.)
# -Denable_gamescope_wsi_layer ON, and installed under our own name below. This used to be off,
# on the grounds that the distro's gamescope package already ships a
# layer and that the layer is "version-independent of the compositor
# binary". That second half is FALSE: the layer and the compositor
# speak `gamescope_swapchain` to each other, and when they disagree
# the compositor rejects the client's `swapchain_feedback` and every
# Vulkan client dies on a black screen. A compositor we ship needs
# the layer we built beside it.
#
# `force_fallback_for` includes **wlroots** on purpose, and it is load-bearing for a binary we
# SHIP: gamescope vendors a wlroots submodule, but meson prefers a system one when the build host
@@ -124,7 +128,7 @@ meson setup "$BUILD" "$SRCDIR" \
-Dpipewire=enabled \
-Denable_tests=false \
-Denable_openvr_support=false \
-Denable_gamescope_wsi_layer=false
-Denable_gamescope_wsi_layer=true
echo "==> building"
ninja -C "$BUILD" ${JOBS:+-j "$JOBS"}
@@ -149,6 +153,36 @@ DEST="${DESTDIR}${PREFIX}/bin/punktfunk-gamescope"
echo "==> installing $DEST"
install -Dm755 "$BIN" "$DEST"
# The WSI layer, under OUR name, at OUR path.
#
# A game nested under gamescope gets an HDR10 swapchain from this layer and from nothing else —
# gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
# through — so a compositor shipped WITHOUT a matching layer simply cannot do HDR for games. Built
# from this same tree at this same rev, so the two can never drift apart; that is the whole point,
# and it is what makes the host's old "compare version triples and hope" check unnecessary.
#
# It must not collide with the distro's layer and must be switchable independently of it, so the
# generated manifest is rewritten to carry our layer name, our library path and our own
# enable/disable variables. The Vulkan loader keys implicit layers on that NAME, so with a distinct
# one both layers can sit installed side by side and the host picks per session.
#
# python3 rather than sed because meson is itself a Python program — it is guaranteed present on any
# host that got this far — and a JSON edit belongs in a JSON parser.
LAYER_SO=$(find "$BUILD" -type f -name 'libVkLayer_*gamescope_wsi*.so' | head -1)
LAYER_SRC_JSON=$(find "$BUILD" -type f -name '*gamescope_wsi*.json' | head -1)
[ -n "$LAYER_SO" ] && [ -n "$LAYER_SRC_JSON" ] || {
echo "the WSI layer did not build (.so=${LAYER_SO:-none} .json=${LAYER_SRC_JSON:-none}) — without" >&2
echo "it no game in a punktfunk gamescope session can get an HDR10 swapchain" >&2
exit 1
}
LAYER_LIB_PATH="${PREFIX}/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
LAYER_DEST_JSON="${DESTDIR}${PREFIX}/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
echo "==> installing ${DESTDIR}${LAYER_LIB_PATH}"
install -Dm755 "$LAYER_SO" "${DESTDIR}${LAYER_LIB_PATH}"
install -d "$(dirname "$LAYER_DEST_JSON")"
python3 "$(dirname "$0")/rewrite-wsi-layer-manifest.py" \
"$LAYER_SRC_JSON" "$LAYER_DEST_JSON" "$LAYER_LIB_PATH"
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.
+33 -2
View File
@@ -9,7 +9,7 @@
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
#
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --stage <destdir-the-build-script-wrote>
Name: punktfunk-gamescope
Version: %{pf_version}
Release: %{pf_release}%{?dist}
@@ -19,6 +19,12 @@ Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor
License: BSD-2-Clause
URL: https://git.unom.io/unom/punktfunk
Source0: punktfunk-gamescope
# The Vulkan WSI layer built from the same tree at the same rev as the compositor above. A game
# nested under gamescope gets its HDR10 swapchain from this layer and from nothing else, and a layer
# built for a DIFFERENT gamescope kills every Vulkan client — so the two ship together or the
# package is a trap.
Source1: libVkLayer_PUNKTFUNK_gamescope_wsi.so
Source2: punktfunk_gamescope_wsi.json
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
@@ -49,7 +55,10 @@ packaging/gamescope/patches:
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
stream, so the fps/stats readout is visible to someone watching remotely.
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
Installed as /usr/bin/punktfunk-gamescope, with its matching Vulkan WSI layer under
/usr/lib/punktfunk. The layer carries its own name and its own enable variable, so it sits beside
the one your gamescope package installs rather than replacing it, and only sessions punktfunk-host
starts switch to it. Your system gamescope is untouched.
%prep
# Nothing to unpack: Source0 IS the binary.
@@ -60,6 +69,14 @@ Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
%install
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
# /usr/lib, spelled literally rather than %{_libdir}, which is /usr/lib64 here. The layer's manifest
# carries an ABSOLUTE library_path baked in at build time (/usr/lib/punktfunk/...), so this path and
# that string have to agree or the loader finds a manifest pointing at nothing. Nothing links this
# .so by soname — the Vulkan loader dlopens it by that absolute path — so there is no multilib
# question to answer, and a private vendor directory is where it belongs.
install -Dm0755 %{SOURCE1} %{buildroot}/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so
install -Dm0644 %{SOURCE2} %{buildroot}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json
%check
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
@@ -71,8 +88,22 @@ install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
exit 1
}
# The manifest's absolute library_path must name the file we actually installed. Getting this wrong
# (%{_libdir} on a multilib box, a renamed .so) produces a package that installs cleanly and then
# does nothing at all — the loader reads a manifest, finds no library, and moves on in silence.
LAYER_LIB="$(grep -o '"library_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
%{buildroot}/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json \
| sed 's/.*"\(\/[^"]*\)".*/\1/')"
[ -f "%{buildroot}${LAYER_LIB}" ] || {
echo "punktfunk-gamescope: the layer manifest points at ${LAYER_LIB}, which this package does" >&2
echo " not install games would silently get no HDR swapchain" >&2
exit 1
}
%files
%{_bindir}/punktfunk-gamescope
/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so
/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json
%changelog
# Generated per build; see the git history for the patch set's own changes.
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Rewrite gamescope's generated Vulkan layer manifest so OUR copy of the layer can be installed
beside the distro's instead of colliding with it.
A game nested under gamescope gets its HDR10 swapchain from the FROG WSI layer and from nothing
else, and that layer speaks `gamescope_swapchain` to the compositor: a layer built for a DIFFERENT
gamescope makes the compositor reject the client's swapchain_feedback, and every Vulkan client dies
on a black screen with sound and input and no error. So a compositor we ship needs the layer we
built beside it which means two gamescope WSI layers on one box.
Three fields make that safe, and the loader is why:
* `name` the Vulkan loader deduplicates implicit layers by name, and with both called
VK_LAYER_FROG_gamescope_wsi which one wins is unspecified. A distinct name is what lets both sit
installed at once.
* `library_path` made absolute, so resolution never depends on where the loader found the
manifest.
* `enable_environment` / `disable_environment` our own gates, so the host can switch ours ON and
the distro's OFF in the same session. Sharing ENABLE_GAMESCOPE_WSI would make that impossible.
Everything else is passed through untouched, `functions` above all: it names the layer's entry
points, and a manifest with the wrong ones is a layer that silently never loads.
Used by build-punktfunk-gamescope.sh (FHS packaging) and packaging/nix/gamescope.nix (the Nix store),
which is the point of it being a file rather than a heredoc the two must not drift.
Usage: rewrite-wsi-layer-manifest.py <src.json> <dst.json> <installed-library-path>
"""
import json
import sys
LAYER_NAME = "VK_LAYER_PUNKTFUNK_gamescope_wsi"
ENABLE_VAR = "PUNKTFUNK_GAMESCOPE_WSI"
DISABLE_VAR = "PUNKTFUNK_GAMESCOPE_WSI_DISABLE"
def main(argv):
if len(argv) != 4:
print(__doc__, file=sys.stderr)
return 2
src, dst, lib = argv[1:4]
with open(src) as f:
manifest = json.load(f)
layer = manifest.get("layer")
if not isinstance(layer, dict):
print(f"{src}: no 'layer' object — not a Vulkan layer manifest", file=sys.stderr)
return 1
# A manifest that never named the entry points would produce a layer that loads and does
# nothing, which is indistinguishable on a running box from "this GPU has no HDR".
if not layer.get("functions") and not layer.get("library_path"):
print(f"{src}: neither 'functions' nor 'library_path' — refusing to rewrite", file=sys.stderr)
return 1
layer["name"] = LAYER_NAME
layer["library_path"] = lib
layer["enable_environment"] = {ENABLE_VAR: "1"}
layer["disable_environment"] = {DISABLE_VAR: "1"}
with open(dst, "w") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+38 -4
View File
@@ -31,7 +31,9 @@
{
lib,
gamescope,
python3,
patchDir,
manifestRewriter,
}:
let
# As of nixos-unstable (checked 2026-07-28) `gamescope` IS the buildable derivation — pname
@@ -81,14 +83,39 @@ unwrapped.overrideAttrs (old: {
"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.
# Ship the compositor, renamed, AND the WSI layer built beside it. Everything else nixpkgs
# installs (gamescopectl, gamescopereaper, gamescopestream, .desktop files) belongs to the real
# gamescope package — duplicating it here would put two of each on PATH.
#
# The layer is not dressing: a game nested under this compositor gets its HDR10 swapchain from it
# or from nowhere, and a layer built for a DIFFERENT gamescope makes the compositor reject the
# client's swapchain_feedback and kills every Vulkan client. So it travels with the binary it was
# built against. It is renamed and re-homed under $out/lib/punktfunk, with its own enable
# variable, so it sits beside the system gamescope's layer rather than shadowing it — the Vulkan
# loader deduplicates implicit layers by name, so two of the same name would be a coin toss.
#
# Staged through $TMPDIR because the prune below removes $out/lib and $out/share wholesale.
postInstall = (old.postInstall or "") + ''
layerSo=$(find $out -type f -name 'libVkLayer_*gamescope_wsi*.so' | head -1)
layerJson=$(find $out -type f -name '*gamescope_wsi*.json' | head -1)
if [ -z "$layerSo" ] || [ -z "$layerJson" ]; then
echo "punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game under the" >&2
echo " compositor could ever obtain an HDR10 swapchain" >&2
exit 1
fi
cp "$layerSo" "$TMPDIR/pf-layer.so"
${python3}/bin/python3 ${manifestRewriter} \
"$layerJson" "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
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
install -Dm0755 "$TMPDIR/pf-layer.so" \
"$out/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$TMPDIR/pf-layer.json" \
"$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
'';
# `gamescope --version` exits non-zero on some builds; the grep is the real assertion.
@@ -97,6 +124,13 @@ unwrapped.overrideAttrs (old: {
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; }
# The manifest must name a library this derivation actually installed. A manifest pointing at a
# path that does not exist is the worst shape of this bug: the loader reads it, finds nothing,
# and carries on silently, so the box looks healthy and every game renders SDR.
lib=$(sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
$out/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json)
[ -f "$lib" ] \
|| { echo "punktfunk-gamescope: the layer manifest points at $lib, which is not installed"; exit 1; }
runHook postInstallCheck
'';
+10
View File
@@ -507,6 +507,16 @@ in
# can still override this (or set it to `off` to force the in-process encoder) — the same
# "an operator's own override still wins" posture as PUNKTFUNK_GAMESCOPE_BIN above.
environment.PUNKTFUNK_ENCODE_WORKER = "${config.security.wrapperDir}/punktfunk-encode-worker";
# Where our Vulkan WSI layer's manifest lives. The host defaults to the FHS path every
# distro package uses, which no NixOS box has — here the layer travels inside the gamescope
# derivation, so point at it. Without this a game nested under the compositor gets no HDR10
# swapchain at all: that layer is the only route to one, and the host falls back to
# disabling the system layer, which is HDR-less by construction.
#
# Same override posture as PUNKTFUNK_GAMESCOPE_BIN: `Environment=` renders before
# `EnvironmentFile=`, so an operator's `settings` still wins.
environment.PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR = mkIf cfg.host.gamescopeHdr
"${cfg.host.gamescopePackage}/lib/punktfunk/vulkan/implicit_layer.d";
serviceConfig = {
# The store path DIRECTLY — not a capability wrapper. /proc/<pid>/exe then resolves to the
# very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is