From 689297c86e1d3c0553e9981c67352247087d04a1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 14:37:52 +0200 Subject: [PATCH] feat(hdr): a gamescope session streams true HDR10, decided before the Welcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/pf-capture/src/lib.rs | 114 +++++++++--- crates/pf-capture/src/linux/mod.rs | 44 +++-- crates/pf-capture/src/linux/pipewire.rs | 104 ++++++++--- crates/pf-host-config/src/lib.rs | 21 +++ crates/pf-vdisplay/src/lib.rs | 27 +++ crates/pf-vdisplay/src/vdisplay/backend.rs | 16 ++ .../src/vdisplay/linux/gamescope.rs | 167 +++++++++++++++--- .../src/vdisplay/linux/gamescope/discovery.rs | 104 ++++++++++- crates/pf-vdisplay/src/vdisplay/registry.rs | 9 + crates/punktfunk-host/src/capture.rs | 58 +++++- crates/punktfunk-host/src/gamestream/mod.rs | 28 ++- crates/punktfunk-host/src/gamestream/rtsp.rs | 26 ++- crates/punktfunk-host/src/main.rs | 22 ++- crates/punktfunk-host/src/native/handshake.rs | 22 +-- crates/punktfunk-host/src/native/stream.rs | 6 + crates/punktfunk-host/src/session_plan.rs | 11 +- 16 files changed, 643 insertions(+), 136 deletions(-) diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index dd6848ca..8a1489c7 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -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 { @@ -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, + /// 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> { 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, allow_zerocopy: bool, want_444: bool, + want_hdr: bool, policy: ZeroCopyPolicy, expect_exact_dims: bool, ) -> Result> { @@ -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, ) diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 4f4efb9c..7dc129de 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -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, @@ -320,6 +325,7 @@ impl PortalCapturer { keepalive: Box, allow_zerocopy: bool, want_444: bool, + want_hdr: bool, policy: ZeroCopyPolicy, expect_exact_dims: bool, ) -> Result { @@ -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>, portal: Option, + 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 { 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 \ diff --git a/crates/pf-capture/src/linux/pipewire.rs b/crates/pf-capture/src/linux/pipewire.rs index 4a118e65..5b86f19e 100644 --- a/crates/pf-capture/src/linux/pipewire.rs +++ b/crates/pf-capture/src/linux/pipewire.rs @@ -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, diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 7b61a926..ffab3527 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -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, /// `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::().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()), diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index ac292a00..95b82459 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -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. diff --git a/crates/pf-vdisplay/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs index 30abd077..36a2498a 100644 --- a/crates/pf-vdisplay/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -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) {} + /// 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 diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 353f60a0..4e2ac10a 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -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, + /// 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 { +fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result { // 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 { 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 { 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 { } // (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 { 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 { - 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 { +fn create_managed_session_steamos(mode: Mode, hdr: bool) -> Result { 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 { )); } 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 { 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 { 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 { /// 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 { +fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Result { 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 { .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 { + 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 -- `. 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 { +fn spawn( + w: u32, + h: u32, + hz: u32, + cmd: Option<&str>, + log: &std::path::Path, + hdr: bool, +) -> Result { // 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 diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs index 3672fa14..2cb48d35 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs @@ -329,13 +329,110 @@ pub(super) fn find_gamescope_eis_socket() -> Option { /// 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 = 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 `/` 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 { + 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` 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 = 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!( "{}{}", diff --git a/crates/pf-vdisplay/src/vdisplay/registry.rs b/crates/pf-vdisplay/src/vdisplay/registry.rs index e5d7209a..79daebdb 100644 --- a/crates/pf-vdisplay/src/vdisplay/registry.rs +++ b/crates/pf-vdisplay/src/vdisplay/registry.rs @@ -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, } } diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 54ee6d24..02243889 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -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> { - // 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) -> 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, diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 9ea9a2ea..281d92c6 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -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")))] { diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 88fa2b87..720be9c0 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -456,8 +456,15 @@ fn stream_config(map: &HashMap) -> Option { "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) -> Option { // 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; } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index c89a078c..3dea42c2 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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(()) diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index c802a536..884606e5 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -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). diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 1a1c02e0..9ad74396 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1168,6 +1168,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option= 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 diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs index 6b5f378e..3bc05b29 100644 --- a/crates/punktfunk-host/src/session_plan.rs +++ b/crates/punktfunk-host/src/session_plan.rs @@ -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.