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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")))]
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user