diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 281d92c6..7c03d852 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -58,10 +58,11 @@ pub const SCM_AV1_MAIN10: u32 = 0x0002_0000; /// full-chroma is a punktfunk/1-native negotiation only (`crate::capture::capturer_supports_444`). pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8; -/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate -/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and (together -/// with the live capture-side check at RTSP time) for honoring a client's `dynamicRangeMode` -/// request. Behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate the native +/// Whether this host can deliver an **HDR** (10-bit BT.2020 PQ) GameStream at all — the gate for +/// `IsHdrSupported` per app, for layering the 10-bit codec bits in serverinfo, and (together with +/// the live capture-side check and the session's own codec at RTSP time) for honoring a client's +/// `dynamicRangeMode` request. Host-wide and codec-agnostic on purpose: the per-codec depth +/// question belongs to whoever knows which codec is in play. Behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate the native /// punktfunk/1 plane honors — on both OSes. /// /// **Windows**: the IDD-push capturer streams HEVC Main10 PQ whenever the desktop is HDR, and a @@ -97,7 +98,12 @@ pub fn host_hdr_capable() -> bool { .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) + // ANY 10-bit-capable codec makes the host HDR-capable; which BITS get advertised, and + // whether a given session's negotiated codec can carry it, are per-codec questions + // answered by `serverinfo::apply_hdr` and the RTSP honor respectively. + source_can_hdr + && (crate::encode::can_encode_10bit(crate::encode::Codec::H265) + || crate::encode::can_encode_10bit(crate::encode::Codec::Av1)) } #[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 720be9c0..cf2ce1bb 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -456,6 +456,18 @@ fn stream_config(map: &HashMap) -> Option { "client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR" ); } + // …and this SESSION's codec must be one of the 10-bit-capable ones. `host_hdr_capable` is + // host-wide (any codec), and serverinfo advertises the 10-bit bit per codec, so a client + // that picked the other one has to degrade here rather than be handed a PQ label over an + // 8-bit stream. H.264 always lands here — there is no 10-bit H.264 encode anywhere. + if hdr && !crate::encode::can_encode_10bit(codec) { + tracing::warn!( + ?codec, + "client requested HDR but this host cannot encode 10-bit with the codec it \ + negotiated — streaming 8-bit SDR (pick the other codec client-side for HDR)" + ); + hdr = false; + } // 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 diff --git a/crates/punktfunk-host/src/gamestream/serverinfo.rs b/crates/punktfunk-host/src/gamestream/serverinfo.rs index b41ea2bb..fc62994d 100644 --- a/crates/punktfunk-host/src/gamestream/serverinfo.rs +++ b/crates/punktfunk-host/src/gamestream/serverinfo.rs @@ -43,26 +43,41 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String { ) } -/// The `` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus -/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] / -/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR -/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ. +/// The `` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) +/// plus the 10-bit (HDR) bit of each codec the host can actually deliver HDR with ([`apply_hdr`] / +/// [`crate::gamestream::host_hdr_capable`]). Without a 10-bit bit Moonlight never offers its HDR +/// toggle; with one, enabling HDR client-side negotiates that profile and the host streams +/// BT.2020 PQ. fn codec_mode_support() -> u32 { + use crate::encode::Codec; + // Per codec, exactly like the SDR baseline is: `can_encode_10bit` answers for the backend this + // host will actually open (on AMD/Intel, the union of VAAPI's and Vulkan Video's 10-bit + // support), so a box that encodes HEVC Main10 but not 10-bit AV1 — or the reverse — advertises + // the truth instead of one bit standing in for both. + let hdr = crate::gamestream::host_hdr_capable(); apply_hdr( base_codec_mode_support(), - crate::gamestream::host_hdr_capable(), + hdr && crate::encode::can_encode_10bit(Codec::H265), + hdr && crate::encode::can_encode_10bit(Codec::Av1), ) } -/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the -/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left -/// off until the GameStream AV1 path is live-confirmed.) -fn apply_hdr(base: u32, hdr: bool) -> u32 { - if hdr && base & super::SCM_HEVC != 0 { - base | super::SCM_HEVC_MAIN10 - } else { - base +/// Layer each codec's 10-bit (HDR) bit onto `base`, gated on the SDR baseline already advertising +/// that codec — pure so the HDR-layering is unit-testable without a GPU. +/// +/// AV1 Main10 used to be omitted unconditionally, on the theory that the GameStream AV1 path was +/// unconfirmed. But the baseline already offers AV1 **Main8** to every client, so the AV1 path is +/// either live or it is not — the depth was never the uncertain part. Now that the encoders probe +/// 10-bit per codec, withholding the bit only cost AV1-preferring clients their HDR. +fn apply_hdr(base: u32, hevc_10bit: bool, av1_10bit: bool) -> u32 { + let mut m = base; + if hevc_10bit && base & super::SCM_HEVC != 0 { + m |= super::SCM_HEVC_MAIN10; } + if av1_10bit && base & super::SCM_AV1_MAIN8 != 0 { + m |= super::SCM_AV1_MAIN10; + } + m } /// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY @@ -140,7 +155,7 @@ fn probed_mask(caps: crate::encode::CodecSupport) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::gamestream::{SCM_AV1_MAIN8, SCM_H264, SCM_HEVC, SCM_HEVC_MAIN10}; + use crate::gamestream::{SCM_AV1_MAIN10, SCM_AV1_MAIN8, SCM_H264, SCM_HEVC, SCM_HEVC_MAIN10}; /// The advertised codec mask: H.264 + HEVC + AV1 Main8 (= 65793), and explicitly *no* /// 10-bit bits — Moonlight gates its HDR mode on those, which we can't deliver (8-bit @@ -160,20 +175,29 @@ mod tests { ); } + /// The 10-bit bits are layered PER CODEC, and each needs both halves: the host able to encode + /// 10-bit for that codec, and the SDR baseline already advertising it. A client gates its HDR + /// toggle on these, so an over-claim invites it into a mode the encoder cannot open. #[test] - fn apply_hdr_adds_main10_only_when_capable_and_hevc() { - // HDR-capable + HEVC advertised → Main10 added. + fn apply_hdr_adds_each_codecs_10bit_bit_independently() { + let sdr = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8; + // Both codecs 10-bit-capable → both bits. assert_eq!( - apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true), - SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10 + apply_hdr(sdr, true, true), + sdr | SCM_HEVC_MAIN10 | SCM_AV1_MAIN10 ); - // Not HDR-capable → baseline unchanged (no HDR claim). + // Neither → baseline unchanged (no HDR claim). + assert_eq!(apply_hdr(sdr, false, false), sdr); + // One without the other — the case a single shared flag used to get wrong in both + // directions (AV1 Main10 was never advertised at all, and HEVC Main10 stood in for it). + assert_eq!(apply_hdr(sdr, true, false), sdr | SCM_HEVC_MAIN10); + assert_eq!(apply_hdr(sdr, false, true), sdr | SCM_AV1_MAIN10); + // 10-bit-capable but the codec isn't in the SDR baseline at all → no bit for it. + assert_eq!(apply_hdr(SCM_H264, true, true), SCM_H264); assert_eq!( - apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false), - SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 + apply_hdr(SCM_H264 | SCM_HEVC, true, true), + SCM_H264 | SCM_HEVC | SCM_HEVC_MAIN10 ); - // HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC). - assert_eq!(apply_hdr(SCM_H264, true), SCM_H264); } #[test] diff --git a/docs/releases/v0.21.0.md b/docs/releases/v0.21.0.md index e151939f..cc43d089 100644 --- a/docs/releases/v0.21.0.md +++ b/docs/releases/v0.21.0.md @@ -45,6 +45,7 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the - **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode. - **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render). - **Vulkan Video learned 10-bit**, which is what keeps AMD/Intel HDR on the good path: an HDR session opens a 10-bit video profile (HEVC Main10 / AV1 Main at 10 bits) with a `G10X6…3PACK16` picture and DPB, headers carrying the depth and the BT.2020/PQ CICP triplet — an SPS `bit_depth_*_minus8 = 2` for HEVC, `high_bitdepth` plus the matching sequence-header OBU bits for AV1 — and a new `rgb2yuv10.comp`: the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all. +- **GameStream advertises its 10-bit codec bits per codec.** `ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10` — a blanket omission on the theory that the GameStream AV1 path was unconfirmed, even though the SDR baseline has always offered AV1 Main8, so the depth was never the uncertain part. Each 10-bit bit is now gated on that codec's own `can_encode_10bit` probe AND the baseline already advertising it, and the RTSP honor degrades a session whose NEGOTIATED codec can't carry 10 bits rather than labelling an 8-bit stream PQ. `host_hdr_capable` became codec-agnostic to match (any 10-bit-capable codec makes the host HDR-capable; which one a session gets is the session's question). - **The Vulkan encode backend is now capability-probed per codec AND depth**, mirroring what the direct-SDK NVENC path already does with its GUID probe. `vkGetPhysicalDeviceVideoCapabilitiesKHR` is asked against the very profile the session open will build, so the dispatcher's prediction cannot disagree with reality: a device that can encode 10-bit keeps the Vulkan path, one that can't routes to libav VAAPI *before* burning a failed session open, and `can_encode_10bit` reports the union of what VAAPI and Vulkan Video can do rather than VAAPI's answer alone (which was under-reporting 10-bit on hardware that could do it). - **The zero-CSC RGB-direct (EFC) source works in HDR too.** The `VK_VALVE_video_encode_rgb_conversion` probe now asks for the BT.2020 model and the captured 10-bit packed-RGB format instead of assuming BT.709/BGRA, and the session selects the matching model — so an HDR session with no pointer to composite (the GameStream desktop mirror) hands the captured buffer straight to the encoder's fixed-function front end and runs no host CSC at all. Sessions that DO composite a pointer keep the compute CSC, as before: the EFC cannot blend. - **NVIDIA gained a zero-copy HDR leg**, and the VAAPI fallback needed no new encoder code. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill.