feat(hdr): a gamescope session streams true HDR10, decided before the Welcome

The Linux native plane has been 8-bit for a reason that stopped being true:
`capturer_supports_hdr()` returned a flat `false` because Mutter's virtual
monitors are SDR-only upstream. That is still right for Mutter, KWin and
wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ
(packaging/gamescope). Open the three gates that held it off, together:

* the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`):
  Windows keeps its platform answer, gamescope asks whether the resolved binary
  offers the formats, everything else stays false. It must be truthful rather
  than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit
  encoder are a deliberate hard error — so every term is a static fact resolved
  before anything is spawned, including "do we spawn gamescope at all" (an
  attach inherits someone else's flags and can promise nothing);
* `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it
  never had, and the host arm stopped dropping `want.hdr` on the floor;
* the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three
  sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim),
  from one shared `hdr_args` — the force flag is what makes it work headless,
  and forgetting it looks exactly like a negotiation failure.

Two things that would have gone wrong quietly:

* the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a
  kept SDR display handed to an HDR session would give the game no HDR surfaces
  while the stream negotiated PQ over an SDR composite — wrong, and not
  obviously broken. Same for the managed session-plus/SteamOS reuse check.
* the HDR-negotiation-failure latch becomes PER SOURCE. It was one
  process-wide flag, so a monitor that left HDR mode would have disabled
  gamescope HDR until the host restarted, and vice versa — two facts with
  nothing in common but the word HDR.

GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm,
and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source —
a headless box has no monitor to be in HDR mode, so running it there would
hard-refuse every gamescope HDR session.

Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and
`punktfunk-host hdr-probe` now answers for both Linux HDR sources.
This commit is contained in:
2026-07-28 18:02:13 +02:00
parent d6818263ce
commit 689297c86e
16 changed files with 643 additions and 136 deletions
+86 -28
View File
@@ -134,8 +134,9 @@ pub trait Capturer: Send {
// ---- Stream properties ------------------------------------------------------------------
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`), or a generic HDR10
/// block once an HDR stream is negotiated (Linux — neither the portal nor gamescope exposes a
/// real mastering volume). `None` = unknown / SDR / a backend that doesn't expose it.
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
@@ -362,6 +363,12 @@ pub struct ZeroCopyPolicy {
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
pub pyrowave_modifiers: Vec<u64>,
/// The resolved encoder can ingest a packed 10-bit PQ CUDA payload (`pf_encode::linux_hdr_cuda_ok`
/// — direct-SDK NVENC only). An HDR capture builds the GPU importer ONLY when this holds:
/// libav's HDR route wants a P010 hardware frame it swscales into, so a packed-2:10:10:10 CUDA
/// buffer would land in a P010 surface as garbage. `false` ⇒ HDR takes the CPU path, exactly as
/// it did before the direct backend learned 10-bit.
pub hdr_cuda_ok: bool,
}
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
@@ -387,15 +394,21 @@ pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
}
/// Whether the **native-plane** capturer (a compositor virtual output) can deliver an HDR (10-bit
/// PQ/BT.2020) source on this platform — the capture-side gate the punktfunk/1 handshake consults
/// before negotiating 10-bit (mirroring [`capturer_supports_444`]).
/// PQ/BT.2020) source **on this platform alone**, without knowing which compositor will be
/// driven — the platform half of the gate the punktfunk/1 handshake consults before negotiating
/// 10-bit (mirroring [`capturer_supports_444`]).
///
/// Linux: `false`. GNOME 50 added HDR **screen sharing** for *monitor* streams only — Mutter's
/// `RecordVirtual` virtual-monitor streams advertise 8-bit BGRx/BGRA exclusively (still true on
/// the GNOME 51 dev branch), and virtual outputs report no BT2020/PQ colour capabilities, so they
/// can't be flipped into HDR mode via DisplayConfig either. The Linux HDR path that DOES exist —
/// the GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — is gated
/// separately by the GameStream plane (`host_hdr_capable` + the live monitor colour-mode probe).
/// Linux: `false`, and this is NOT the whole Linux answer any more. It says only that no Linux
/// virtual output is HDR-capable *by platform*: Mutter's `RecordVirtual` virtual-monitor streams
/// advertise 8-bit BGRx/BGRA exclusively (still true on the GNOME 51 dev branch) and report no
/// BT2020/PQ colour capabilities, and KWin/wlroots virtual outputs are the same. The one Linux
/// virtual output that CAN be 10-bit — gamescope's PipeWire node, with our carried
/// `pipewire-hdr` patch (`packaging/gamescope`) — depends on the resolved compositor **and** the
/// resolved gamescope binary, neither of which this crate knows. The host resolves it in
/// `capture::capturer_supports_hdr_for(compositor)`, which consults this for the platform floor;
/// the other Linux HDR path (the GNOME 50+ portal **monitor mirror**, `open_portal_monitor` with
/// `want_hdr`) is gated separately by the GameStream plane (`host_hdr_capable` + the live monitor
/// colour-mode probe).
#[cfg(target_os = "linux")]
pub fn capturer_supports_hdr() -> bool {
false
@@ -410,28 +423,67 @@ pub fn capturer_supports_hdr() -> bool {
false
}
/// Process-wide latch: a `want_hdr` portal capture failed to negotiate the HDR (10-bit PQ) offer —
/// the compositor never accepted it (monitor left HDR mode between the probe and the negotiation,
/// NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter…). Later sessions consult
/// [`hdr_capture_failed`] and fall back to the SDR offer instead of re-running the same doomed
/// 10-second negotiation timeout on every reconnect. Sticky until host restart (matching the
/// zero-copy downgrade latches); the log line at latch time says so.
/// Which HDR capture source a `want_hdr` negotiation failure belongs to.
///
/// The failure latch below is **per source**, because the two Linux HDR sources fail for
/// completely unrelated reasons and share nothing but the word "HDR": the portal monitor mirror
/// fails when the mirrored monitor leaves HDR mode (a live, box-state fact), a gamescope virtual
/// output fails when the spawned binary has no 10-bit formats (a static, binary-identity fact).
/// A single process-wide latch let either one disable the other until the host restarted.
#[cfg(target_os = "linux")]
static HDR_CAPTURE_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSource {
/// The GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — the
/// GameStream plane's HDR path.
PortalMonitor,
/// A compositor **virtual output** (`open_virtual_output` with `want_hdr`) — today only
/// gamescope's PipeWire node, with the carried `pipewire-hdr` patch.
VirtualOutput,
}
/// Per-source latch: a `want_hdr` capture failed to negotiate the HDR (10-bit PQ) offer — the
/// producer never accepted it (monitor left HDR mode between the probe and the negotiation,
/// NVIDIA EGL not listing LINEAR for XR30, an unpatched gamescope…). Later sessions **on that
/// same source** consult [`hdr_capture_failed`] and fall back to the SDR offer instead of
/// re-running the same doomed 10-second negotiation timeout on every reconnect. Sticky until host
/// restart (matching the zero-copy downgrade latches); the log line at latch time says so.
/// Indexed by [`HdrSource`] — see its doc for why one shared latch was wrong.
#[cfg(target_os = "linux")]
static HDR_CAPTURE_FAILED: [std::sync::atomic::AtomicBool; 2] = [
std::sync::atomic::AtomicBool::new(false),
std::sync::atomic::AtomicBool::new(false),
];
#[cfg(target_os = "linux")]
pub fn hdr_capture_failed() -> bool {
HDR_CAPTURE_FAILED.load(std::sync::atomic::Ordering::Relaxed)
impl HdrSource {
fn slot(self) -> usize {
match self {
HdrSource::PortalMonitor => 0,
HdrSource::VirtualOutput => 1,
}
}
}
#[cfg(target_os = "linux")]
pub(crate) fn note_hdr_capture_failed() {
if !HDR_CAPTURE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(
"HDR capture negotiation failed — this host will offer SDR capture for the rest of \
the process lifetime (restart the host after fixing the monitor's HDR mode to retry)"
);
pub fn hdr_capture_failed(source: HdrSource) -> bool {
HDR_CAPTURE_FAILED[source.slot()].load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(target_os = "linux")]
pub(crate) fn note_hdr_capture_failed(source: HdrSource) {
if !HDR_CAPTURE_FAILED[source.slot()].swap(true, std::sync::atomic::Ordering::Relaxed) {
match source {
HdrSource::PortalMonitor => tracing::warn!(
"HDR capture negotiation failed on the monitor mirror — this host will offer SDR \
for that source for the rest of the process lifetime (restart the host after \
fixing the monitor's HDR mode to retry)"
),
HdrSource::VirtualOutput => tracing::warn!(
"HDR capture negotiation failed on the virtual output — this host will offer SDR \
for that source for the rest of the process lifetime (is the spawned gamescope \
the punktfunk build? see packaging/gamescope)"
),
}
}
}
#[cfg(target_os = "windows")]
@@ -543,7 +595,7 @@ pub fn open_portal_monitor(
) -> Result<Box<dyn Capturer>> {
linux::PortalCapturer::open(
anchored,
want_hdr && !hdr_capture_failed(),
want_hdr && !hdr_capture_failed(HdrSource::PortalMonitor),
want_metadata_cursor,
policy,
)
@@ -553,7 +605,11 @@ pub fn open_portal_monitor(
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
/// caller (host facade) explodes its `VirtualOutput` into these primitives + owns nothing after —
/// the capturer takes `keepalive`, so dropping it releases the output. `allow_zerocopy` mirrors
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert.
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert. `want_hdr` offers the
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
/// session that negotiated PQ cannot fall back to SDR afterwards.
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
pub fn open_virtual_output(
@@ -563,6 +619,7 @@ pub fn open_virtual_output(
keepalive: Box<dyn Send>,
allow_zerocopy: bool,
want_444: bool,
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<Box<dyn Capturer>> {
@@ -573,6 +630,7 @@ pub fn open_virtual_output(
keepalive,
allow_zerocopy,
want_444,
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
policy,
expect_exact_dims,
)
+32 -12
View File
@@ -167,6 +167,9 @@ pub struct PortalCapturer {
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
hdr_offer: bool,
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
hdr_source: super::HdrSource,
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
node_id: u32,
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
@@ -301,7 +304,7 @@ impl PortalCapturer {
},
policy,
)?
.into_capturer(node_id, None, Some(portal)))
.into_capturer(node_id, None, Some(portal), super::HdrSource::PortalMonitor))
}
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
@@ -312,6 +315,8 @@ impl PortalCapturer {
/// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
/// [`crate::open_virtual_output`] for who is allowed to pass it.
#[allow(clippy::too_many_arguments)]
pub fn from_virtual_output(
remote_fd: Option<OwnedFd>,
@@ -320,6 +325,7 @@ impl PortalCapturer {
keepalive: Box<dyn Send>,
allow_zerocopy: bool,
want_444: bool,
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
) -> Result<PortalCapturer> {
@@ -327,11 +333,14 @@ impl PortalCapturer {
node_id,
allow_zerocopy,
want_444,
want_hdr,
expect_exact_dims,
"connecting PipeWire to virtual output"
);
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
// BGRx/BGRA exclusively, GNOME 50 and 51-dev alike) — never run the HDR offer here.
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
// 8-bit BGRx/BGRA exclusively, GNOME 50 and 51-dev alike; KWin/wlroots the same), so
// `want_hdr` reaches here ONLY for a gamescope node off our `pipewire-hdr` build — the
// host resolves that before the Welcome (`capture::capturer_supports_hdr_for`).
Ok(spawn_pipewire(
remote_fd,
node_id,
@@ -339,15 +348,19 @@ impl PortalCapturer {
CaptureOpts {
allow_zerocopy,
want_444,
// Virtual outputs are SDR-only upstream (see the comment above).
want_hdr: false,
want_hdr,
expect_exact_dims,
},
policy,
)?
// No portal thread on this path: the node belongs to a virtual output the caller created,
// and `keepalive` is what releases it.
.into_capturer(node_id, Some(keepalive), None))
.into_capturer(
node_id,
Some(keepalive),
None,
super::HdrSource::VirtualOutput,
))
}
}
@@ -378,6 +391,7 @@ impl PwHandles {
node_id: u32,
keepalive: Option<Box<dyn Send>>,
portal: Option<PortalSession>,
hdr_source: super::HdrSource,
) -> PortalCapturer {
PortalCapturer {
slot: self.slot,
@@ -386,6 +400,7 @@ impl PwHandles {
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
hdr_offer: self.hdr_offer,
hdr_source,
node_id,
quit: Some(self.quit),
join: Some(self.join),
@@ -456,6 +471,7 @@ fn spawn_pipewire(
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
// bare `!= "0"` string compare) restores the packed-RGB negotiation.
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
hdr_cuda_ok: policy.hdr_cuda_ok,
});
// The capturer's timeout diagnosis reads the SAME resolved bool the thread acts on.
let vaapi_dmabuf = plan.vaapi_passthrough;
@@ -633,11 +649,15 @@ impl Capturer for PortalCapturer {
&& self.join.as_ref().is_some_and(|j| !j.is_finished())
}
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
/// same fallback Windows uses when a display reports nothing. The native stream loop prefers
/// the client display's own volume when the client sent one (`Hello::display_hdr`).
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format — for
/// BOTH Linux HDR sources (the portal monitor mirror and a gamescope virtual output).
/// Neither producer exposes a mastering volume through the screencast: Mutter has no
/// per-monitor one, and gamescope's PipeWire node carries no per-frame HDR metadata (the
/// game's `VK_EXT_hdr_metadata` blob stops at the compositor — forwarding it needs the
/// patch's v2 custom SPA meta). So this is the standard HDR10 default block (BT.2020
/// primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the same fallback Windows uses
/// when a display reports nothing. The native stream loop prefers the client display's own
/// volume when the client sent one (`Hello::display_hdr`).
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
if !self.signals.hdr_negotiated.load(Ordering::Relaxed) {
return None;
@@ -722,7 +742,7 @@ impl PortalCapturer {
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed();
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
+79 -25
View File
@@ -132,6 +132,9 @@ pub(super) struct NegotiationInputs {
pub gpu_dmabuf_negotiation_failed: bool,
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
pub native_nv12_env_on: bool,
/// [`ZeroCopyPolicy::hdr_cuda_ok`] — the resolved encoder can ingest a packed 10-bit PQ CUDA
/// payload. Only the direct-SDK NVENC backend can.
pub hdr_cuda_ok: bool,
}
/// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread
@@ -166,8 +169,11 @@ pub(super) struct NegotiationPlan {
///
/// The four invariants this encodes were previously prose-only comments spread across the
/// prologue; `negotiation_plan_invariants` in the tests below pins each one:
/// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth
/// loss); the HDR consumers are the CPU mmap path and the raw passthrough.
/// 1. HDR never takes the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture
/// → silent depth loss). It may still build the importer, because the HDR pod family
/// advertises LINEAR only ([`build_hdr_dmabuf_format`]) — so an HDR dmabuf necessarily
/// takes the Vulkan-bridge / CUDA-external-memory arm, which is byte-exact for any 4 Bpp
/// packed format. The per-frame gate in `.process` enforces the tiled half.
/// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
/// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough
/// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB).
@@ -177,16 +183,22 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
// CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor).
let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session;
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
// must not crash-loop); `gpu_dmabuf_negotiation_failed` is the offer's own timeout latch (a
// compositor that accepts none of the importer's modifiers refuses them identically on every
// retry, so the next session negotiates the CPU path instead of re-paying the 10 s timeout).
// worse, succeed and produce CUDA payloads only NVENC can consume. `gpu_import_disabled` is
// the repeated-worker-death latch (a wedged GPU stack must not crash-loop);
// `gpu_dmabuf_negotiation_failed` is the offer's own timeout latch (a compositor that accepts
// none of the importer's modifiers refuses them identically on every retry, so the next
// session negotiates the CPU path instead of re-paying the 10 s timeout).
//
// HDR is NOT excluded outright (invariant 1): its pods are LINEAR-only, so it lands on the
// Vulkan-bridge arm, never the 8-bit de-tile blit. But it is excluded where the encoder cannot
// take a packed 10-bit CUDA payload — the libav fallback's HDR route swscales into a P010
// hardware frame, so it must keep getting CPU frames. Without that term a
// `PUNKTFUNK_NVENC_DIRECT=0` host would stream garbage.
let build_importer = i.zerocopy
&& !raw_passthrough
&& !i.want_hdr
&& !i.gpu_import_disabled
&& !i.gpu_dmabuf_negotiation_failed;
&& !i.gpu_dmabuf_negotiation_failed
&& (!i.want_hdr || i.hdr_cuda_ok);
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
// necessarily absent here) and it is what made the decision look impure — the reason
@@ -209,9 +221,11 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
&& !i.force_shm
&& raw_passthrough
&& i.raw_dmabuf_import_disabled,
// Every `build_importer` term EXCEPT the two latches, and then either latch — i.e. exactly
// "this capture would have built the importer, but a latch stopped it".
gpu_import_latched: i.zerocopy
&& !raw_passthrough
&& !i.want_hdr
&& (!i.want_hdr || i.hdr_cuda_ok)
&& (i.gpu_import_disabled || i.gpu_dmabuf_negotiation_failed),
}
}
@@ -463,10 +477,20 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// through to the shm de-pad copy below.
let mut gpu_import_broken = false;
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
// Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its
// de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the
// importer, so this gate only matters if those invariants ever drift apart.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() {
// Invariant 1's teeth: a 10-bit PQ frame may take the LINEAR (Vulkan-bridge → CUDA
// external memory) arm, which moves 4 Bpp words verbatim, but must NEVER take the TILED
// EGL de-tile blit — that renders into an 8-bit `GL_RGBA8` texture and would crush the
// depth silently. The HDR pods advertise LINEAR only, so a tiled modifier here means the
// producer ignored the offer; drop to the CPU path rather than trust it.
let hdr_tiled = fmt.is_hdr_rgb10() && ud.modifier != 0;
if hdr_tiled {
warn_once(
"HDR frame arrived with a tiled modifier — the GPU de-tile blit is 8-bit, so \
this stream falls back to the CPU path (the producer ignored our LINEAR-only \
HDR offer)",
);
}
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !hdr_tiled {
let plane = pf_zerocopy::DmabufPlane {
fd: datas[0].fd(),
offset: datas[0].chunk().offset(),
@@ -486,8 +510,13 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
// failure latches RGB for the stream (mid-frame fallback, no drop).
let yuv444 = ud.yuv444 && modifier.is_some();
let mut nv12 = ud.nv12 && !ud.yuv444;
// A 10-bit frame takes NEITHER convert: both the GL and the Vulkan compute CSCs
// write 8-bit planes, and NVENC ingests the packed 10-bit RGB natively
// (`ARGB10`/`ABGR10`) with its own BT.2020 CSC. So HDR stays packed RGB all the
// way to the encoder — no depth loss, no extra pass.
let ten_bit = fmt.is_hdr_rgb10();
let yuv444 = ud.yuv444 && modifier.is_some() && !ten_bit;
let mut nv12 = ud.nv12 && !ud.yuv444 && !ten_bit;
let imported = if let Some(m) = modifier {
if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
@@ -818,8 +847,8 @@ pub fn pipewire_thread(
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf
// kills the worker, not this host. If construction fails, log and fall back to the CPU path
// (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try
// at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched
// the import off — see `negotiation_plan`).
// at all (not under a raw passthrough, not once repeated worker deaths latched the import
// off — see `negotiation_plan`).
if plan.gpu_import_latched {
tracing::warn!(
"zero-copy GPU import disabled for this host process (repeated import-worker deaths, \
@@ -1420,6 +1449,7 @@ mod tests {
gpu_import_disabled: false,
gpu_dmabuf_negotiation_failed: false,
native_nv12_env_on: true,
hdr_cuda_ok: true,
}
}
@@ -1435,17 +1465,19 @@ mod tests {
/// The four invariants that were prose-only comments in `pipewire_thread`'s prologue.
#[test]
fn negotiation_plan_invariants() {
// 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an
// importer here would silently crush the 10-bit depth.
// 1. HDR builds the importer on the NVENC path — its pods are LINEAR-only, so the frames
// take the Vulkan-bridge/CUDA arm (byte-exact for 4 Bpp), never the 8-bit de-tile
// blit. (The tiled half of the invariant is enforced per frame in `.process`, which
// sees the negotiated modifier this plan cannot.)
for want_444 in [false, true] {
let p = negotiation_plan(NegotiationInputs {
want_hdr: true,
want_444,
..nvenc()
});
assert!(!p.build_importer, "HDR must not build the importer");
assert!(p.build_importer, "HDR on NVENC keeps zero-copy");
}
// …on every backend, including the ones that take the raw passthrough.
// …but never under a raw passthrough (VAAPI/PyroWave import the dmabuf themselves).
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
@@ -1453,6 +1485,26 @@ mod tests {
})
.build_importer
);
// …and never when the resolved encoder can't take a packed 10-bit CUDA payload (libav
// NVENC: its HDR route swscales into a P010 hardware frame). SDR is unaffected — the term
// is HDR-only, so a libav host keeps its 8-bit zero-copy.
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
hdr_cuda_ok: false,
..nvenc()
})
.build_importer,
"HDR must stay on the CPU path where the encoder can't ingest 10-bit CUDA"
);
assert!(
negotiation_plan(NegotiationInputs {
hdr_cuda_ok: false,
..nvenc()
})
.build_importer,
"the HDR-only guard must not touch an SDR session"
);
// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
let p = negotiation_plan(NegotiationInputs {
@@ -1614,16 +1666,18 @@ mod tests {
});
assert!(!p.build_importer);
assert!(p.gpu_import_latched);
// It is NOT reported for a capture that would never have built one anyway (HDR, or a
// raw passthrough) — that would misdirect the operator.
// Reported for an HDR capture too — HDR takes the same importer (LINEAR/Vulkan-bridge
// arm), so the latch really did cost it zero-copy.
assert!(
!negotiation_plan(NegotiationInputs {
negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
want_hdr: true,
..nvenc()
})
.gpu_import_latched
);
// It is NOT reported for a capture that would never have built one anyway (a raw
// passthrough) — that would misdirect the operator.
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
+21
View File
@@ -137,6 +137,22 @@ pub struct HostConfig {
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
pub gamescope_splash: bool,
/// `PUNKTFUNK_GAMESCOPE_HDR` — allow HDR (10-bit BT.2020 PQ) sessions on the gamescope
/// backend. Needs the punktfunk gamescope build (`packaging/gamescope`), which teaches
/// gamescope's PipeWire node the 10-bit PQ capture formats; the host probes for it and stays
/// SDR when it isn't installed, so this knob only decides whether HDR is *attempted*.
///
/// Default OFF for the canary release, then default-on (matching `PUNKTFUNK_10BIT`'s
/// explicit-off grammar). It gates the whole feature — spawn flags included — so an operator
/// who hits a bad interaction can turn the gamescope backend back into exactly today's 8-bit
/// path with one env var and no downgrade path to trip over.
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).
/// 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.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
@@ -210,6 +226,11 @@ impl HostConfig {
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
// its first frames at all; `=0` is the A/B + escape hatch.
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
// Default OFF for one canary release (design §4 rollout), then flip the `unwrap_or`.
gamescope_hdr: env_on("PUNKTFUNK_GAMESCOPE_HDR").unwrap_or(false),
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
.filter(|s| !s.trim().is_empty()),
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
+27
View File
@@ -516,6 +516,33 @@ pub fn gamescope_splash_client() -> anyhow::Result<()> {
gamescope::splash_run()
}
/// Can a gamescope session on this host stream true HDR (10-bit BT.2020 PQ)?
///
/// **A static answer, resolved before anything is spawned** — the punktfunk/1 Welcome fixes a
/// session's bit depth before the display exists and cannot take it back. Two terms, both facts
/// about how the session will be brought up rather than about how it went:
///
/// 1. the resolved gamescope binary offers 10-bit PQ capture formats (our `pipewire-hdr` build —
/// probed once per process from the `--version` banner), and
/// 2. this host **spawns** gamescope rather than attaching to a foreign one
/// (`PUNKTFUNK_GAMESCOPE_NODE`): an attach inherits whatever flags someone else's session was
/// started with, so it can promise nothing. (Attach-mode HDR needs the distro's gamescope
/// patched — the upstream-PR follow-up.)
///
/// A host-managed `gamescope-session-plus` / SteamOS session counts as a spawn: we own its
/// `GAMESCOPE_BIN` wrapper (or PATH shim), so the flags are ours.
///
/// Always `false` off Linux.
pub fn gamescope_hdr_available() -> bool {
#[cfg(target_os = "linux")]
{
let attaching = with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some());
!attaching && gamescope::gamescope_hdr_capable()
}
#[cfg(not(target_os = "linux"))]
false
}
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
@@ -142,6 +142,22 @@ pub trait VirtualDisplay: Send {
/// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint
/// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us).
fn set_client_hdr(&mut self, _hdr: Option<punktfunk_core::quic::HdrMeta>) {}
/// Tell the backend THIS SESSION negotiated HDR — a 10-bit BT.2020/PQ stream (`bit_depth >=
/// 10`, decided in the punktfunk/1 Welcome before any display exists). Distinct from
/// [`set_client_hdr`](Self::set_client_hdr), which describes the *client panel's* colour
/// volume for the EDID; this is the stream's own colourimetry, and the backend may have to
/// bring the output up differently for it. Carried on the backend instance; set once before
/// [`create`](Self::create). Default: no-op — only the Linux gamescope backend uses it, where
/// it adds `--hdr-enabled --hdr-debug-force-support` to the spawn so nested games get HDR
/// surfaces from the WSI layer and the composite can be captured as 10-bit PQ.
fn set_hdr(&mut self, _on: bool) {}
/// The HDR request currently set (see [`set_hdr`](Self::set_hdr)). The registry includes it in
/// the keep-alive REUSE key: a kept display brought up SDR cannot serve an HDR session (it was
/// spawned without the HDR flags — the game would see no HDR surfaces while the stream
/// negotiated PQ over an SDR composite) nor vice versa.
fn hdr(&self) -> bool {
false
}
/// Ask the backend for an OUT-OF-BAND cursor on the created output (the cursor channel):
/// the compositor/OS stops compositing the pointer into captured frames and the capture
/// layer surfaces shape/position separately. Carried on the backend instance; set once
@@ -24,12 +24,12 @@ mod discovery;
#[path = "gamescope/splash.rs"]
mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
SteamGameWatch,
game_session_exited, gamescope_hdr_capable, is_available, steam_appid_from_launch,
wait_for_steam_game_exit, SteamGameWatch,
};
pub(crate) use splash::run as splash_run;
@@ -44,6 +44,11 @@ pub struct GamescopeDisplay {
/// The resolved per-session launch command (set via [`VirtualDisplay::set_launch_command`]); the
/// bare-spawn path runs it instead of reading the process-global `PUNKTFUNK_GAMESCOPE_APP`.
cmd: Option<String>,
/// This session negotiated HDR (10-bit BT.2020 PQ) — set via [`VirtualDisplay::set_hdr`]
/// before `create`. Spawns gamescope with `--hdr-enabled --hdr-debug-force-support` so the
/// WSI layer advertises HDR10/scRGB surfaces to nested games, and the composite gamescope
/// hands us can be negotiated as a 10-bit PQ stream (`packaging/gamescope`).
hdr: bool,
}
/// A running host-managed session (its transient systemd --user unit) + the mode it was launched at.
@@ -51,6 +56,11 @@ struct SessionState {
width: u32,
height: u32,
refresh_hz: u32,
/// Whether the session was launched with the HDR flags. Part of the reuse key for the same
/// reason the registry's is: gamescope cannot turn HDR on live, so an SDR session cannot be
/// handed to an HDR client (the game would get no HDR surfaces while the stream negotiated
/// PQ) — that needs a relaunch, exactly like a mode change.
hdr: bool,
}
/// The host-managed `gamescope-session-plus` session, tracked at **host lifetime** (NOT per
@@ -242,6 +252,17 @@ impl VirtualDisplay for GamescopeDisplay {
self.cmd = cmd;
}
fn set_hdr(&mut self, on: bool) {
self.hdr = on;
}
fn hdr(&self) -> bool {
// The registry keys keep-alive reuse on it too: a kept SDR gamescope was spawned WITHOUT
// the HDR flags, so handing it to an HDR session would give the game no HDR surfaces and
// negotiate a PQ stream over an SDR composite — wrong, and not obviously broken.
self.hdr
}
fn poolable_now(&self) -> bool {
// Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); managed
// (`PUNKTFUNK_GAMESCOPE_SESSION`) and attach (`PUNKTFUNK_GAMESCOPE_NODE`) report
@@ -274,7 +295,7 @@ impl VirtualDisplay for GamescopeDisplay {
// them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) —
// and relaunch it when the client's mode changes. Reuses the node + EIS discovery below.
if let Ok(client) = std::env::var("PUNKTFUNK_GAMESCOPE_SESSION") {
return create_managed_session(&client, mode);
return create_managed_session(&client, mode, self.hdr);
}
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
// of spawning our own: capture its node AND inject into its EIS socket.
@@ -331,6 +352,7 @@ impl VirtualDisplay for GamescopeDisplay {
mode.refresh_hz.max(1),
self.cmd.as_deref(),
&log,
self.hdr,
)?;
let child_pid = child.id();
let proc = GamescopeProc {
@@ -368,14 +390,14 @@ impl VirtualDisplay for GamescopeDisplay {
/// the running session if the mode is unchanged and its node is still live (no Steam restart);
/// otherwise stop the old transient unit and RELAUNCH at the new mode (gamescope can't change output
/// mode live). Then discover the node + point the injector, exactly as the attach path does.
fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<VirtualOutput> {
// A (re)connect cancels any pending debounced TV-restore: we're about to (re)use the managed
// session, so the autologin must stay stopped and the warm session stays up (no stop/relaunch).
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
// SteamOS (the real Steam Deck) has no session-plus: take over its `gamescope-session.target`
// headless at the client's mode instead of launching a separate managed session.
if steamos_session_present() {
return create_managed_session_steamos(mode);
return create_managed_session_steamos(mode, hdr);
}
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
@@ -417,7 +439,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
if crate::rebuild_probe_active() {
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
s.width == mode.width
&& s.height == mode.height
&& s.refresh_hz == mode.refresh_hz
&& s.hdr == hdr
});
if same_mode {
if let Some(node_id) = find_gamescope_node() {
@@ -466,7 +491,10 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
free_desktop_steam()?;
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
s.width == mode.width
&& s.height == mode.height
&& s.refresh_hz == mode.refresh_hz
&& s.hdr == hdr
});
if same_mode {
if let Some(node_id) = find_gamescope_node() {
@@ -485,7 +513,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
}
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
// exactly one gamescope `Video/Source` node for discovery.
let node_id = match launch_session(client, SESSION_UNIT, mode) {
let node_id = match launch_session(client, SESSION_UNIT, mode, hdr) {
Ok(id) => id,
Err(e) => {
// The takeover already happened (autologin units stopped, possibly the DM down) — arm
@@ -505,6 +533,7 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
width: mode.width,
height: mode.height,
refresh_hz: mode.refresh_hz,
hdr,
});
tracing::info!(
node_id,
@@ -781,8 +810,11 @@ fn headless_shim_dir() -> std::path::PathBuf {
/// session's `exec gamescope` (via PATH) and rewrite to a headless output at the client's mode (read
/// from `PF_W`/`PF_H`/`PF_HZ`), dropping the physical flags. Idempotent; returns the shim's directory.
fn write_headless_shim() -> Result<std::path::PathBuf> {
const SHIM_BODY: &str = r#"#!/bin/bash
W="${PF_W:-1920}"; H="${PF_H:-1080}"; HZ="${PF_HZ:-60}"
// `$PF_HDR_ARGS` is unquoted for the same reason as in the GAMESCOPE_BIN wrapper: it is our
// own flag list ([`hdr_args`]) and must word-split into separate argv entries.
let shim_body = format!(
r#"#!/bin/bash
W="${{PF_W:-1920}}"; H="${{PF_H:-1080}}"; HZ="${{PF_HZ:-60}}"
keep=()
while [ $# -gt 0 ]; do
case "$1" in
@@ -790,12 +822,14 @@ while [ $# -gt 0 ]; do
*) keep+=("$1"); shift;;
esac
done
exec /usr/bin/gamescope --backend headless -W "$W" -H "$H" -w "$W" -h "$H" -r "$HZ" "${keep[@]}"
"#;
exec {bin} --backend headless -W "$W" -H "$H" -w "$W" -h "$H" -r "$HZ" ${{PF_HDR_ARGS}} "${{keep[@]}}"
"#,
bin = gamescope_bin()
);
let dir = headless_shim_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
let shim = dir.join("gamescope");
std::fs::write(&shim, SHIM_BODY).with_context(|| format!("write shim {}", shim.display()))?;
std::fs::write(&shim, &shim_body).with_context(|| format!("write shim {}", shim.display()))?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod shim {}", shim.display()))?;
@@ -812,7 +846,7 @@ fn steamos_dropin_path() -> std::path::PathBuf {
/// Write the drop-in: prepend the shim dir to the service's PATH + pass the client's mode via `PF_*`.
/// A subsequent `daemon-reload` + target restart applies it.
fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Result<()> {
let path = steamos_dropin_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
@@ -827,11 +861,15 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
Environment=PF_W={w}\n\
Environment=PF_H={h}\n\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
shim = shim_dir.display(),
w = mode.width,
h = mode.height,
hz = mode.refresh_hz.max(1),
// Read (unquoted) by the PATH shim — empty for an SDR session. Quoted HERE because a
// systemd `Environment=` value with spaces must be, or only the first flag survives.
hdr_args = hdr_args(hdr).join(" "),
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))
}
@@ -846,10 +884,13 @@ fn remove_steamos_dropin() {
/// brings Steam up in the fresh headless gamescope — and attach to its node. A same-mode reconnect
/// reuses the running session (no Steam restart); a different mode rewrites the drop-in + restarts.
/// The restart kills any prior gamescope, so there's exactly one node to discover (no stale attach).
fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
fn create_managed_session_steamos(mode: Mode, hdr: bool) -> Result<VirtualOutput> {
let mut guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
let same_mode = guard.as_ref().is_some_and(|s| {
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
s.width == mode.width
&& s.height == mode.height
&& s.refresh_hz == mode.refresh_hz
&& s.hdr == hdr
});
if same_mode {
if let Some(node_id) = find_gamescope_node() {
@@ -876,7 +917,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
));
}
let shim_dir = write_headless_shim()?;
write_steamos_dropin(&shim_dir, mode)?;
write_steamos_dropin(&shim_dir, mode, hdr)?;
systemctl_user(&["daemon-reload"]);
systemctl_user(&["restart", STEAMOS_SESSION_TARGET]);
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = true;
@@ -895,6 +936,7 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
width: mode.width,
height: mode.height,
refresh_hz: mode.refresh_hz,
hdr,
});
tracing::info!(
node_id,
@@ -1978,13 +2020,20 @@ fn gamescope_bin_wrapper_path() -> std::path::PathBuf {
/// Write the `GAMESCOPE_BIN` wrapper that injects `--nested-refresh $PF_HZ` — the flag
/// gamescope-session-plus does NOT expose, and the one that makes games see the client's refresh
/// instead of ~60 Hz. The body is constant (the rate comes from the `PF_HZ` env per launch), so the
/// write is idempotent. Returns its path.
/// instead of ~60 Hz — plus `$PF_HDR_ARGS` for an HDR session. The body is constant (rate and HDR
/// flags come from the env per launch), so the write is idempotent. Returns its path.
///
/// `$PF_HDR_ARGS` is deliberately UNQUOTED: it is either empty or a short list of gamescope flags
/// this host built itself ([`hdr_args`]), never operator input, and it has to word-split into
/// separate argv entries.
fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
let path = gamescope_bin_wrapper_path();
std::fs::write(
&path,
"#!/bin/sh\nexec /usr/bin/gamescope --nested-refresh \"${PF_HZ:-60}\" \"$@\"\n",
format!(
"#!/bin/sh\nexec {} --nested-refresh \"${{PF_HZ:-60}}\" ${{PF_HDR_ARGS}} \"$@\"\n",
gamescope_bin()
),
)
.with_context(|| format!("write GAMESCOPE_BIN wrapper {}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
@@ -1998,7 +2047,7 @@ fn write_gamescope_bin_wrapper() -> Result<std::path::PathBuf> {
/// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and
/// not the box's physical-display EDID. Blocks until the gamescope `Video/Source` node appears
/// (Steam Big Picture cold-start is slow), returning its id; on timeout it stops the unit and errors.
fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Result<u32> {
if !std::path::Path::new(SESSION_PLUS_BIN).exists() {
anyhow::bail!(
"PUNKTFUNK_GAMESCOPE_SESSION is set but {SESSION_PLUS_BIN} is missing — the host-managed \
@@ -2019,6 +2068,8 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode) -> Result<u32> {
.arg(format!("--setenv=SCREEN_WIDTH={}", mode.width))
.arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height))
.arg(format!("--setenv=PF_HZ={hz}"))
// Read (unquoted) by the GAMESCOPE_BIN wrapper — empty for an SDR session.
.arg(format!("--setenv=PF_HDR_ARGS={}", hdr_args(hdr).join(" ")))
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
.arg("--setenv=DRM_MODE=cvt")
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
@@ -2150,6 +2201,7 @@ fn add_bare_gamescope_args(
hz: u32,
steam_mode: bool,
grab_cursor: bool,
hdr: bool,
) {
command
.args(["--backend", "headless"])
@@ -2162,9 +2214,43 @@ fn add_bare_gamescope_args(
if grab_cursor {
command.arg("--force-grab-cursor");
}
for arg in hdr_args(hdr) {
command.arg(arg);
}
command.args(["--xwayland-count", "1", "--"]);
}
/// The gamescope flags that make an HDR session HDR — shared by all three spawn sub-modes (bare
/// spawn, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim), which is the point: a kept display
/// is keyed on `hdr`, so the flags must not be able to drift between the paths that produce it.
///
/// * `--hdr-enabled` sets gamescope's `cv_hdr_enabled` convar.
/// * `--hdr-debug-force-support` is what makes it work HEADLESS: the headless connector hardcodes
/// `SupportsHDR() == false`, and this flag is the documented bypass. Without it steamcompmgr
/// never pushes the `gamescopeHDROutputFeedback` root atom, so the WSI layer advertises no
/// HDR10/scRGB surfaces and nested games render SDR — which would look exactly like a capture
/// negotiation failure while actually being a spawn-flag bug. (A first-class `--headless-hdr`
/// is the upstream-friendly replacement; we pin the gamescope we ship, so the debug flag is
/// 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.
fn hdr_args(hdr: bool) -> Vec<String> {
if !hdr {
return Vec::new();
}
let mut args = 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
}
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
@@ -2175,7 +2261,14 @@ fn add_bare_gamescope_args(
/// compositor has a painting window from the first second: gamescope pushes capture buffers only
/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first
/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`).
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
fn spawn(
w: u32,
h: u32,
hz: u32,
cmd: Option<&str>,
log: &std::path::Path,
hdr: bool,
) -> Result<Child> {
// A non-empty per-session command (set via `set_launch_command`) wins; else the
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
// gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently
@@ -2218,8 +2311,8 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash"))
.ok()
});
let mut cmd = Command::new("gamescope");
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
let mut cmd = Command::new(gamescope_bin());
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor, hdr);
let script = nested_wrapper_script(&relay, splash_exe.is_some());
cmd.args(["sh", "-c", &script, "sh"]);
if let Some(exe) = &splash_exe {
@@ -2245,7 +2338,8 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
tracing::info!(
w, h, hz, steam_mode,
w, h, hz, steam_mode, hdr,
bin = %gamescope_bin(),
splash = splash_exe.is_some(),
%app,
log = %log.display(),
@@ -2295,10 +2389,29 @@ impl Drop for GamescopeProc {
mod tests {
use super::{
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
display_manager_unit_under, dm_survives_masked_unit, hdr_args, is_steam_launch,
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
};
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
/// indistinguishable, on-glass, from a capture negotiation failure. Both flags are required:
/// `--hdr-enabled` alone does nothing on the HEADLESS backend, whose connector hardcodes
/// `SupportsHDR() == false`.
#[test]
fn hdr_spawn_flags_are_both_present_and_absent_for_sdr() {
assert!(
hdr_args(false).is_empty(),
"an SDR spawn takes no HDR flags"
);
let args = hdr_args(true);
assert!(args.iter().any(|a| a == "--hdr-enabled"));
assert!(
args.iter().any(|a| a == "--hdr-debug-force-support"),
"without the force flag the headless connector reports no HDR support, so the WSI \
layer advertises no HDR surfaces and games render SDR"
);
}
#[test]
fn user_manager_lifetime_detection() {
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
@@ -329,13 +329,110 @@ pub(super) fn find_gamescope_eis_socket() -> Option<String> {
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
/// create path); just checks the binary executes.
pub(crate) fn is_available() -> bool {
std::process::Command::new("gamescope")
std::process::Command::new(gamescope_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// The gamescope binary this host spawns, resolved ONCE per process:
///
/// 1. `PUNKTFUNK_GAMESCOPE_BIN` — an absolute path override (an operator's own build),
/// 2. `punktfunk-gamescope` on `PATH` — our carried build (`packaging/gamescope`), which adds
/// 10-bit BT.2020/PQ capture formats to gamescope's PipeWire node,
/// 3. `gamescope` — the distro's.
///
/// Resolved to an ABSOLUTE path whenever it can be (`which`-style `PATH` walk), because the
/// same answer has to be baked into the two indirect spawn paths — the `GAMESCOPE_BIN` wrapper
/// (gamescope-session-plus) and the SteamOS PATH shim — which run outside this process's `PATH`.
/// A resolution failure falls back to the bare name so a normal install still works.
pub(crate) fn gamescope_bin() -> &'static str {
static BIN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
BIN.get_or_init(|| {
// The env override is read under the shared env lock (a concurrent session's `set_var`
// must not race it) — same discipline as every other env read in this crate.
let over = crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_BIN").ok())
.filter(|s| !s.trim().is_empty());
if let Some(path) = over {
tracing::info!(bin = %path, "gamescope: PUNKTFUNK_GAMESCOPE_BIN override");
return path;
}
if let Some(path) = which_in_path("punktfunk-gamescope") {
tracing::info!(
bin = %path,
"gamescope: using the punktfunk build (10-bit HDR capture available)"
);
return path;
}
which_in_path("gamescope").unwrap_or_else(|| "gamescope".to_string())
})
.as_str()
}
/// Minimal `which`: the first executable `<dir>/<name>` across `PATH`. No crate dependency for
/// three lines of `access(2)`-shaped logic, and it keeps [`gamescope_bin`] honest about whether
/// our build is actually installed (a bare name would "resolve" and then fail at spawn).
fn which_in_path(name: &str) -> Option<String> {
use std::os::unix::fs::PermissionsExt;
let path = crate::with_env_lock(|| std::env::var("PATH").ok())?;
for dir in path.split(':').filter(|d| !d.is_empty()) {
let cand = std::path::Path::new(dir).join(name);
let Ok(md) = std::fs::metadata(&cand) else {
continue;
};
if md.is_file() && md.permissions().mode() & 0o111 != 0 {
return Some(cand.to_string_lossy().into_owned());
}
}
None
}
/// Does the resolved gamescope offer 10-bit BT.2020/PQ formats on its PipeWire node — i.e. can a
/// session on this host stream true HDR10 off a gamescope virtual output?
///
/// **A static, binary-identity answer, cached for the process.** It has to be: punktfunk fixes a
/// session's bit depth in the Welcome, *before* the display exists, and the Welcome is
/// irrevocable (PQ frames handed to an 8-bit encoder are a deliberate hard error). Optimistic
/// "spawn it and see" would strand the session. So we ask the binary, once, and believe it.
///
/// The answer is the `+pfhdr<N>` marker our build stamps into the `--version` banner (see
/// `packaging/gamescope/README.md`). When upstream takes the patch this becomes a plain version
/// floor, exactly like [`MIN_GAMESCOPE_OVERLAY`].
pub(crate) fn gamescope_hdr_capable() -> bool {
static CAP: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CAP.get_or_init(|| {
let Ok(out) = Command::new(gamescope_bin()).arg("--version").output() else {
return false;
};
// The banner goes to stderr on some builds, stdout on others (same as the version gate).
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let capable = text.contains(PFHDR_MARKER);
if capable {
tracing::info!(
bin = %gamescope_bin(),
"gamescope offers 10-bit BT.2020/PQ capture — HDR sessions are available"
);
} else {
tracing::debug!(
bin = %gamescope_bin(),
"gamescope has no {PFHDR_MARKER} marker — sessions on this backend stay 8-bit SDR \
(install punktfunk-gamescope for HDR)"
);
}
capable
})
}
/// The marker `packaging/gamescope/patches/0002-*` stamps into the `--version` banner. Matched as
/// a PREFIX (`+pfhdr1`, `+pfhdr2`, …): the number tracks the patch's wire behaviour, and every
/// revision of it still offers 10-bit PQ capture.
const PFHDR_MARKER: &str = "+pfhdr";
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
@@ -354,7 +451,10 @@ const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
/// diagnostic, not a gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new("gamescope").arg("--version").output().ok()?;
let out = Command::new(gamescope_bin())
.arg("--version")
.output()
.ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others.
let text = format!(
"{}{}",
@@ -273,6 +273,12 @@ mod linux {
/// embedded display has no cursor metadata for a channel session to forward, and a kept
/// metadata display would leave a channel-less session with no pointer in its frames.
hw_cursor: bool,
/// The stream colourimetry this display was BROUGHT UP for: 10-bit BT.2020/PQ (HDR) vs
/// 8-bit SDR. Reuse requires an exact match — a kept SDR gamescope was spawned without
/// `--hdr-enabled`, so an HDR session reusing it would get a game with no HDR surfaces
/// under a stream that negotiated PQ over an SDR composite (wrong, and not obviously
/// broken); the reverse would try to negotiate 8-bit off a PQ composite.
hdr: bool,
}
/// A per-group topology-restore action (see [`Entry::topology_restore`]).
@@ -479,6 +485,7 @@ mod linux {
&& e.mode == mode
&& e.launch == launch
&& e.hw_cursor == vd.hw_cursor()
&& e.hdr == vd.hdr()
&& epoch_matches(e.backend, e.epoch, cur_epoch)
})
.map(|e| (e.gen, e.node_id))
@@ -615,6 +622,7 @@ mod linux {
epoch: cur_epoch,
gen,
hw_cursor: vd.hw_cursor(),
hdr: vd.hdr(),
};
// Compute this new display's position in its group (design §6.2) BEFORE pushing, then push
@@ -1037,6 +1045,7 @@ mod linux {
epoch: 0,
gen,
hw_cursor: false,
hdr: false,
}
}
+49 -9
View File
@@ -12,10 +12,10 @@ use anyhow::Result;
// `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture).
pub use pf_frame::{CapturedFrame, OutputFormat, PixelFormat};
// The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths.
pub use pf_capture::{
capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer,
SyntheticCapturer,
};
// `capturer_supports_hdr` is deliberately NOT re-exported: on Linux it is only the platform floor,
// and a caller reaching for it by that name would silently miss the gamescope arm. The host's
// answer is [`capturer_supports_hdr_for`] below.
pub use pf_capture::{capturer_supports_444, Capturer, FastSyntheticCapturer, SyntheticCapturer};
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")]
@@ -60,6 +60,10 @@ fn zero_copy_policy(
pyrowave_session,
pyrowave_modifiers,
native_nv12_session,
// Only the direct-SDK NVENC backend takes a packed 10-bit PQ CUDA payload; without it an
// HDR capture must stay on the CPU path (libav's HDR route swscales into a P010 hardware
// frame). Resolved here, in the facade, like every other encode fact capture is told.
hdr_cuda_ok: pf_encode::linux_hdr_cuda_ok(),
}
}
@@ -107,13 +111,17 @@ pub fn capture_virtual_output(
want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> {
// The Linux NATIVE plane stays 8-bit (Mutter's virtual-monitor streams are SDR-only upstream;
// the GNOME 50+ HDR path is monitor-mirror only — `open_portal_monitor`) and the portal
// negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the capture
// backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch) and
// `want.chroma_444` selects the worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
// and `want.chroma_444` selects the worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4
// without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident RGB to swscale
// into YUV444P.
//
// `want.hdr` runs the 10-bit PQ/BT.2020 offer. It is only ever set for a gamescope output off
// our `pipewire-hdr` build — every other Linux virtual output is SDR-only upstream — and the
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
// the floor, which is what kept the Linux native plane at 8 bits.
pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
@@ -121,11 +129,43 @@ pub fn capture_virtual_output(
vout.keepalive,
want.gpu,
want.chroma_444,
want.hdr,
zero_copy_policy(want.pyrowave, want.nv12_native),
vout.expect_exact_dims,
)
}
/// Can the NATIVE-plane capture source this session will drive deliver a 10-bit PQ/BT.2020 frame?
/// The capture-side half of the punktfunk/1 bit-depth gate (`native::handshake`), and the single
/// source-aware answer — `pf_capture::capturer_supports_hdr()` alone cannot answer it on Linux,
/// where it depends on which compositor is resolved and which gamescope binary is installed.
///
/// **Must be truthful, because the Welcome is irrevocable**: `bit_depth` is decided before the
/// display exists, and PQ frames handed to an 8-bit encoder are a deliberate hard error
/// (`pf-encode/src/enc/linux/mod.rs`). So every term here is a STATIC fact resolvable before the
/// spawn — never "spawn it and find out".
///
/// - **Windows**: the IDD-push capturer proactively enables advanced colour → the platform answer.
/// - **Linux + gamescope**: true when the host knob allows it, the resolved gamescope binary
/// offers 10-bit BT.2020/PQ capture formats (`packaging/gamescope`), the sub-mode is one we
/// SPAWN (an attach to a foreign gamescope tells us nothing about how it was started — §3.6
/// stretch), and no earlier virtual-output HDR negotiation on this host has latched a downgrade.
/// - **Linux, anything else**: false. Mutter/KWin/wlroots virtual outputs are 8-bit upstream. The
/// other Linux HDR path — the GNOME 50+ portal monitor mirror — belongs to the GameStream plane
/// and is gated by `gamestream::host_hdr_capable` + the live monitor colour-mode probe instead.
pub fn capturer_supports_hdr_for(compositor: Option<crate::vdisplay::Compositor>) -> bool {
#[cfg(target_os = "linux")]
{
if compositor == Some(crate::vdisplay::Compositor::Gamescope) {
return pf_host_config::config().gamescope_hdr
&& pf_vdisplay::gamescope_hdr_available()
&& !pf_capture::hdr_capture_failed(pf_capture::HdrSource::VirtualOutput);
}
}
let _ = compositor;
pf_capture::capturer_supports_hdr()
}
#[cfg(target_os = "windows")]
pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput,
+20 -8
View File
@@ -68,12 +68,16 @@ pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
/// client HDR request proactively enables advanced color on the per-session virtual display so PQ
/// flows even from an SDR desktop.
///
/// **Linux**: the GNOME 50+ portal **monitor mirror** (`video_source=portal`) can negotiate the
/// 10-bit PQ formats while the mirrored monitor is in HDR mode, and the NVENC/VAAPI encoders have
/// a probed Main10 path ([`crate::encode::can_encode_10bit`]). The virtual-output source stays SDR
/// (Mutter's RecordVirtual streams are 8-bit-only upstream), so this is `false` for it. Whether
/// the monitor is ACTUALLY in HDR mode right now is checked live at RTSP honor time
/// ([`pf_capture::gnome_hdr_monitor_active`]) — this fn is the static serverinfo capability.
/// **Linux**: two sources can do it, and they are gated differently because they fail differently.
/// The GNOME 50+ portal **monitor mirror** (`video_source=portal`) negotiates the 10-bit PQ
/// formats only while the mirrored monitor is in HDR mode — a LIVE box-state fact, re-checked at
/// RTSP honor time ([`pf_capture::gnome_hdr_monitor_active`]), so this fn can only make the static
/// claim. A **gamescope virtual output** negotiates them whenever the resolved gamescope offers
/// them (our `pipewire-hdr` build) — a STATIC binary-identity fact, so
/// [`crate::capture::capturer_supports_hdr_for`] is the whole answer and the RTSP gate has nothing
/// live to add. Every other virtual output stays SDR (Mutter's RecordVirtual streams and the
/// KWin/wlroots equivalents are 8-bit upstream). Both arms also need the encoders' probed Main10
/// path ([`crate::encode::can_encode_10bit`]).
pub fn host_hdr_capable() -> bool {
if !pf_host_config::config().ten_bit {
return false;
@@ -84,8 +88,16 @@ pub fn host_hdr_capable() -> bool {
}
#[cfg(target_os = "linux")]
{
pf_host_config::config().video_source.as_deref() == Some("portal")
&& crate::encode::can_encode_10bit(crate::encode::Codec::H265)
let source_can_hdr = match pf_host_config::config().video_source.as_deref() {
Some("portal") => true,
// A virtual-output GameStream session drives the host's configured compositor; the
// gamescope arm is the only one that can be HDR. `detect()` is the same resolution
// the session itself will do, and it is cheap + cached downstream.
_ => crate::vdisplay::detect()
.ok()
.is_some_and(|c| crate::capture::capturer_supports_hdr_for(Some(c))),
};
source_can_hdr && crate::encode::can_encode_10bit(crate::encode::Codec::H265)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
+21 -5
View File
@@ -456,8 +456,15 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
);
}
// SOURCE-AWARE: the live colour-mode probe belongs to the portal MONITOR mirror, whose HDR
// depends on a monitor being in BT.2100 right now. A gamescope virtual-output session has no
// monitor at all — its HDR is a static fact about the binary we spawn, already settled by
// `host_hdr_capable` — so running the probe there would hard-refuse every gamescope HDR
// session on a headless box (there is no monitor to be in HDR mode).
#[cfg(target_os = "linux")]
if hdr && !pf_capture::gnome_hdr_monitor_active() {
let portal_source = pf_host_config::config().video_source.as_deref() == Some("portal");
#[cfg(target_os = "linux")]
if hdr && portal_source && !pf_capture::gnome_hdr_monitor_active() {
tracing::warn!(
"client requested HDR but no monitor is in BT.2100 (HDR) colour mode — enable HDR in \
GNOME Settings Displays (GNOME 50+) to stream it; streaming 8-bit SDR"
@@ -474,12 +481,21 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
// anywhere — and because the latch is sticky until host restart, every reconnect repeats it.
// Consulted here rather than folded into `host_hdr_capable` for the same reason as the probe
// above: that fn is the STATIC serverinfo capability, and this is a live per-session fact.
// The latch is PER SOURCE, so consult the one belonging to the source this session drives —
// a wedged monitor mirror must not disable a gamescope session's HDR, or vice versa.
#[cfg(target_os = "linux")]
if hdr && pf_capture::hdr_capture_failed() {
let hdr_source = if portal_source {
pf_capture::HdrSource::PortalMonitor
} else {
pf_capture::HdrSource::VirtualOutput
};
#[cfg(target_os = "linux")]
if hdr && pf_capture::hdr_capture_failed(hdr_source) {
tracing::warn!(
"client requested HDR and a monitor is in BT.2100 (HDR) colour mode, but an earlier \
HDR capture negotiation on this host failed the capturer offers SDR only for the \
rest of the process lifetime, so streaming 8-bit SDR (restart the host to retry HDR)"
?hdr_source,
"client requested HDR and the host is HDR-capable, but an earlier HDR capture \
negotiation on this source failed the capturer offers SDR only for the rest of the \
process lifetime, so streaming 8-bit SDR (restart the host to retry HDR)"
);
hdr = false;
}
+17 -5
View File
@@ -463,20 +463,32 @@ fn real_main() -> Result<()> {
}
crate::capture::dxgi::hdr_p010_selftest_at(size.0, size.1, vendor)
}
// Linux HDR readiness probe (GNOME 50+ portal path): prints whether a monitor is currently
// in BT.2100 (HDR) colour mode, whether the NVENC/VAAPI backend probes Main10 for
// HEVC/AV1, and the GameStream HDR capability the two combine into — the "why isn't my
// stream HDR?" diagnostic (no display/session needed for the encoder half).
// Linux HDR readiness probe: prints, for BOTH Linux HDR sources, whether they can deliver
// 10-bit PQ right now — the monitor mirror (is a monitor in BT.2100 colour mode?) and the
// gamescope virtual output (is the resolved gamescope our `pipewire-hdr` build, and is the
// knob on?) — plus whether the NVENC/VAAPI backend probes Main10 for HEVC/AV1 and the
// GameStream capability they combine into. The "why isn't my stream HDR?" diagnostic (no
// display/session needed for the encoder half).
#[cfg(target_os = "linux")]
Some("hdr-probe") => {
let monitor_hdr = pf_capture::gnome_hdr_monitor_active();
let hevc10 = encode::can_encode_10bit(encode::Codec::H265);
let av110 = encode::can_encode_10bit(encode::Codec::Av1);
let gs_binary_hdr = pf_vdisplay::gamescope_hdr_available();
let gs_knob = pf_host_config::config().gamescope_hdr;
let compositor = vdisplay::detect().ok();
println!("monitor in BT.2100 (HDR) colour mode: {monitor_hdr}");
println!("gamescope offers 10-bit PQ capture: {gs_binary_hdr}");
println!("PUNKTFUNK_GAMESCOPE_HDR: {gs_knob}");
println!("encoder Main10 (HEVC): {hevc10}");
println!("encoder 10-bit (AV1): {av110}");
println!(
"GameStream HDR capable (PUNKTFUNK_10BIT + video_source=portal + encoder): {}",
"native-plane HDR on the resolved compositor ({}): {}",
compositor.map_or("none".to_string(), |c| format!("{c:?}")),
crate::capture::capturer_supports_hdr_for(compositor)
);
println!(
"GameStream HDR capable (PUNKTFUNK_10BIT + a capable source + encoder): {}",
gamestream::host_hdr_capable()
);
Ok(())
+11 -11
View File
@@ -264,18 +264,18 @@ pub(super) async fn negotiate(
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
// The capture side must be able to deliver a 10-bit HDR source for the NATIVE plane's
// virtual-output capture — the honest-downgrade gate, mirroring `capturer_supports_444`.
// Windows IDD-push can (it proactively enables advanced colour); Linux cannot: Mutter's
// RecordVirtual virtual-monitor streams are 8-bit-only upstream (GNOME 50 added HDR for
// *monitor* streams only — the GameStream portal-mirror path uses that; see
// `gamestream::host_hdr_capable`), so a Linux native session honestly stays 8-bit SDR even
// though `can_encode_10bit` now probes true on a Main10-capable GPU.
// SOURCE-AWARE, because on Linux the answer depends on which compositor we just resolved:
// Windows IDD-push always can (it proactively enables advanced colour); a gamescope output
// can when the host runs our `pipewire-hdr` gamescope build and the knob allows it; every
// other Linux virtual output is 8-bit upstream (Mutter's RecordVirtual streams, KWin's and
// wlroots' virtual outputs alike — GNOME 50 added HDR for *monitor* streams only, which is
// the GameStream portal-mirror path, see `gamestream::host_hdr_capable`).
//
// That `false` is also why this plane needs no equivalent of the GameStream path's
// `pf_capture::hdr_capture_failed()` check (rtsp.rs): that latch is a fact about the PORTAL
// capturer's HDR offer, and the native plane captures a virtual output instead — it never
// reaches the portal path, and on Linux it never negotiates 10-bit at all. Revisit both halves
// together if Mutter ever gains HDR for RecordVirtual streams.
let capture_supports_hdr = crate::capture::capturer_supports_hdr();
// The gamescope arm folds in its own downgrade latch
// (`pf_capture::hdr_capture_failed(VirtualOutput)`), which is why the check the GameStream
// path makes separately in rtsp.rs has no twin here: that latch is per-source, and this gate
// already consulted the one belonging to the source this session will drive.
let capture_supports_hdr = crate::capture::capturer_supports_hdr_for(compositor);
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
// gates. The result is cached process-wide per (GPU, codec).
@@ -1168,6 +1168,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// panel instead of the driver's built-in ~1000-nit placeholder. No-op on Linux
// backends and for older/SDR clients.
vd.set_client_hdr(client_hdr);
// THIS SESSION's colourimetry (distinct from the client panel's volume above): a
// 10-bit session needs the output brought up HDR, which on gamescope means spawning
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
vd.set_hdr(bit_depth >= 10);
// Cursor-forward sessions ask the backend for an out-of-band hardware cursor
// (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it).
vd.set_hw_cursor(cursor_forward);
@@ -3191,6 +3196,7 @@ pub(super) fn prepare_display(
let mut vd = crate::vdisplay::open(compositor)?;
vd.set_client_identity(client_identity);
vd.set_client_hdr(client_hdr);
vd.set_hdr(bit_depth >= 10);
vd.set_hw_cursor(cursor_forward);
vd.set_quit_flag(quit.clone());
// Slot-scoped setup serialization + reconnect preempt — see the inline arm in
+7 -4
View File
@@ -88,10 +88,13 @@ pub struct SessionPlan {
pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
pub bit_depth: u8,
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag handed to the capturer so it
/// proactively enables advanced color on the virtual display. The Linux NATIVE plane is 8-bit
/// (Mutter's virtual-monitor streams are SDR-only upstream — GNOME 50 HDR is monitor-mirror
/// only, which the GameStream portal path uses; see `capture::capturer_supports_hdr`).
/// The want-HDR flag handed to the capturer (`bit_depth >= 10`): on Windows the IDD-push
/// capturer proactively enables advanced colour on the virtual display; on Linux it runs the
/// 10-bit PQ/BT.2020 PipeWire offer. It is only ever set where the handshake's source-aware
/// gate said yes (`capture::capturer_supports_hdr_for`) — on Linux that means a gamescope
/// output off our `pipewire-hdr` build, since Mutter's/KWin's/wlroots' virtual outputs are
/// 8-bit upstream (GNOME 50's HDR is monitor-mirror only, which is the GameStream portal
/// path's business).
pub hdr: bool,
/// Handshake-negotiated chroma subsampling (4:2:0, or full-chroma 4:4:4 when the client + host +
/// GPU all support it). Resolved before the Welcome; `Yuv420` on every backend that declined it.