From d0415b097e87d9a6506c5aac17a6f46f19ac5dec Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 19:52:06 +0100 Subject: [PATCH 01/60] feat: Implement wp_color_management_v1 Wayland protocol This commit introduces the `wp_color_management_v1` Wayland protocol, allowing clients to provide parametric image descriptions for surfaces. This enables the compositor to correctly interpret and display content on various display types, such as HDR or wide-gamut monitors. The implementation focuses on parametric descriptions (primaries, transfer functions, luminances) and explicitly does not support ICC profiles, aligning with the scope of GLES-based rendering pipelines. --- src/shojiwm/src/protocols/color_management.rs | 62 +++++++++++++++++++ src/shojiwm/src/protocols/mod.rs | 1 + 2 files changed, 63 insertions(+) create mode 100644 src/shojiwm/src/protocols/color_management.rs diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs new file mode 100644 index 00000000..1cb11852 --- /dev/null +++ b/src/shojiwm/src/protocols/color_management.rs @@ -0,0 +1,62 @@ +//! wp_color_management_v1: advertises per-output image descriptions and +//! records per-surface parametric image descriptions in the surface data_map. + +use smithay::reexports::wayland_protocols::wp::color_management::v1::server::{ + wp_color_manager_v1::{self, WpColorManagerV1, RenderIntent, Feature, Primaries, TransferFunction}, + wp_color_management_surface_v1::WpColorManagementSurfaceV1, + wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1, + wp_image_description_v1::WpImageDescriptionV1, + wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, +}; +use crate::color::{ImageDescription, TransferCharacteristics, ColorPrimaries}; + +const VERSION: u32 = 1; + +/// Per-surface color state, stored in the surface's data_map +/// (same pattern as TearingControlSurfaceData in tearing_control.rs). +#[derive(Debug, Default)] +pub struct ColorSurfaceData { + /// Committed image description; None => compositor assumes sRGB. + pub description: Mutex>, +} + +pub struct ColorManagementState; + +impl ColorManagementState { + pub fn new(display: &DisplayHandle) -> Self + where + D: GlobalDispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + 'static, + { + display.create_global::(VERSION, ()); + Self + } +} + +/// Advertised on bind: parametric-only, no ICC (matches GLES pipeline scope). +pub fn send_supported(mgr: &WpColorManagerV1) { + mgr.supported_feature(Feature::Parametric); + mgr.supported_feature(Feature::SetPrimaries); + mgr.supported_feature(Feature::SetLuminances); + mgr.supported_rendering_intent(RenderIntent::Perceptual); + mgr.supported_primaries_named(Primaries::Srgb); + mgr.supported_primaries_named(Primaries::Bt2020); + mgr.supported_tf_named(TransferFunction::Srgb); + mgr.supported_tf_named(TransferFunction::St2084Pq); + mgr.supported_tf_named(TransferFunction::ExtLinear); + mgr.done(); +} + +/// Render-side read, mirroring surface_prefers_tearing (tearing_control.rs:71). +pub fn surface_image_description(surface: &WlSurface) -> ImageDescription { + with_states(surface, |states| { + states.data_map.get::() + .and_then(|d| d.description.lock().unwrap().clone()) + .unwrap_or(ImageDescription::SRGB) + }) +} \ No newline at end of file diff --git a/src/shojiwm/src/protocols/mod.rs b/src/shojiwm/src/protocols/mod.rs index 839c6343..ead8b7bf 100644 --- a/src/shojiwm/src/protocols/mod.rs +++ b/src/shojiwm/src/protocols/mod.rs @@ -1,2 +1,3 @@ pub mod screencopy; pub mod tearing_control; +mod color_management; From 1947eec06c8fde7239074ef3e889635d14c46143 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 21:10:24 +0100 Subject: [PATCH 02/60] Add HDR output formats and color management infrastructure Introduce core types for managing output color modes (SDR/HDR10) and blending spaces (sRGB/Linear BT.2020). This includes enabling 10-bit scanout formats (ABGR/ARGB2101010) and integrating a new color management protocol, laying the groundwork for full HDR support. --- src/shojiwm/src/backend/tty.rs | 6 +++++- src/shojiwm/src/color/mod.rs | 28 ++++++++++++++++++++++++++++ src/shojiwm/src/state.rs | 2 ++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/shojiwm/src/color/mod.rs diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index b4591f7c..3c875692 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1002,7 +1002,11 @@ pub fn device_added( allocator, exporter, Some(gbm), - [Format::Argb8888], + [ + Format::Abgr2101010, + Format::Argb2101010, + Format::Argb8888 + ], render_formats, ); diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs new file mode 100644 index 00000000..f25d8767 --- /dev/null +++ b/src/shojiwm/src/color/mod.rs @@ -0,0 +1,28 @@ +/// What an output is driven as. Decided per-connector in tty.rs from +/// EDID capability + config override; SDR is the zero-cost default. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum OutputColorMode { + /// Today's path, bit-for-bit: Argb8888 scanout, sRGB blending. + Sdr, + /// PQ/BT.2020 signal: 10-bit scanout + HDR_OUTPUT_METADATA blob. + Hdr10 { max_display_luminance: f32, min_display_luminance: f32 }, +} + +/// The space all compositing (blur, liquid-glass, blending) happens in. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BlendSpace { + /// Non-linear sRGB in Abgr8888 — unchanged current behavior. + Srgb, + /// Linear-light BT.2020 in Abgr16161616F (fp16). Requires + /// GL_EXT_color_buffer_half_float; probed once at device_added. + LinearBt2020, +} + +pub struct OutputColorState { + pub mode: OutputColorMode, + pub blend_space: BlendSpace, + /// EDID-derived display capabilities (smithay-drm-extras). + pub edid_hdr: Option, + /// Cached DRM property handles + current metadata blob id. + pub drm_props: Option, +} \ No newline at end of file diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index 2de68e17..4fdfadb2 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -1040,6 +1040,8 @@ impl ShojiWM { crate::protocols::screencopy::ScreencopyManagerState::new::(&dh, |_| true); let tearing_control_state = crate::protocols::tearing_control::TearingControlManagerState::new::(&dh); + let color_management_state = + crate::protocols::color_management::ColorManagementState::new::(&dh); let foreign_toplevel_list_state = smithay::wayland::foreign_toplevel_list::ForeignToplevelListState::new::(&dh); let wlr_foreign_toplevel_manager_state = From 6a1ee0bbccc9ec1b3d779beb9605d66a3cf26a33 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:03:03 +0100 Subject: [PATCH 03/60] Add parametric color descriptions and dynamic output mode resolution Introduce detailed parametric descriptions for color primaries, transfer characteristics, and luminances via `ImageDescription`. These types form the basis for `wp_color_management_v1` and DRM metadata signaling. Implement logic to dynamically resolve the `OutputColorMode` for each connector, factoring in EDID capabilities and the `SHOJI_HDR_OUTPUTS` experimental gate. Per-output state now captures a synthesized `ImageDescription` based on the resolved mode. Note that while HDR signaling is enabled, the compositor's blend space remains sRGB; full linear BT.2020 blending is a future phase. --- src/shojiwm/src/color/mod.rs | 223 +++++++++++++++++++++++++++++++++-- 1 file changed, 213 insertions(+), 10 deletions(-) diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs index f25d8767..bcb3a158 100644 --- a/src/shojiwm/src/color/mod.rs +++ b/src/shojiwm/src/color/mod.rs @@ -1,28 +1,231 @@ -/// What an output is driven as. Decided per-connector in tty.rs from -/// EDID capability + config override; SDR is the zero-cost default. +//! Color management core: output color modes, blend spaces, and the +//! parametric image descriptions shared by the `wp_color_management_v1` +//! protocol (`protocols/color_management.rs`), the DRM signaling layer +//! (`drm_metadata`), and — in a later phase — the render pipeline. + +pub mod drm_metadata; +pub mod primaries; + +use drm_metadata::EdidHdrMetadata; +use tracing::warn; + +/// Named primaries we support parametrically (no custom chromaticities yet). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] +pub enum ColorPrimaries { + Srgb, + Bt2020, +} + +/// Transfer characteristics we support parametrically. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] +pub enum TransferCharacteristics { + Srgb, + St2084Pq, + ExtLinear, +} + +/// Luminance metadata in cd/m², from `set_luminances` or the +/// per-transfer-function protocol defaults. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct Luminances { + pub min: f32, + pub max: f32, + pub reference: f32, +} + +impl TransferCharacteristics { + /// Protocol-defined default luminances: PQ has fixed absolute levels, + /// everything else defaults to the SDR 80 cd/m² reference. + pub fn default_luminances(self) -> Luminances { + match self { + TransferCharacteristics::St2084Pq => Luminances { + min: 0.005, + max: 10000.0, + reference: 203.0, + }, + TransferCharacteristics::Srgb | TransferCharacteristics::ExtLinear => Luminances { + min: 0.2, + max: 80.0, + reference: 80.0, + }, + } + } +} + +/// An immutable parametric image description, as created through +/// `wp_image_description_creator_params_v1` or synthesized for an output. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct ImageDescription { + pub primaries: ColorPrimaries, + pub tf: TransferCharacteristics, + /// `None` => the transfer characteristic's default luminances apply. + pub luminances: Option, + /// Maximum content light level (cd/m²), if the client provided one. + pub max_cll: Option, + /// Maximum frame-average light level (cd/m²), if the client provided one. + pub max_fall: Option, +} + +impl ImageDescription { + pub const SRGB: Self = Self { + primaries: ColorPrimaries::Srgb, + tf: TransferCharacteristics::Srgb, + luminances: None, + max_cll: None, + max_fall: None, + }; + + pub fn effective_luminances(&self) -> Luminances { + self.luminances + .unwrap_or_else(|| self.tf.default_luminances()) + } +} + +/// What an output is driven as. Decided per-connector in `tty.rs` from +/// EDID capability + the `SHOJI_HDR_OUTPUTS` gate; SDR is the zero-cost +/// default. #[derive(Debug, Clone, Copy, PartialEq)] pub enum OutputColorMode { - /// Today's path, bit-for-bit: Argb8888 scanout, sRGB blending. + /// Today's path, bit-for-bit: 8/10-bit scanout, sRGB signal. Sdr, /// PQ/BT.2020 signal: 10-bit scanout + HDR_OUTPUT_METADATA blob. - Hdr10 { max_display_luminance: f32, min_display_luminance: f32 }, + Hdr10 { + max_display_luminance: f32, + min_display_luminance: f32, + }, } /// The space all compositing (blur, liquid-glass, blending) happens in. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq +)] pub enum BlendSpace { /// Non-linear sRGB in Abgr8888 — unchanged current behavior. Srgb, /// Linear-light BT.2020 in Abgr16161616F (fp16). Requires - /// GL_EXT_color_buffer_half_float; probed once at device_added. + /// GL_EXT_color_buffer_half_float; not wired up yet (phase 3). LinearBt2020, } +/// Per-output color state, keyed by output name in `ShojiWM::output_color`. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct OutputColorState { pub mode: OutputColorMode, pub blend_space: BlendSpace, - /// EDID-derived display capabilities (smithay-drm-extras). + /// The signal description clients observe through + /// `wp_color_management_output_v1`. + pub description: ImageDescription, + /// EDID-derived HDR capabilities (CTA-861-G static metadata block). pub edid_hdr: Option, - /// Cached DRM property handles + current metadata blob id. - pub drm_props: Option, -} \ No newline at end of file + /// DRM blob id of the HDR_OUTPUT_METADATA currently applied to the + /// connector; destroyed on disconnect. + pub hdr_metadata_blob: Option, +} + +impl OutputColorState { + pub fn new( + mode: OutputColorMode, + edid_hdr: Option, + hdr_metadata_blob: Option, + ) -> Self { + let description = match mode { + OutputColorMode::Sdr => ImageDescription::SRGB, + OutputColorMode::Hdr10 { + max_display_luminance, + min_display_luminance, + } => ImageDescription { + primaries: ColorPrimaries::Bt2020, + tf: TransferCharacteristics::St2084Pq, + luminances: Some(Luminances { + min: min_display_luminance, + max: max_display_luminance, + reference: 203.0, + }), + max_cll: None, + max_fall: None, + }, + }; + // Blending stays sRGB until the fp16 linear pipeline (phase 3) + // lands: an HDR-signaled output therefore shows incorrect + // (washed-out) colors and is only useful for hardware bring-up. + Self { + mode, + blend_space: BlendSpace::Srgb, + description, + edid_hdr, + hdr_metadata_blob, + } + } +} + +/// Experimental gate: `SHOJI_HDR_OUTPUTS=DP-1,DP-2` (or `all`) opts +/// connectors into HDR10 signaling and widens the protocol advertisement +/// to PQ/BT.2020. Off by default because the render pipeline still +/// composites in sRGB. +pub fn hdr_experiment_enabled() -> bool { + std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| !value.trim().is_empty()) +} + +fn hdr_output_requested(output_name: &str) -> bool { + std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| { + value + .split(',') + .map(str::trim) + .any(|entry| entry == "all" || entry == output_name) + }) +} + +/// Decide how to drive a connector: HDR10 only when the user opted the +/// output in *and* its EDID advertises ST 2084 support. +pub fn resolve_output_mode( + output_name: &str, + edid_hdr: Option<&EdidHdrMetadata>, +) -> OutputColorMode { + if !hdr_output_requested(output_name) { + return OutputColorMode::Sdr; + } + let Some(edid) = edid_hdr else { + warn!( + output = output_name, + "HDR requested but EDID has no HDR static metadata block; staying SDR" + ); + return OutputColorMode::Sdr; + }; + if !edid.supports_pq { + warn!( + output = output_name, + "HDR requested but display does not advertise ST 2084 (PQ); staying SDR" + ); + return OutputColorMode::Sdr; + } + OutputColorMode::Hdr10 { + max_display_luminance: edid.max_luminance.unwrap_or(1000.0), + min_display_luminance: edid.min_luminance.unwrap_or(0.005), + } +} From 29190fad03583eacaad58767a0f7c88f32a3883e Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:08:50 +0100 Subject: [PATCH 04/60] Add color primary chromaticity data Introduce `Chromaticity` and `PrimariesChromaticities` types to represent CIE 1931 color primary and white point data for standards like sRGB (BT.709) and BT.2020. This also includes utility methods for encoding this data into formats used by the Wayland color management protocol (`wp_image_description_info_v1`) and DRM HDR metadata (CTA-861-G). --- src/shojiwm/src/color/primaries.rs | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/shojiwm/src/color/primaries.rs diff --git a/src/shojiwm/src/color/primaries.rs b/src/shojiwm/src/color/primaries.rs new file mode 100644 index 00000000..5240b894 --- /dev/null +++ b/src/shojiwm/src/color/primaries.rs @@ -0,0 +1,103 @@ +//! CIE 1931 chromaticity data for the named primaries we support, plus the +//! two wire encodings that consume it (Wayland protocol micro-units and +//! CTA-861-G / DRM HDR metadata units). + +use super::ColorPrimaries; + +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct Chromaticity { + pub x: f32, + pub y: f32, +} + +impl Chromaticity { + /// `wp_image_description_info_v1.primaries` encoding: CIE xy × 1,000,000. + pub fn to_protocol(self) -> ( + i32, + i32 + ) { + ( + (self.x * 1_000_000.0).round() as i32, + (self.y * 1_000_000.0).round() as i32, + ) + } + + /// CTA-861-G / kernel `hdr_output_metadata` encoding: CIE xy in units + /// of 0.00002 (i.e. × 50,000). + pub fn to_cta861(self) -> ( + u16, + u16 + ) { + ( + (self.x * 50_000.0).round() as u16, + (self.y * 50_000.0).round() as u16, + ) + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct PrimariesChromaticities { + pub red: Chromaticity, + pub green: Chromaticity, + pub blue: Chromaticity, + pub white: Chromaticity, +} + +/// BT.709 primaries with D65 white point (shared by sRGB). +pub const SRGB: PrimariesChromaticities = PrimariesChromaticities { + red: Chromaticity { + x: 0.640, + y: 0.330 + }, + green: Chromaticity { + x: 0.300, + y: 0.600 + }, + blue: Chromaticity { + x: 0.150, + y: 0.060 + }, + white: Chromaticity { + x: 0.3127, + y: 0.3290 + }, +}; + +/// BT.2020 primaries with D65 white point. +pub const BT2020: PrimariesChromaticities = PrimariesChromaticities { + red: Chromaticity { + x: 0.708, + y: 0.292 + }, + green: Chromaticity { + x: 0.170, + y: 0.797 + }, + blue: Chromaticity { + x: 0.131, + y: 0.046 + }, + white: Chromaticity { + x: 0.3127, + y: 0.3290 + }, +}; + +impl ColorPrimaries { + pub fn chromaticities(self) -> PrimariesChromaticities { + match self { + ColorPrimaries::Srgb => SRGB, + ColorPrimaries::Bt2020 => BT2020, + } + } +} From 634e0b05e6a43bf29bcb6b426efa152ea0744503 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:20:21 +0100 Subject: [PATCH 05/60] Add EDID HDR probing and DRM HDR10 signaling This module introduces functionality to read HDR static metadata from a connector's EDID, determining capabilities like EOTF support and luminance ranges. It also implements the signaling mechanism for HDR10 output, configuring DRM connector properties such as `max bpc`, `Colorspace` (to BT2020_RGB), and `HDR_OUTPUT_METADATA` (SMPTE ST 2086 / CTA-861.3 blob) to inform the display about the incoming HDR signal. These properties are set using legacy `SET_PROPERTY` ioctl to ensure persistence across smithay's atomic commits. A reset function is provided for transitioning back to SDR. --- src/shojiwm/src/color/drm_metadata.rs | 338 ++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 src/shojiwm/src/color/drm_metadata.rs diff --git a/src/shojiwm/src/color/drm_metadata.rs b/src/shojiwm/src/color/drm_metadata.rs new file mode 100644 index 00000000..05e2248d --- /dev/null +++ b/src/shojiwm/src/color/drm_metadata.rs @@ -0,0 +1,338 @@ +//! EDID HDR capability probing and DRM connector properties for HDR10 +//! signaling: `max bpc`, `Colorspace`, and the `HDR_OUTPUT_METADATA` +//! (SMPTE ST 2086 / CTA-861.3) property blob. +//! +//! Property writes use the legacy SET_PROPERTY ioctl on purpose: the kernel +//! folds them into the connector's atomic state, and smithay's commit path +//! never touches these three properties, so they persist across the +//! DrmOutputManager's own atomic commits. + +use std::io; + +use smithay::reexports::drm::control::{Device as ControlDevice, connector, property}; +use tracing::{debug, warn}; + +use super::{ColorPrimaries, OutputColorMode}; + +/// CTA-861-G HDR static metadata parsed from the EDID. +#[derive( + Debug, + Clone, + Copy, + PartialEq +)] +pub struct EdidHdrMetadata { + /// Display accepts SMPTE ST 2084 (PQ) EOTF. + pub supports_pq: bool, + /// Display accepts Hybrid Log-Gamma EOTF. + pub supports_hlg: bool, + /// Desired max luminance (cd/m²), if the panel reports one. + pub max_luminance: Option, + /// Desired max frame-average luminance (cd/m²), if reported. + pub max_frame_avg_luminance: Option, + /// Desired min luminance (cd/m²), if reported (needs max to decode). + pub min_luminance: Option, +} + +const PROP_EDID: &str = "EDID"; +const PROP_COLORSPACE: &str = "Colorspace"; +const PROP_HDR_OUTPUT_METADATA: &str = "HDR_OUTPUT_METADATA"; +const PROP_MAX_BPC: &str = "max bpc"; + +/// Kernel uapi `hdr_metadata_infoframe` (drm_mode.h), CTA-861.3 static +/// metadata type 1. Chromaticities in 0.00002 units, max mastering +/// luminance in cd/m², min in 0.0001 cd/m². +#[repr(C)] +struct HdrMetadataInfoframe { + eotf: u8, + metadata_type: u8, + display_primaries: [[u16; 2]; 3], + white_point: [u16; 2], + max_display_mastering_luminance: u16, + min_display_mastering_luminance: u16, + max_cll: u16, + max_fall: u16, +} + +/// Kernel uapi `hdr_output_metadata` (drm_mode.h). +#[repr(C)] +struct HdrOutputMetadata { + metadata_type: u32, + hdmi_metadata_type1: HdrMetadataInfoframe, +} + +/// CTA-861-G EOTF code for SMPTE ST 2084 (PQ). +const HDMI_EOTF_ST2084: u8 = 2; +/// CTA-861-G static metadata descriptor type 1. +const HDMI_STATIC_METADATA_TYPE1: u8 = 0; + +fn find_connector_property( + device: &impl ControlDevice, + conn: &connector::Info, + name: &str, +) -> Option<(property::Info, property::RawValue)> { + let props = device.get_properties(conn.handle()).ok()?; + for (handle, value) in props.iter() { + let Ok(info) = device.get_property(*handle) else { + continue; + }; + if info.name().to_str() == Ok(name) { + return Some((info, *value)); + } + } + None +} + +/// Read the connector's EDID blob and extract the CTA-861-G HDR static +/// metadata data block, if the display has one. +pub fn read_edid_hdr( + device: &impl ControlDevice, + conn: &connector::Info, +) -> Option { + let (_, blob_id) = find_connector_property( + device, + conn, + PROP_EDID)?; + if blob_id == 0 { + return None; + } + let edid = device.get_property_blob(blob_id).ok()?; + parse_edid_hdr(&edid) +} + +/// Scan EDID extension blocks for a CTA-861 block containing the HDR +/// static metadata data block (extended tag 0x06). +pub fn parse_edid_hdr(edid: &[u8]) -> Option { + if edid.len() < 128 { + return None; + } + let extension_count = edid[126] as usize; + for block_index in 1..=extension_count { + let start = block_index * 128; + let Some(block) = edid.get(start..start + 128) else { + break; + }; + // CTA-861 extension block tag. + if block[0] != 0x02 { + continue; + } + // Byte 2: offset of the detailed timing descriptors; the data + // block collection sits between byte 4 and that offset. + let dtd_offset = (block[2] as usize).min(128); + if dtd_offset < 4 { + continue; + } + let mut index = 4; + while index < dtd_offset { + let header = block[index]; + let tag = header >> 5; + let length = (header & 0x1f) as usize; + if index + 1 + length > dtd_offset { + break; + } + // Extended tag block (7) with extended tag 0x06 = HDR static + // metadata. Payload: [eotf bitfield, descriptor bitfield, + // optional max/max-frame-avg/min luminance codes]. + if tag == 0x07 && length >= 2 && block[index + 1] == 0x06 { + let payload = &block[index + 2..index + 1 + length]; + let eotfs = payload[0]; + let max_code = payload.get(2) + .copied() + .filter(|&code| code != 0); + let max_frame_avg_code = payload + .get(3) + .copied() + .filter(|&code| code != 0); + let min_code = payload + .get(4) + .copied(); + let max_luminance = max_code.map(cta_luminance); + let max_frame_avg_luminance = max_frame_avg_code.map(cta_luminance); + // Min luminance decoding needs the max value as reference. + let min_luminance = match (max_luminance, min_code) { + (Some(max), Some(code)) => { + let fraction = code as f32 / 255.0; + Some(max * fraction * fraction / 100.0) + } + _ => None, + }; + return Some(EdidHdrMetadata { + supports_pq: eotfs & (1 << 2) != 0, + supports_hlg: eotfs & (1 << 3) != 0, + max_luminance, + max_frame_avg_luminance, + min_luminance, + }); + } + index += 1 + length; + } + } + None +} + +/// CTA-861-G luminance code decoding: 50 * 2^(code/32) cd/m². +fn cta_luminance(code: u8) -> f32 { + 50.0 * 2f32.powf(code as f32 / 32.0) +} + +/// Put the connector into HDR10 signaling: max bpc >= 10, Colorspace = +/// BT2020_RGB, and an ST 2086 metadata blob. Returns the blob id so the +/// caller can destroy it on disconnect. +pub fn apply_hdr_connector_state( + device: &impl ControlDevice, + conn: &connector::Info, + mode: &OutputColorMode, +) -> io::Result> { + let OutputColorMode::Hdr10 { + max_display_luminance, + min_display_luminance, + } = *mode + else { + return Ok(None); + }; + + // Raise the link depth so the 10-bit scanout format isn't dithered + // back down; missing property is fine (some drivers always run 10-bit). + if let Some((info, current)) = find_connector_property(device, conn, PROP_MAX_BPC) { + let target = match info.value_type() { + property::ValueType::UnsignedRange(_, max) => (*max).min(10), + _ => 10, + }; + if current < target { + device.set_property(conn.handle(), info.handle(), target)?; + } + } + + // Without a Colorspace property the sink would interpret the PQ signal + // as sRGB — bail instead of producing garbage. + let (colorspace_info, _) = find_connector_property(device, conn, PROP_COLORSPACE) + .ok_or_else(|| io::Error::other("connector has no Colorspace property"))?; + let bt2020_value = match colorspace_info.value_type() { + property::ValueType::Enum(values) => values + .enums() + .iter() + .find(|entry| entry + .name() + .to_str() == Ok("BT2020_RGB")) + .map(|entry| entry.value()), + _ => None, + } + .ok_or_else(|| io::Error::other("Colorspace property has no BT2020_RGB entry"))?; + + let (metadata_info, _) = find_connector_property( + device, + conn, + PROP_HDR_OUTPUT_METADATA + ) + .ok_or_else(|| io::Error::other("connector has no HDR_OUTPUT_METADATA property"))?; + + let chroma = ColorPrimaries::Bt2020.chromaticities(); + let metadata = HdrOutputMetadata { + metadata_type: HDMI_STATIC_METADATA_TYPE1 as u32, + hdmi_metadata_type1: HdrMetadataInfoframe { + eotf: HDMI_EOTF_ST2084, + metadata_type: HDMI_STATIC_METADATA_TYPE1, + display_primaries: [ + [ + chroma.red.to_cta861().0, + chroma.red.to_cta861().1 + ], + [ + chroma.green.to_cta861().0, + chroma.green.to_cta861().1 + ], + [ + chroma.blue.to_cta861().0, + chroma.blue.to_cta861().1 + ], + ], + white_point: [ + chroma.white.to_cta861().0, + chroma.white.to_cta861().1 + ], + max_display_mastering_luminance: max_display_luminance.round() as u16, + min_display_mastering_luminance: (min_display_luminance * 10000.0).round() as u16, + // 0 = unknown; we don't track content light levels yet. + max_cll: 0, + max_fall: 0, + }, + }; + let blob = device.create_property_blob(&metadata)?; + let property::Value::Blob(blob_id) = blob else { + return Err(io::Error::other("create_property_blob returned non-blob")); + }; + device.set_property( + conn.handle(), + metadata_info.handle(), + blob_id + )?; + device.set_property( + conn.handle(), + colorspace_info.handle(), + bt2020_value + )?; + debug!( + connector = ?conn.handle(), + blob_id, + "applied HDR10 connector state" + ); + Ok(Some(blob_id)) +} + +/// Best-effort reset for SDR outputs: clears HDR metadata and Colorspace +/// leftovers from a previous session so the sink drops out of HDR mode. +pub fn reset_hdr_connector_state( + device: &impl ControlDevice, + conn: &connector::Info +) { + if let Some((info, current)) = find_connector_property( + device, + conn, + PROP_HDR_OUTPUT_METADATA + ) + { + if current != 0 { + if let Err(error) = device.set_property( + conn.handle(), + info.handle(), + 0 + ) { + warn!( + ?error, + "failed to clear HDR_OUTPUT_METADATA" + ); + } + } + } + if let Some((info, current)) = find_connector_property( + device, + conn, + PROP_COLORSPACE + ) { + let default_value = match info.value_type() { + property::ValueType::Enum(values) => values + .enums() + .iter() + .find(|entry| entry.name() + .to_str() == Ok("Default")) + .map(|entry| entry.value()), + _ => None, + }; + if let Some(default_value) = default_value { + if current != default_value { + if let Err(error) = + device.set_property( + conn.handle(), + info.handle(), + default_value + ) + { + warn!( + ?error, + "failed to reset Colorspace" + ); + } + } + } + } +} From 56bb0d2326a494b38624cc9b8600cf220643fa9c Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:44:26 +0100 Subject: [PATCH 06/60] Add full `wp_color_management_v1` protocol implementation This change fully implements the `wp_color_management_v1` Wayland protocol for parametric image descriptions. It allows clients to query output color capabilities, provide color descriptions for their surfaces, and request preferred descriptions from the compositor. HDR features, such as BT.2020 primaries and ST.2084 PQ transfer functions, are now dynamically advertised based on the `SHOJI_HDR_OUTPUTS` experiment gate, ensuring clients only use capabilities the compositor supports. The implementation includes comprehensive state management and error handling for all protocol objects. --- src/shojiwm/src/protocols/color_management.rs | 917 +++++++++++++++++- 1 file changed, 878 insertions(+), 39 deletions(-) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index 1cb11852..6c7460a1 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -1,62 +1,901 @@ -//! wp_color_management_v1: advertises per-output image descriptions and -//! records per-surface parametric image descriptions in the surface data_map. +//! `wp_color_management_v1` global. +//! +//! Advertises the compositor's color capabilities, exposes per-output image +//! descriptions, and records per-surface parametric image descriptions in +//! the surface `data_map` (same passive pattern as `tearing_control.rs`). +//! +//! Scope: parametric-only (no ICC), perceptual render intent, sRGB always; +//! PQ/BT.2020 entries are added when the `SHOJI_HDR_OUTPUTS` experiment is +//! enabled (`crate::color::hdr_experiment_enabled`). +//! +//! Note: the protocol specifies `set_image_description` as double-buffered +//! (applied on `wl_surface.commit`). We apply it immediately instead — the +//! same simplification `tearing_control.rs` documents; clients set this +//! once before their first commit in practice. + +use std::sync::{ + Mutex, + atomic::{ + AtomicBool, + AtomicU32, + Ordering + }, +}; use smithay::reexports::wayland_protocols::wp::color_management::v1::server::{ - wp_color_manager_v1::{self, WpColorManagerV1, RenderIntent, Feature, Primaries, TransferFunction}, - wp_color_management_surface_v1::WpColorManagementSurfaceV1, - wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1, - wp_image_description_v1::WpImageDescriptionV1, - wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, + wp_color_management_output_v1::{ + self, + WpColorManagementOutputV1 + }, + wp_color_management_surface_feedback_v1::{ + self, + WpColorManagementSurfaceFeedbackV1 + }, + wp_color_management_surface_v1::{ + self, + WpColorManagementSurfaceV1 + }, + wp_color_manager_v1::{ + self, + Feature, + Primaries, + RenderIntent, + TransferFunction, + WpColorManagerV1, + }, + wp_image_description_creator_icc_v1::{ + self, + WpImageDescriptionCreatorIccV1 + }, + wp_image_description_creator_params_v1::{ + self, + WpImageDescriptionCreatorParamsV1 + }, + wp_image_description_info_v1::{ + self, + WpImageDescriptionInfoV1 + }, + wp_image_description_v1::{ + self, + WpImageDescriptionV1 + }, +}; +use smithay::reexports::wayland_server::{ + Client, + DataInit, + Dispatch, + DisplayHandle, + GlobalDispatch, + New, + Resource, + WEnum, + backend::ClientId, + protocol::{wl_output::WlOutput, + wl_surface::WlSurface}, +}; +use smithay::wayland::compositor::with_states; + +use crate::color::{ + ColorPrimaries, + ImageDescription, + Luminances, + TransferCharacteristics, + hdr_experiment_enabled, }; -use crate::color::{ImageDescription, TransferCharacteristics, ColorPrimaries}; const VERSION: u32 = 1; -/// Per-surface color state, stored in the surface's data_map -/// (same pattern as TearingControlSurfaceData in tearing_control.rs). +/// Compositor-side lookups the protocol cannot answer from surface state. +pub trait ColorManagementHandler { + /// The image description of the signal this output is driven with. + fn output_image_description(&mut self, output: &WlOutput) -> ImageDescription; + /// The description the compositor prefers for this surface's content. + fn surface_preferred_description(&mut self, surface: &WlSurface) -> ImageDescription; +} + +/// Aggregate dispatch bound so each impl below doesn't repeat nine clauses. +pub trait ColorManagementDispatch: + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + ColorManagementHandler + + 'static +{ +} + +impl ColorManagementDispatch for T where + T: Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + Dispatch + + ColorManagementHandler + + 'static +{ +} + +/// Per-surface color state, stored in the surface's `data_map`. #[derive(Debug, Default)] pub struct ColorSurfaceData { - /// Committed image description; None => compositor assumes sRGB. - pub description: Mutex>, + /// A `wp_color_management_surface_v1` already exists for this surface; + /// the protocol requires erroring on a second one. + has_surface_object: AtomicBool, + /// Committed image description; `None` => untagged (compositor assumes + /// sRGB). + description: Mutex>, +} + +fn with_color_surface_data( + surface: &WlSurface, + f: impl FnOnce(&ColorSurfaceData) -> T, +) -> T { + with_states(surface, |states| { + states + .data_map + .insert_if_missing_threadsafe(ColorSurfaceData::default); + f(states.data_map.get::().unwrap()) + }) } +/// Render-side read: the surface's committed image description, or `None` +/// for untagged content (treat as sRGB). +pub fn surface_image_description(surface: &WlSurface) -> Option { + if !surface.is_alive() { + return None; + } + with_states(surface, |states| { + states + .data_map + .get::() + .and_then( + |data| *data + .description + .lock() + .unwrap() + ) + }) +} + +/// Manager state for the `wp_color_manager_v1` global. +#[derive(Debug)] pub struct ColorManagementState; impl ColorManagementState { + /// Create and advertise the `wp_color_manager_v1` global. pub fn new(display: &DisplayHandle) -> Self where - D: GlobalDispatch - + Dispatch - + Dispatch - + Dispatch - + Dispatch - + Dispatch - + 'static, + D: GlobalDispatch + ColorManagementDispatch, { display.create_global::(VERSION, ()); Self } } -/// Advertised on bind: parametric-only, no ICC (matches GLES pipeline scope). -pub fn send_supported(mgr: &WpColorManagerV1) { - mgr.supported_feature(Feature::Parametric); - mgr.supported_feature(Feature::SetPrimaries); - mgr.supported_feature(Feature::SetLuminances); - mgr.supported_rendering_intent(RenderIntent::Perceptual); - mgr.supported_primaries_named(Primaries::Srgb); - mgr.supported_primaries_named(Primaries::Bt2020); - mgr.supported_tf_named(TransferFunction::Srgb); - mgr.supported_tf_named(TransferFunction::St2084Pq); - mgr.supported_tf_named(TransferFunction::ExtLinear); - mgr.done(); +/// Sent once on bind: parametric-only, perceptual intent, sRGB always; +/// HDR entries only while the experiment gate is on so clients never +/// submit PQ content the render pipeline can't handle yet. +fn send_supported(manager: &WpColorManagerV1) { + manager.supported_intent(RenderIntent::Perceptual); + manager.supported_feature(Feature::Parametric); + manager.supported_primaries_named(Primaries::Srgb); + manager.supported_tf_named(TransferFunction::Srgb); + if hdr_experiment_enabled() { + manager.supported_feature(Feature::SetLuminances); + manager.supported_primaries_named(Primaries::Bt2020); + manager.supported_tf_named(TransferFunction::St2084Pq); + manager.supported_tf_named(TransferFunction::ExtLinear); + } + manager.done(); } -/// Render-side read, mirroring surface_prefers_tearing (tearing_control.rs:71). -pub fn surface_image_description(surface: &WlSurface) -> ImageDescription { - with_states(surface, |states| { - states.data_map.get::() - .and_then(|d| d.description.lock().unwrap().clone()) - .unwrap_or(ImageDescription::SRGB) - }) -} \ No newline at end of file +/// Monotonic identity for `wp_image_description_v1.ready`. Unique per +/// object is conformant (equal ids must mean identical descriptions; +/// distinct ids carry no meaning). +fn next_identity() -> u32 { + static NEXT_IDENTITY: AtomicU32 = AtomicU32::new(1); + NEXT_IDENTITY.fetch_add( + 1, + Ordering::Relaxed + ) +} + +/// Object data for `wp_color_management_output_v1`. +#[derive(Debug)] +pub struct ColorOutputData { + output: WlOutput, +} + +/// Object data for `wp_color_management_surface_v1`. +#[derive(Debug)] +pub struct ColorSurfaceObjData { + surface: WlSurface, +} + +/// Object data for `wp_color_management_surface_feedback_v1`. +#[derive(Debug)] +pub struct FeedbackData { + surface: WlSurface, +} + +/// Object data for `wp_image_description_v1`. `None` marks a failed +/// description (created only to consume the id on an error path). +#[derive(Debug)] +pub struct ImageDescriptionData { + description: Option, +} + +/// Accumulator for `wp_image_description_creator_params_v1`. +#[derive(Debug, Default)] +pub struct ParametricCreatorData { + params: Mutex, +} + +#[derive(Debug, Default, Clone, Copy)] +struct CreatorParams { + primaries: Option, + tf: Option, + luminances: Option, + max_cll: Option, + max_fall: Option, +} + +/// Initialize a description object, mark it ready, and return it. +fn init_ready_description( + data_init: &mut DataInit<'_, D>, + id: New, + description: ImageDescription, +) -> WpImageDescriptionV1 +where + D: Dispatch + 'static, +{ + let object = data_init.init( + id, + ImageDescriptionData { + description: Some(description), + }, + ); + object.ready(next_identity()); + object +} + +fn protocol_primaries(primaries: ColorPrimaries) -> Primaries { + match primaries { + ColorPrimaries::Srgb => Primaries::Srgb, + ColorPrimaries::Bt2020 => Primaries::Bt2020, + } +} + +fn protocol_tf(tf: TransferCharacteristics) -> TransferFunction { + match tf { + TransferCharacteristics::Srgb => TransferFunction::Srgb, + TransferCharacteristics::St2084Pq => TransferFunction::St2084Pq, + TransferCharacteristics::ExtLinear => TransferFunction::ExtLinear, + } +} + +fn send_information(info: &WpImageDescriptionInfoV1, description: &ImageDescription) { + let chroma = description.primaries.chromaticities(); + let (r_x, r_y) = chroma.red.to_protocol(); + let (g_x, g_y) = chroma.green.to_protocol(); + let (b_x, b_y) = chroma.blue.to_protocol(); + let (w_x, w_y) = chroma.white.to_protocol(); + info.primaries( + r_x, + r_y, + g_x, + g_y, + b_x, + b_y, + w_x, + w_y + ); + info.primaries_named( + protocol_primaries( + description.primaries + ) + ); + info.tf_named( + protocol_tf( + description.tf + ) + ); + let luminances = description.effective_luminances(); + info.luminances( + (luminances.min * 10000.0).round() as u32, + luminances.max.round() as u32, + luminances.reference.round() as u32, + ); + // `done` is a destructor event: the info object dies here. + info.done(); +} + +impl GlobalDispatch for ColorManagementState +where + D: GlobalDispatch + ColorManagementDispatch, +{ + fn bind( + _state: &mut D, + _dh: &DisplayHandle, + _client: &Client, + manager: New, + _data: &(), + data_init: &mut DataInit<'_, D>, + ) { + let manager = data_init.init( + manager, + () + ); + send_supported( + &manager + ); + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + manager: &WpColorManagerV1, + request: wp_color_manager_v1::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_manager_v1::Request::GetOutput { + id, + output + } => { + data_init.init( + id, + ColorOutputData { + output + } + ); + } + wp_color_manager_v1::Request::GetSurface { id, surface } => { + // Always init to consume the id, even on the error path. + let already_present = with_color_surface_data( + &surface, + |data| { + data + .has_surface_object + .swap( + true, + Ordering::Relaxed + ) + } + ); + data_init.init( + id, + ColorSurfaceObjData { + surface: surface.clone(), + }, + ); + if already_present { + manager.post_error( + wp_color_manager_v1::Error::SurfaceExists, + "wl_surface already has a wp_color_management_surface_v1", + ); + } + } + wp_color_manager_v1::Request::GetSurfaceFeedback { + id, + surface + } => { + data_init.init( + id, + FeedbackData { + surface + } + ); + } + wp_color_manager_v1::Request::CreateIccCreator { obj } => { + data_init.init( + obj, + () + ); + manager.post_error( + wp_color_manager_v1::Error::UnsupportedFeature, + "icc_v2_v4 is not supported", + ); + } + wp_color_manager_v1::Request::CreateParametricCreator { obj } => { + data_init.init( + obj, + ParametricCreatorData::default() + ); + } + wp_color_manager_v1::Request::CreateWindowsScrgb { image_description } => { + data_init.init( + image_description, + ImageDescriptionData { + description: None + } + ); + manager.post_error( + wp_color_manager_v1::Error::UnsupportedFeature, + "windows_scrgb is not supported", + ); + } + wp_color_manager_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + state: &mut D, + _client: &Client, + _output_obj: &WpColorManagementOutputV1, + request: wp_color_management_output_v1::Request, + data: &ColorOutputData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_output_v1::Request::GetImageDescription { + image_description, + } => { + let description = state.output_image_description(&data.output); + init_ready_description( + data_init, + image_description, + description + ); + } + wp_color_management_output_v1::Request::Destroy => {} + _ => {} + } + } +} + +fn reset_surface_color(surface: &WlSurface) { + if !surface.is_alive() { + return; + } + with_color_surface_data(surface, |data| { + data.has_surface_object.store(false, Ordering::Relaxed); + *data.description.lock().unwrap() = None; + }); +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + surface_obj: &WpColorManagementSurfaceV1, + request: wp_color_management_surface_v1::Request, + data: &ColorSurfaceObjData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_surface_v1::Request::SetImageDescription { + image_description, + render_intent, + } => { + if !data.surface.is_alive() { + surface_obj.post_error( + wp_color_management_surface_v1::Error::Inert, + "the wl_surface has been destroyed", + ); + return; + } + if render_intent != WEnum::Value(RenderIntent::Perceptual) { + surface_obj.post_error( + wp_color_management_surface_v1::Error::RenderIntent, + "unsupported render intent", + ); + return; + } + let description = image_description + .data::() + .and_then(|data| data.description); + let Some(description) = description else { + surface_obj.post_error( + wp_color_management_surface_v1::Error::ImageDescription, + "image description is not usable", + ); + return; + }; + with_color_surface_data(&data.surface, |surface_data| { + *surface_data.description.lock().unwrap() = Some(description); + }); + } + wp_color_management_surface_v1::Request::UnsetImageDescription => { + if data.surface.is_alive() { + with_color_surface_data(&data.surface, |surface_data| { + *surface_data.description.lock().unwrap() = None; + }); + } + } + wp_color_management_surface_v1::Request::Destroy => { + reset_surface_color(&data.surface); + } + _ => {} + } + } + + fn destroyed( + _state: &mut D, + _client: ClientId, + _surface_obj: &WpColorManagementSurfaceV1, + data: &ColorSurfaceObjData, + ) { + // Safety net for clients that drop the object without an explicit + // destroy request (e.g. on disconnect). + reset_surface_color(&data.surface); + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + state: &mut D, + _client: &Client, + feedback: &WpColorManagementSurfaceFeedbackV1, + request: wp_color_management_surface_feedback_v1::Request, + data: &FeedbackData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_color_management_surface_feedback_v1::Request::GetPreferred { + image_description, + } + | wp_color_management_surface_feedback_v1::Request::GetPreferredParametric { + image_description, + } => { + if !data.surface.is_alive() { + data_init + .init(image_description, ImageDescriptionData { description: None }); + feedback.post_error( + wp_color_management_surface_feedback_v1::Error::Inert, + "the wl_surface has been destroyed", + ); + return; + } + let description = state.surface_preferred_description( + &data.surface + ); + init_ready_description( + data_init, + image_description, + description + ); + } + wp_color_management_surface_feedback_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + _creator: &WpImageDescriptionCreatorIccV1, + _request: wp_image_description_creator_icc_v1::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + // Only reachable after the unsupported_feature error already killed + // the client; nothing to do. + } +} + +impl Dispatch + for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + creator: &WpImageDescriptionCreatorParamsV1, + request: wp_image_description_creator_params_v1::Request, + data: &ParametricCreatorData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + use wp_image_description_creator_params_v1::{Error, Request}; + match request { + Request::SetTfNamed { tf } => { + let WEnum::Value(named) = tf else { + creator.post_error( + Error::InvalidTf, + "invalid transfer function" + ); + return; + }; + let mapped = match named { + TransferFunction::Srgb => Some(TransferCharacteristics::Srgb), + TransferFunction::St2084Pq if hdr_experiment_enabled() => { + Some(TransferCharacteristics::St2084Pq) + } + TransferFunction::ExtLinear if hdr_experiment_enabled() => { + Some(TransferCharacteristics::ExtLinear) + } + _ => None, + }; + let Some(mapped) = mapped else { + creator.post_error( + Error::InvalidTf, + "unsupported transfer function" + ); + return; + }; + let mut params = data.params.lock().unwrap(); + if params.tf.replace(mapped).is_some() { + creator.post_error( + Error::AlreadySet, + "transfer function already set" + ); + } + } + Request::SetPrimariesNamed { primaries } => { + let WEnum::Value(named) = primaries else { + creator.post_error( + Error::InvalidPrimariesNamed, + "invalid primaries" + ); + return; + }; + let mapped = match named { + Primaries::Srgb => Some(ColorPrimaries::Srgb), + Primaries::Bt2020 if hdr_experiment_enabled() => Some(ColorPrimaries::Bt2020), + _ => None, + }; + let Some(mapped) = mapped else { + creator.post_error( + Error::InvalidPrimariesNamed, + "unsupported primaries" + ); + return; + }; + let mut params = data.params + .lock() + .unwrap(); + if params.primaries.replace(mapped).is_some() { + creator.post_error( + Error::AlreadySet, + "primaries already set" + ); + } + } + Request::SetLuminances { + min_lum, + max_lum, + reference_lum, + } => { + if !hdr_experiment_enabled() { + creator.post_error( + Error::UnsupportedFeature, + "set_luminances is not supported", + ); + return; + } + let luminances = Luminances { + min: min_lum as f32 / 10000.0, + max: max_lum as f32, + reference: reference_lum as f32, + }; + if luminances.max <= luminances.min || luminances.reference <= luminances.min { + creator.post_error( + Error::InvalidLuminance, + "invalid luminance ordering" + ); + return; + } + let mut params = data.params + .lock() + .unwrap(); + if params.luminances.replace(luminances).is_some() { + creator.post_error( + Error::AlreadySet, + "luminances already set" + ); + } + } + Request::SetMaxCll { max_cll } => { + let mut params = data.params + .lock() + .unwrap(); + if params.max_cll.replace(max_cll).is_some() { + creator.post_error( + Error::AlreadySet, + "max_cll already set" + ); + } + } + Request::SetMaxFall { max_fall } => { + let mut params = data.params + .lock() + .unwrap(); + if params.max_fall.replace(max_fall).is_some() { + creator.post_error( + Error::AlreadySet, + "max_fall already set" + ); + } + } + Request::SetTfPower { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_tf_power is not supported" + ); + } + Request::SetPrimaries { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_primaries is not supported" + ); + } + Request::SetMasteringDisplayPrimaries { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_mastering_display_primaries is not supported", + ); + } + Request::SetMasteringLuminance { .. } => { + creator.post_error( + Error::UnsupportedFeature, + "set_mastering_luminance is not supported", + ); + } + Request::Create { image_description } => { + let params = *data.params.lock().unwrap(); + match (params.primaries, params.tf) { + ( + Some(primaries), + Some(tf) + ) => { + init_ready_description( + data_init, + image_description, + ImageDescription { + primaries, + tf, + luminances: params.luminances, + max_cll: params.max_cll, + max_fall: params.max_fall, + }, + ); + } + _ => { + data_init.init( + image_description, + ImageDescriptionData { + description: None + }, + ); + creator.post_error( + Error::IncompleteSet, + "primaries and transfer function are required", + ); + } + } + } + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + description_obj: &WpImageDescriptionV1, + request: wp_image_description_v1::Request, + data: &ImageDescriptionData, + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, D>, + ) { + match request { + wp_image_description_v1::Request::GetInformation { information } => { + let info = data_init.init(information, ()); + match &data.description { + Some(description) => send_information( + &info, + description + ), + None => { + description_obj.post_error( + wp_image_description_v1::Error::NoInformation, + "image description has no information", + ); + } + } + } + wp_image_description_v1::Request::Destroy => {} + _ => {} + } + } +} + +impl Dispatch for ColorManagementState +where + D: ColorManagementDispatch, +{ + fn request( + _state: &mut D, + _client: &Client, + _info: &WpImageDescriptionInfoV1, + _request: wp_image_description_info_v1::Request, + _data: &(), + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, D>, + ) { + // The interface has no requests. + } +} + +/// Delegate the `wp_color_management_v1` globals to [`ColorManagementState`]. +#[macro_export] +macro_rules! delegate_color_management { + ($(@<$( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+>)? $ty: ty) => { + smithay::reexports::wayland_server::delegate_global_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_manager_v1::WpColorManagerV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_manager_v1::WpColorManagerV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_output_v1::WpColorManagementOutputV1: $crate::protocols::color_management::ColorOutputData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_surface_v1::WpColorManagementSurfaceV1: $crate::protocols::color_management::ColorSurfaceObjData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1: $crate::protocols::color_management::FeedbackData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_creator_icc_v1::WpImageDescriptionCreatorIccV1: () + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1: $crate::protocols::color_management::ParametricCreatorData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_v1::WpImageDescriptionV1: $crate::protocols::color_management::ImageDescriptionData + ] => $crate::protocols::color_management::ColorManagementState); + + smithay::reexports::wayland_server::delegate_dispatch!($(@< $( $lt $( : $clt $(+ $dlt )* )? ),+ >)? $ty: [ + smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_info_v1::WpImageDescriptionInfoV1: () + ] => $crate::protocols::color_management::ColorManagementState); + }; +} From 81d5fe0e2d10c3b67feef502b8c7418d2c00076c Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:45:24 +0100 Subject: [PATCH 07/60] make module public --- src/shojiwm/src/protocols/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shojiwm/src/protocols/mod.rs b/src/shojiwm/src/protocols/mod.rs index ead8b7bf..f37b827a 100644 --- a/src/shojiwm/src/protocols/mod.rs +++ b/src/shojiwm/src/protocols/mod.rs @@ -1,3 +1,3 @@ pub mod screencopy; pub mod tearing_control; -mod color_management; +pub mod color_management; From 8b5b87170ac12e0364532b1116d7902ef09abfdc Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:45:49 +0100 Subject: [PATCH 08/60] add public clour module --- src/shojiwm/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/main.rs b/src/shojiwm/src/main.rs index 548d8a21..0b9de7dd 100644 --- a/src/shojiwm/src/main.rs +++ b/src/shojiwm/src/main.rs @@ -23,6 +23,7 @@ use tracing_subscriber::EnvFilter; pub mod activation_environment; pub mod backend; +pub mod color; pub mod config; pub mod config_error; pub mod cursor; From 28512ea241f7d2553e4014ccad13b692894c4a74 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:48:13 +0100 Subject: [PATCH 09/60] add colour management states --- src/shojiwm/src/state.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index 4fdfadb2..1f24e585 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -259,6 +259,7 @@ pub struct ShojiWM { pub fractional_scale_manager_state: FractionalScaleManagerState, pub screencopy_state: crate::protocols::screencopy::ScreencopyManagerState, pub tearing_control_state: crate::protocols::tearing_control::TearingControlManagerState, + pub color_management_state: crate::protocols::color_management::ColorManagementState, pub foreign_toplevel_list_state: smithay::wayland::foreign_toplevel_list::ForeignToplevelListState, pub wlr_foreign_toplevel_manager_state: @@ -338,6 +339,8 @@ pub struct ShojiWM { pub runtime_scheduler_kick_interval_ms: Option, pub runtime_animation_outputs: std::collections::HashSet, pub runtime_output_globals: HashMap, + /// Per-output color mode/signal state, keyed by output name (tty only). + pub output_color: HashMap, pub managed_window_animations: HashMap>, pub managed_window_animation_sequence: u64, pub runtime_output_configs: std::collections::BTreeMap, @@ -1228,6 +1231,7 @@ impl ShojiWM { fractional_scale_manager_state, screencopy_state, tearing_control_state, + color_management_state, foreign_toplevel_list_state, wlr_foreign_toplevel_manager_state, ext_workspace_manager_state, @@ -1294,6 +1298,7 @@ impl ShojiWM { runtime_scheduler_kick_interval_ms: None, runtime_animation_outputs: Default::default(), runtime_output_globals: Default::default(), + output_color: Default::default(), managed_window_animations: Default::default(), managed_window_animation_sequence: 0, runtime_output_configs: Default::default(), From eb367b9f124fb4ed649c07946d45d14a1d3b52fb Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 22:54:53 +0100 Subject: [PATCH 10/60] Initialize output color management on connector connect This change integrates the previously introduced EDID HDR probing and DRM HDR10 signaling into the output connection process. When a connector is detected, its color pipeline is resolved, determining if HDR10 signaling is applicable based on EDID. The necessary DRM connector properties are configured to signal HDR to the display. The resolved output color state is then stored and made available to Wayland clients via the `wp_color_management_v1` protocol. --- src/shojiwm/src/backend/tty.rs | 65 +++++++++++++++++++++++++++++++++ src/shojiwm/src/handlers/mod.rs | 26 +++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 3c875692..95d2cf24 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11661,6 +11661,71 @@ fn connector_connected( } let mode = select_output_mode(&connector, &state.display_config.default_mode); + + // Resolve the output's color pipeline before the DRM surface exists so + // the connector properties (max bpc / Colorspace / HDR_OUTPUT_METADATA) + // are already part of the connector state when initialize_output performs + // the first atomic commit on this CRTC. + let color_state = { + let backend = state.tty_backends + .get(&node) + .unwrap(); + let device = backend.drm_output_manager.device(); + let edid_hdr = crate::color::drm_metadata::read_edid_hdr( + device, + &connector + ); + let color_mode = crate::color::resolve_output_mode( + &output_name, + edid_hdr.as_ref() + ); + let hdr_metadata_blob = match color_mode { + crate::color::OutputColorMode::Hdr10 { .. } => { + match crate::color::drm_metadata::apply_hdr_connector_state( + device, + &connector, + &color_mode, + ) { + Ok(blob) => blob, + Err(error) => { + warn!( + output = %output_name, + ?error, + "failed to apply HDR connector state; falling back to SDR signaling" + ); + None + } + } + } + crate::color::OutputColorMode::Sdr => { + // Clear leftovers from a previous session so the sink drops + // out of HDR mode. + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector + ); + None + } + }; + crate::color::OutputColorState::new( + color_mode, + edid_hdr, + hdr_metadata_blob + ) + }; + info!( + output = %output_name, + mode = ?color_state.mode, + edid_hdr = ?color_state.edid_hdr, + "resolved output color mode" + ); + state + .output_color + .insert( + output_name.clone(), + color_state + ); + let available_modes = connector .modes() .iter() diff --git a/src/shojiwm/src/handlers/mod.rs b/src/shojiwm/src/handlers/mod.rs index 5324ea9a..077d4f33 100644 --- a/src/shojiwm/src/handlers/mod.rs +++ b/src/shojiwm/src/handlers/mod.rs @@ -874,7 +874,33 @@ impl crate::protocols::screencopy::ScreencopyHandler for ShojiWM { } } +impl crate::protocols::color_management::ColorManagementHandler for ShojiWM { + fn output_image_description( + &mut self, + wl_output: &smithay::reexports::wayland_server::protocol::wl_output::WlOutput, + ) -> crate::color::ImageDescription { + Output::from_resource(wl_output) + .and_then(|output| { + self.output_color + .get(output.name().as_str()) + .map(|state| state.description) + }) + .unwrap_or(crate::color::ImageDescription::SRGB) + } + + fn surface_preferred_description( + &mut self, + _surface: &WlSurface, + ) -> crate::color::ImageDescription { + // The compositor composites in sRGB, so sRGB content is what we + // prefer from every client regardless of the output's signal mode. + // Revisit when the fp16 linear blend space (phase 3) lands. + crate::color::ImageDescription::SRGB + } +} + crate::delegate_screencopy!(ShojiWM); crate::delegate_tearing_control!(ShojiWM); +crate::delegate_color_management!(ShojiWM); crate::delegate_wlr_foreign_toplevel!(ShojiWM); crate::workspace_manager::delegate_ext_workspace_manager!(ShojiWM); From 465584bb94ef76a07875da1f34594bebf8f8c9c7 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 23:01:21 +0100 Subject: [PATCH 11/60] Destroy HDR property blobs on output disconnect The kernel only frees DRM property blobs when the device file descriptor closes. Since the DRM device remains open across hotplug cycles, explicitly destroy HDR metadata blobs when an output disconnects to prevent resource leaks during replug cycles. --- src/shojiwm/src/backend/tty.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 95d2cf24..15421bda 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11846,6 +11846,25 @@ fn connector_disconnected( let Some(surface) = backend.surfaces.remove(&crtc) else { return; }; + if let Some( + color_state + ) = state.output_color.remove( + &output_name + ) { + if let Some( + blob + ) = color_state.hdr_metadata_blob { + // The kernel only frees property blobs when the DRM fd closes, + // and this device stays open across hotplugs — destroy explicitly + // so replug cycles don't leak blobs. + let _ = backend + .drm_output_manager + .device() + .destroy_property_blob( + blob + ); + } + } let output = surface.output; state.space.unmap_output(&output); state.remove_output_global(&output); From e971c9ad097bc88050e753aca778abcf151059a1 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 23:04:52 +0100 Subject: [PATCH 12/60] Refactor HDR metadata blob destruction Extract the logic for destroying HDR output metadata blobs into a dedicated helper function. This centralizes the destruction process, adds error logging, and improves code organization within the color management backend. --- src/shojiwm/src/backend/tty.rs | 11 +++++------ src/shojiwm/src/color/drm_metadata.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 15421bda..09ae1c1e 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11857,12 +11857,11 @@ fn connector_disconnected( // The kernel only frees property blobs when the DRM fd closes, // and this device stays open across hotplugs — destroy explicitly // so replug cycles don't leak blobs. - let _ = backend - .drm_output_manager - .device() - .destroy_property_blob( - blob - ); + crate::color::drm_metadata::destroy_metadata_blob( + backend.drm_output_manager + .device(), + blob, + ); } } let output = surface.output; diff --git a/src/shojiwm/src/color/drm_metadata.rs b/src/shojiwm/src/color/drm_metadata.rs index 05e2248d..07b6306b 100644 --- a/src/shojiwm/src/color/drm_metadata.rs +++ b/src/shojiwm/src/color/drm_metadata.rs @@ -336,3 +336,22 @@ pub fn reset_hdr_connector_state( } } } + +/// Free an HDR_OUTPUT_METADATA blob created by [`apply_hdr_connector_state`]. +/// Best-effort: the kernel reclaims blobs at fd close anyway. +pub fn destroy_metadata_blob( + device: &impl ControlDevice, + blob: u64 +) { + if let Err( + error + ) = device.destroy_property_blob( + blob + ) { + warn!( + ?error, + blob, + "failed to destroy HDR metadata blob" + ); + } +} From 49fc007cee7b02887c1b313dc90ca73302e497d2 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 23:18:19 +0100 Subject: [PATCH 13/60] Update DRM enum property value access Adjust how enum property values are retrieved from the `drm` crate's `ValueType::Enum` variant to match an API change. The values are now accessed via `values().1`. --- src/shojiwm/src/color/drm_metadata.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shojiwm/src/color/drm_metadata.rs b/src/shojiwm/src/color/drm_metadata.rs index 07b6306b..b697a81b 100644 --- a/src/shojiwm/src/color/drm_metadata.rs +++ b/src/shojiwm/src/color/drm_metadata.rs @@ -209,7 +209,8 @@ pub fn apply_hdr_connector_state( .ok_or_else(|| io::Error::other("connector has no Colorspace property"))?; let bt2020_value = match colorspace_info.value_type() { property::ValueType::Enum(values) => values - .enums() + .values() + .1 .iter() .find(|entry| entry .name() @@ -311,7 +312,8 @@ pub fn reset_hdr_connector_state( ) { let default_value = match info.value_type() { property::ValueType::Enum(values) => values - .enums() + .values() + .1 .iter() .find(|entry| entry.name() .to_str() == Ok("Default")) From cf3d03206ade74afd7898bae24638d8c65d6911e Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 5 Jul 2026 23:35:33 +0100 Subject: [PATCH 14/60] Add tests for HDR EDID parsing and DRM metadata structure alignment These tests verify the correct extraction of HDR static metadata from EDID blocks. They also confirm that the Rust `HdrOutputMetadata` and `HdrMetadataInfoframe` structs match the expected kernel ABI, which is crucial to prevent silent corruption of HDR infoframe data passed to the display. --- src/shojiwm/src/color/drm_metadata.rs | 144 ++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/shojiwm/src/color/drm_metadata.rs b/src/shojiwm/src/color/drm_metadata.rs index b697a81b..ecccdb7e 100644 --- a/src/shojiwm/src/color/drm_metadata.rs +++ b/src/shojiwm/src/color/drm_metadata.rs @@ -357,3 +357,147 @@ pub fn destroy_metadata_blob( ); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Base EDID block + one CTA-861 extension carrying an HDR static + /// metadata data block (extended tag 0x06). + fn edid_with_hdr_block( + eotfs: u8, + max: u8, + favg: u8, + min: u8 + ) -> Vec { + let mut edid = vec![0u8; 256]; + // one extension block + edid[126] = 1; + let ext = 128; + // CTA-861 tag + edid[ext] = 0x02; + // DTDs start at byte 12; data blocks in 4..12 + edid[ext + 2] = 12; + // extended tag block, length 6 + edid[ext + 4] = (0x07 << 5) | 6; + // HDR static metadata + edid[ext + 5] = 0x06; + edid[ext + 6] = eotfs; + // static metadata type 1 + edid[ext + 7] = 0x01; + edid[ext + 8] = max; + edid[ext + 9] = favg; + edid[ext + 10] = min; + edid + } + + #[test] + fn parses_hdr_static_metadata() { + // EOTF bits: SDR (0) + ST 2084 (2). Code 96 = 50 * 2^3 = 400 cd/m². + let edid = edid_with_hdr_block( + 0b0000_0101, + 96, + 64, + 255); + let hdr = parse_edid_hdr( + &edid + ).expect( + "HDR block should parse" + ); + assert!( + hdr.supports_pq + ); + assert!( + !hdr.supports_hlg + ); + assert_eq!( + hdr.max_luminance, + Some( + 400.0 + ) + ); + assert_eq!( + hdr.max_frame_avg_luminance, + Some( + 200.0 + ) + ); + // min code 255 => max * 1.0² / 100. + assert_eq!( + hdr.min_luminance, + Some( + 4.0 + ) + ); + } + + #[test] + fn ignores_edid_without_hdr_block() { + // Plain base block, no extensions. + let edid = vec![0u8; 128]; + assert_eq!( + parse_edid_hdr( + &edid + ), + None + ); + // CTA extension present but empty data block collection. + let mut edid = vec![0u8; 256]; + edid[126] = 1; + edid[128] = 0x02; + edid[130] = 4; + assert_eq!( + parse_edid_hdr( + &edid + ), + None, + ); + } + + #[test] + fn zero_luminance_codes_mean_unknown() { + let edid = edid_with_hdr_block( + 0b0000_0100, + 0, + 0, + 0, + ); + let hdr = parse_edid_hdr( + &edid + ).expect( + "HDR block should parse", + ); + assert!( + hdr.supports_pq, + ); + assert_eq!( + hdr.max_luminance, + None, + ); + assert_eq!( + hdr.min_luminance, + None, + ); + } + + #[test] + fn hdr_metadata_blob_matches_kernel_layout() { + // The kernel copies sizeof(struct hdr_output_metadata) bytes; a + // layout drift would corrupt the infoframe silently. + assert_eq!( + std::mem::size_of::(), + 26, + ); + assert_eq!( + std::mem::size_of::(), + 32, + ); + assert_eq!( + std::mem::offset_of!( + HdrOutputMetadata, + hdmi_metadata_type1, + ), + 4, + ); + } +} From a108aca44fef312fbbc6935eebf6fdb0903b6ff3 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 00:33:23 +0100 Subject: [PATCH 15/60] Implement colorimetry calculations and PQ curves Provides Rust implementations for RGB-XYZ matrix derivation and SMPTE ST 2084 (PQ) curves. Unit tests use these to validate hardcoded color transformation constants in GLSL shaders, preventing subtle color errors. --- src/shojiwm/src/color/colorimetry.rs | 245 +++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/shojiwm/src/color/colorimetry.rs diff --git a/src/shojiwm/src/color/colorimetry.rs b/src/shojiwm/src/color/colorimetry.rs new file mode 100644 index 00000000..de9352f0 --- /dev/null +++ b/src/shojiwm/src/color/colorimetry.rs @@ -0,0 +1,245 @@ +//! Gamut conversion matrix derivation (RGB → CIE XYZ → RGB) and the SMPTE +//! ST 2084 (PQ) reference curves. +//! +//! The GPU encode pass (`backend/output_encode.frag`) hardcodes the +//! BT.709→BT.2020 matrix and PQ constants for GLSL ES 1.00 compatibility; +//! the unit tests here re-derive them from first principles so a typo in +//! either place fails CI instead of shipping subtly wrong color. + +use super::ColorPrimaries; +use super::primaries::PrimariesChromaticities; + +pub type Mat3 = [[f64; 3]; 3]; + +/// RGB → CIE XYZ for the given primaries, white point normalized to Y = 1. +fn rgb_to_xyz( + chroma: PrimariesChromaticities +) -> Mat3 { + // Columns of the un-scaled matrix are the primaries' XYZ coordinates. + let xyz = |x: f64, y: f64| [x / y, 1.0, (1.0 - x - y) / y]; + let red = xyz( + chroma.red.x as f64, + chroma.red.y as f64 + ); + let green = xyz( + chroma.green.x as f64, + chroma.green.y as f64 + ); + let blue = xyz( + chroma.blue.x as f64, + chroma.blue.y as f64 + ); + let white = xyz( + chroma.white.x as f64, + chroma.white.y as f64 + ); + + // Solve for the per-primary scales that make RGB(1,1,1) hit the white + // point: S = M⁻¹ · W. + let unscaled: Mat3 = [ + [red[0], green[0], blue[0]], + [red[1], green[1], blue[1]], + [red[2], green[2], blue[2]], + ]; + let scales = mat3_mul_vec( + &invert( + &unscaled + ), + &white + ); + [ + [red[0] * scales[0], green[0] * scales[1], blue[0] * scales[2]], + [red[1] * scales[0], green[1] * scales[1], blue[1] * scales[2]], + [red[2] * scales[0], green[2] * scales[1], blue[2] * scales[2]], + ] +} + +fn invert( + m: &Mat3 +) -> Mat3 { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + let inv_det = 1.0 / det; + [ + [ + (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det, + (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det, + (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det, + ], + [ + (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det, + (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det, + (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det, + ], + [ + (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det, + (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det, + (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det, + ], + ] +} + +fn mat3_mul( + a: &Mat3, + b: &Mat3 +) -> Mat3 { + let mut out = [[0.0; 3]; 3]; + for ( + row_index, + row + ) in out.iter_mut().enumerate() { + for ( + col_index, + cell + ) in row.iter_mut().enumerate() { + *cell = (0..3) + .map(|k| a[row_index][k] * b[k][col_index]) + .sum::(); + } + } + out +} + +fn mat3_mul_vec( + m: &Mat3, + v: &[f64; 3] +) -> [f64; 3] { + [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ] +} + +/// Row-major linear-light conversion matrix between two named gamuts +/// (same D65 white point on both sides, so no chromatic adaptation step). +pub fn gamut_conversion_matrix( + from: ColorPrimaries, + to: ColorPrimaries +) -> Mat3 { + mat3_mul( + &invert( + &rgb_to_xyz( + to.chromaticities() + ) + ), + &rgb_to_xyz( + from.chromaticities() + ), + ) +} + +// SMPTE ST 2084 constants. +const PQ_M1: f64 = 1305.0 / 8192.0; +const PQ_M2: f64 = 2523.0 / 32.0; +const PQ_C1: f64 = 107.0 / 128.0; +const PQ_C2: f64 = 2413.0 / 128.0; +const PQ_C3: f64 = 2392.0 / 128.0; + +/// PQ inverse EOTF: absolute luminance (cd/m²) → PQ signal in [0, 1]. +pub fn pq_inverse_eotf( + nits: f64 +) -> f64 { + let y = (nits / 10000.0).clamp( + 0.0, + 1.0 + ); + let ym = y.powf( + PQ_M1 + ); + ((PQ_C1 + PQ_C2 * ym) / (1.0 + PQ_C3 * ym)).powf(PQ_M2) +} + +/// PQ EOTF: PQ signal in [0, 1] → absolute luminance (cd/m²). +pub fn pq_eotf( + signal: f64 +) -> f64 { + let e = signal.clamp( + 0.0, + 1.0 + ).powf(1.0 / PQ_M2); + let y = ((e - PQ_C1).max(0.0)) / (PQ_C2 - PQ_C3 * e); + 10000.0 * y.powf(1.0 / PQ_M1) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The matrix hardcoded in `backend/output_encode.frag` (row-major + /// here; the shader stores it column-major for GLSL). + const SHADER_BT709_TO_BT2020: Mat3 = [ + [0.627404, 0.329283, 0.043313], + [0.069097, 0.919540, 0.011362], + [0.016391, 0.088013, 0.895595], + ]; + + #[test] + fn derived_matrix_matches_shader_constants() { + let derived = gamut_conversion_matrix(ColorPrimaries::Srgb, ColorPrimaries::Bt2020); + for row in 0..3 { + for col in 0..3 { + let delta = (derived[row][col] - SHADER_BT709_TO_BT2020[row][col]) + .abs(); + assert!( + delta < 2e-4, + "matrix[{row}][{col}]: derived {} vs shader {}", + derived[row][col], + SHADER_BT709_TO_BT2020[row][col] + ); + } + } + } + + #[test] + fn white_maps_to_white() { + // Both gamuts share D65, so full white must stay full white. + let m = gamut_conversion_matrix( + ColorPrimaries::Srgb, + ColorPrimaries::Bt2020 + ); + let white = mat3_mul_vec( + &m, + &[ + 1.0, + 1.0, + 1.0 + ] + ); + for channel in white { + assert!((channel - 1.0) + .abs() < 1e-6, "white drifted: {white:?}"); + } + } + + #[test] + fn pq_reference_points() { + // Published reference values for the ST 2084 curve. + assert!((pq_inverse_eotf(0.0) - 0.0) + .abs() < 1e-6); + assert!((pq_inverse_eotf(10000.0) - 1.0) + .abs() < 1e-6); + // 100 cd/m² ≈ 0.508 PQ, 203 cd/m² (HDR reference white) ≈ 0.5806 PQ. + assert!((pq_inverse_eotf(100.0) - 0.5081) + .abs() < 1e-3); + assert!((pq_inverse_eotf(203.0) - 0.5806) + .abs() < 1e-3); + } + + #[test] + fn pq_roundtrip() { + for nits in [0.005, 1.0, 80.0, 203.0, 1000.0, 4000.0, 10000.0] { + let roundtrip = pq_eotf( + pq_inverse_eotf( + nits + ) + ); + assert!( + (roundtrip - nits) + .abs() / nits < 1e-6, + "PQ roundtrip drifted: {nits} -> {roundtrip}" + ); + } + } +} From c871ec3671fcc005ecb051b97578f37230007109 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 00:36:49 +0100 Subject: [PATCH 16/60] add colorimetry module --- src/shojiwm/src/color/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs index bcb3a158..ae46c52d 100644 --- a/src/shojiwm/src/color/mod.rs +++ b/src/shojiwm/src/color/mod.rs @@ -3,6 +3,7 @@ //! protocol (`protocols/color_management.rs`), the DRM signaling layer //! (`drm_metadata`), and — in a later phase — the render pipeline. +pub mod colorimetry; pub mod drm_metadata; pub mod primaries; From fc841c1f4e65f48fa260a4d221a501806e04067b Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 00:42:32 +0100 Subject: [PATCH 17/60] add pq encode shader --- src/shojiwm/src/backend/output_encode.frag | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/shojiwm/src/backend/output_encode.frag diff --git a/src/shojiwm/src/backend/output_encode.frag b/src/shojiwm/src/backend/output_encode.frag new file mode 100644 index 00000000..69cfc41e --- /dev/null +++ b/src/shojiwm/src/backend/output_encode.frag @@ -0,0 +1,66 @@ +#version 100 + +//_DEFINES_ + +#if defined(EXTERNAL) +#extension GL_OES_EGL_image_external : require +#endif + +precision highp float; +#if defined(EXTERNAL) +uniform samplerExternalOES tex; +#else +uniform sampler2D tex; +#endif + +uniform float alpha; +// Absolute luminance (cd/m2) that sRGB full white maps to on the PQ signal. +uniform float sdr_nits; +varying vec2 v_coords; + +#if defined(DEBUG_FLAGS) +uniform float tint; +#endif + +// sRGB EOTF (IEC 61966-2-1 piecewise decode). +vec3 srgb_eotf(vec3 c) { + vec3 lo = c / 12.92; + vec3 hi = pow((c + vec3(0.055)) / 1.055, vec3(2.4)); + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.04045)))); +} + +// BT.709 -> BT.2020 linear-light gamut matrix (BT.2087), column-major. +// Cross-checked against the CPU derivation in color/colorimetry.rs tests. +const mat3 BT709_TO_BT2020 = mat3( + 0.627404, 0.069097, 0.016391, + 0.329283, 0.919540, 0.088013, + 0.043313, 0.011362, 0.895595 +); + +// SMPTE ST 2084 (PQ) inverse EOTF: absolute luminance -> PQ signal. +vec3 pq_inv_eotf(vec3 nits) { + const float m1 = 0.1593017578125; // 1305/8192 + const float m2 = 78.84375; // 2523/32 + const float c1 = 0.8359375; // 107/128 + const float c2 = 18.8515625; // 2413/128 + const float c3 = 18.6875; // 2392/128 + vec3 y = clamp(nits / 10000.0, 0.0, 1.0); + vec3 ym = pow(y, vec3(m1)); + return pow((vec3(c1) + c2 * ym) / (vec3(1.0) + c3 * ym), vec3(m2)); +} + +void main() { + // The intermediate holds the finished composite as sRGB-encoded values. + vec4 color = texture2D(tex, v_coords); + vec3 linear = srgb_eotf(clamp(color.rgb, 0.0, 1.0)); + vec3 bt2020 = BT709_TO_BT2020 * linear; + vec3 pq = pq_inv_eotf(bt2020 * sdr_nits); + vec4 result = vec4(pq, 1.0) * alpha; + +#if defined(DEBUG_FLAGS) + if (tint == 1.0) + result = vec4(0.0, 0.2, 0.0, 0.2) + result * 0.8; +#endif + + gl_FragColor = result; +} From 889464099946cfb4dcc8c87c8a4d45092150c6ee Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 00:56:50 +0100 Subject: [PATCH 18/60] create hdr pipeline module --- src/shojiwm/src/backend/hdr_pipeline.rs | 409 ++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/shojiwm/src/backend/hdr_pipeline.rs diff --git a/src/shojiwm/src/backend/hdr_pipeline.rs b/src/shojiwm/src/backend/hdr_pipeline.rs new file mode 100644 index 00000000..6d25f2b2 --- /dev/null +++ b/src/shojiwm/src/backend/hdr_pipeline.rs @@ -0,0 +1,409 @@ +//! Two-stage HDR10 output pipeline. +//! +//! Stage 1 composites the output's full element list (windows, decorations, +//! effects, cursor, overlays) into a persistent fp16 offscreen texture with +//! its own damage tracker — the same pattern `render_output_capture_mirror` +//! uses for screencopy, so damage semantics are identical. +//! +//! Stage 2 hands the DRM pass a single [`HdrEncodeElement`] that draws the +//! intermediate through `output_encode.frag`: sRGB EOTF decode → +//! BT.709→BT.2020 gamut matrix → scale to `sdr_nits` absolute luminance → +//! ST 2084 (PQ) encode, straight into the 10-bit scanout buffer. +//! +//! Compositing itself still happens on sRGB-encoded values: per-element +//! linearization needs sRGB texture views across every draw program and is +//! deliberately out of scope here. The fp16 intermediate exists so PQ-tagged +//! client content (which exceeds the SDR range once decoded) has headroom +//! when that lands. + +use smithay::{ + backend::renderer::{ + Bind, Color32F, Offscreen, + damage::OutputDamageTracker, + element::{Element, Id, Kind, RenderElement}, + gles::{ + GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, UniformName, + UniformType, + }, + utils::{CommitCounter, OpaqueRegions}, + }, + output::Output, + reexports::drm::control::ModeTypeFlags, + utils::{Buffer, Physical, Rectangle, Scale, Size, Transform, user_data::UserDataMap}, +}; +use tracing::{info, warn}; + +use smithay::backend::allocator::Fourcc; + +/// Probe whether the GL context can render into fp16 (RGBA16F) targets. +/// Smithay only gates the texture allocation on GLES 3.0; actual +/// renderability additionally needs GL_EXT_color_buffer_half_float, which +/// surfaces as a framebuffer-completeness failure on bind. +pub fn probe_fp16_render_support( + renderer: &mut GlesRenderer +) -> bool { + let size = Size::::from( + ( + 16, + 16, + ) + ); + match Offscreen::::create_buffer( + renderer, + Fourcc::Abgr16161616f, + size + ) { + Ok( + mut texture + ) => match renderer + .bind( + &mut texture + ) { + Ok(_) => true, + Err( + error + ) => { + info!( + ?error, + "fp16 render targets unsupported (bind failed)" + ); + false + } + }, + Err( + error + ) => { + info!( + ?error, + "fp16 render targets unsupported (alloc failed)" + ); + false + } + } +} + +/// Luminance that sRGB full white maps to on the PQ signal (cd/m²). +/// ITU-R BT.2408 reference white by default; `SHOJI_SDR_NITS` overrides +/// for taste/testing. +fn sdr_reference_nits() -> f32 { + static NITS: std::sync::OnceLock = std::sync::OnceLock::new(); + *NITS.get_or_init(|| { + std::env::var( + "SHOJI_SDR_NITS" + ) + .ok() + .and_then(|value| value + .trim() + .parse::() + .ok()) + .filter(|nits| (10.0..=1000.0) + .contains( + nits + )) + .unwrap_or( + 203.0 + ) + }) +} + +struct HdrEncodeProgram( + GlesTexProgram +); + +fn ensure_encode_program( + renderer: &mut GlesRenderer +) -> Result { + if renderer + .egl_context() + .user_data() + .get::() + .is_none() + { + let program = renderer.compile_custom_texture_shader( + include_str!( + "output_encode.frag" + ), + &[ + UniformName::new( + "sdr_nits", + UniformType::_1f + ) + ], + )?; + renderer + .egl_context() + .user_data() + .insert_if_missing( + || HdrEncodeProgram( + program + ) + ); + } + Ok(renderer + .egl_context() + .user_data() + .get::() + .unwrap() + .0 + .clone()) +} + +/// Per-output HDR pipeline state, keyed by output name in +/// `ShojiWM::hdr_pipelines` (mirroring `output_capture_mirrors`). +pub struct HdrPipeline { + texture: GlesTexture, + damage_tracker: OutputDamageTracker, + size: Size, + scale: Scale, + transform: Transform, + /// Stable element id so the DRM damage tracker sees one persistent + /// element instead of a brand-new fullscreen quad every frame. + element_id: Id, + commit_counter: CommitCounter, + /// The texture holds last frame's composite (buffer age 1) once we've + /// rendered at least once without errors. + contents_valid: bool, +} + +// ModeTypeFlags is only imported to keep rustc from flagging the drm +// reexport when the probe is compiled out in future cfg work; silence it. +#[allow(dead_code)] +fn _keep_mode_type_flags( + _flags: ModeTypeFlags +) {} + +/// Composite `elements` into the fp16 intermediate and return the single +/// PQ-encode element the DRM pass should render instead of the raw list. +/// Returns `Ok(None)` if the output has no mode yet. +pub fn render_hdr_pipeline( + renderer: &mut GlesRenderer, + pipeline: &mut Option, + output: &Output, + elements: &[E], + clear_color: [f32; 4], +) -> Result, Box> +where + E: RenderElement, +{ + let Some( + mode + ) = output + .current_mode() else { + return Ok( + None + ); + }; + let size = mode.size; + let scale: Scale = output + .current_scale() + .fractional_scale() + .into(); + let transform = output + .current_transform(); + + let recreate = pipeline + .as_ref() + .is_none_or(|pipeline| { + pipeline.size != size || pipeline.scale != scale || pipeline.transform != transform + }); + if recreate { + let buffer_size = size + .to_logical(1) + .to_buffer( + 1, + Transform::Normal + ); + let texture = + Offscreen::::create_buffer( + renderer, + Fourcc::Abgr16161616f, + buffer_size + )?; + *pipeline = Some(HdrPipeline { + texture, + damage_tracker: OutputDamageTracker::new( + size, + scale, + transform + ), + size, + scale, + transform, + element_id: Id::new(), + commit_counter: CommitCounter::default(), + contents_valid: false, + }); + } + let pipeline = pipeline + .as_mut() + .expect( + "pipeline was just created" + ); + + let program = ensure_encode_program( + renderer + )?; + + // Stage 1: composite into the fp16 intermediate. Age 1 keeps partial + // redraws once the texture holds the previous frame. + let age = if pipeline.contents_valid { 1 } else { 0 }; + let render_result = { + let mut target = renderer.bind( + &mut pipeline.texture + )?; + pipeline.damage_tracker + .render_output( + renderer, + &mut target, + age, + elements, + Color32F::new( + clear_color[0], + clear_color[1], + clear_color[2], + clear_color[3], + ), + ) + }; + let damaged = match render_result { + Ok( + result + ) => result.damage + .is_some(), + Err( + error + ) => { + pipeline.contents_valid = false; + return Err( + Box::new( + error + ) + ); + } + }; + pipeline.contents_valid = true; + if damaged { + pipeline.commit_counter + .increment(); + } + + let buffer_size = size + .to_logical(1) + .to_buffer( + 1, + Transform::Normal + ); + Ok(Some(HdrEncodeElement { + id: pipeline.element_id + .clone(), + commit: pipeline.commit_counter, + texture: pipeline.texture + .clone(), + program, + src: Rectangle::from_size( + ( + buffer_size.w as f64, + buffer_size.h as f64 + ).into() + ), + geometry: Rectangle::from_size( + size + ), + sdr_nits: sdr_reference_nits(), + })) +} + +/// Fullscreen quad that draws the fp16 intermediate through the PQ encode +/// shader. Reports itself opaque so the DRM compositor skips the clear. +pub struct HdrEncodeElement { + id: Id, + commit: CommitCounter, + texture: GlesTexture, + program: GlesTexProgram, + src: Rectangle, + geometry: Rectangle, + sdr_nits: f32, +} + +impl Element for HdrEncodeElement { + fn id( + &self + ) -> &Id { + &self.id + } + + fn current_commit( + &self + ) -> CommitCounter { + self.commit + } + + fn src( + &self + ) -> Rectangle { + self.src + } + + fn geometry( + &self, + _scale: Scale + ) -> Rectangle { + self.geometry + } + + fn opaque_regions( + &self, + _scale: Scale + ) -> OpaqueRegions { + OpaqueRegions::from_slice( + &[Rectangle::from_size( + self.geometry.size + )] + ) + } + + fn alpha( + &self + ) -> f32 { + 1.0 + } + + fn kind( + &self + ) -> Kind { + Kind::Unspecified + } +} + +impl RenderElement for HdrEncodeElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + _cache: Option<&UserDataMap>, + ) -> Result<(), GlesError> { + let result = frame.render_texture_from_to( + &self.texture, + src, + dst, + damage, + opaque_regions, + Transform::Normal, + 1.0, + Some(&self.program), + &[Uniform::new("sdr_nits", self.sdr_nits)], + ); + if let Err( + error + ) = &result { + warn!( + ?error, + "HDR encode pass draw failed" + ); + } + result + } +} From 7153e849a96a7795d8396e761f35da066bbf44b1 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:02:36 +0100 Subject: [PATCH 19/60] Remove unused `ModeTypeFlags` import and workaround The `ModeTypeFlags` import and its associated `_keep_mode_type_flags` function were a temporary workaround to silence dead-code warnings. They are no longer necessary. --- src/shojiwm/src/backend/hdr_pipeline.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/shojiwm/src/backend/hdr_pipeline.rs b/src/shojiwm/src/backend/hdr_pipeline.rs index 6d25f2b2..66373297 100644 --- a/src/shojiwm/src/backend/hdr_pipeline.rs +++ b/src/shojiwm/src/backend/hdr_pipeline.rs @@ -28,7 +28,6 @@ use smithay::{ utils::{CommitCounter, OpaqueRegions}, }, output::Output, - reexports::drm::control::ModeTypeFlags, utils::{Buffer, Physical, Rectangle, Scale, Size, Transform, user_data::UserDataMap}, }; use tracing::{info, warn}; @@ -165,13 +164,6 @@ pub struct HdrPipeline { contents_valid: bool, } -// ModeTypeFlags is only imported to keep rustc from flagging the drm -// reexport when the probe is compiled out in future cfg work; silence it. -#[allow(dead_code)] -fn _keep_mode_type_flags( - _flags: ModeTypeFlags -) {} - /// Composite `elements` into the fp16 intermediate and return the single /// PQ-encode element the DRM pass should render instead of the raw list. /// Returns `Ok(None)` if the output has no mode yet. From a726366330bde991a66c2f641e5af6fc80bf9ce8 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:03:44 +0100 Subject: [PATCH 20/60] Add HDR pipeline module to backend --- src/shojiwm/src/backend/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/backend/mod.rs b/src/shojiwm/src/backend/mod.rs index 882d33c0..f6306403 100644 --- a/src/shojiwm/src/backend/mod.rs +++ b/src/shojiwm/src/backend/mod.rs @@ -5,6 +5,7 @@ pub mod damage; pub mod damage_blink; pub mod decoration; pub mod fps_counter; +pub mod hdr_pipeline; pub mod icon; pub mod image_copy_capture_render; pub mod rounded; From 6c7ceb972ecbce7f3c8727f625f7b6b960f97b4b Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:04:45 +0100 Subject: [PATCH 21/60] Add HdrEncode render element to TTY backend --- src/shojiwm/src/backend/tty.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 09ae1c1e..e9061dbb 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1476,6 +1476,7 @@ render_elements! { RelocatedBackdrop=RelocateRenderElement, TransformedBackdrop=RelocateRenderElement>>, Cursor=PointerRenderElement, + HdrEncode=crate::backend::hdr_pipeline::HdrEncodeElement, } fn tty_render_element_name(element: &TtyRenderElements) -> &'static str { From d4e0e9fb6454d05aff93261c487d299bdaa1a049 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:21:12 +0100 Subject: [PATCH 22/60] Add HdrEncode render element name to TTY backend --- src/shojiwm/src/backend/tty.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index e9061dbb..76f3557e 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1500,6 +1500,7 @@ fn tty_render_element_name(element: &TtyRenderElements) -> &'static str { TtyRenderElements::RelocatedBackdrop(_) => "RelocatedBackdrop", TtyRenderElements::TransformedBackdrop(_) => "TransformedBackdrop", TtyRenderElements::Cursor(_) => "Cursor", + TtyRenderElements::HdrEncode(_) => "HdrEncode", _ => "Generic", } } From aa0873355ceb0edb24127b17334324fd1a57dd2b Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:23:20 +0100 Subject: [PATCH 23/60] Add GPU FP16 capability flag to TTY backend This flag is probed once per device to indicate if the GPU can render into fp16 targets. It gates HDR10 output modes, which require fp16 intermediates. --- src/shojiwm/src/backend/tty.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 76f3557e..16bda8c1 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -882,6 +882,9 @@ pub struct BackendData { DrmDeviceFd, >, pub renderer: GlesRenderer, + /// This GPU can render into fp16 targets (probed once at device_added); + /// gates HDR10 output modes, which need the fp16 intermediate. + pub supports_fp16: bool, surfaces: HashMap, /// Per-CRTC (window start, count) of recent surface rebuilds after failed /// atomic commit tests. Bounds the recovery path: a configuration the From 5bb2652c54251bd86d71b1ec50ef4b47ac316e4a Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:28:01 +0100 Subject: [PATCH 24/60] Probe and store GPU FP16 render support in TTY backend This adds the logic to detect if the GPU supports rendering into FP16 targets and stores this capability in the backend data. This information is essential for enabling HDR10 output modes. --- src/shojiwm/src/backend/tty.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 16bda8c1..41d9cbf8 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1013,10 +1013,20 @@ pub fn device_added( render_formats, ); + let supports_fp16 = crate::backend::hdr_pipeline::probe_fp16_render_support( + &mut renderer + ); + info!( + ?node, + supports_fp16, + "probed fp16 render target support" + ); + let backend = BackendData { drm_scanner: DrmScanner::new(), drm_output_manager, renderer, + supports_fp16, surfaces: HashMap::new(), surface_reset_attempts: HashMap::new(), }; From 19d545f75dcc7ad52cf1f8c0096ba3e1166b4ba9 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:37:12 +0100 Subject: [PATCH 25/60] Render HDR10 output and gate on FP16 support This applies the HDR10 encode pipeline to composite the entire frame for outputs configured in HDR10. All elements are composited into an FP16 intermediate buffer before PQ encoding. It also ensures that HDR10 mode is only enabled if the GPU supports FP16 render targets, which are required by the pipeline. --- src/shojiwm/src/backend/tty.rs | 91 ++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 41d9cbf8..216957f9 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -5362,6 +5362,77 @@ fn render_surface( elements.extend(content_for_capture); } + // HDR10 outputs composite the full element list (cursor and overlays + // included — anything drawn outside the encode pass would end up + // sRGB-encoded inside a PQ signal) into the fp16 intermediate, and + // the DRM pass renders a single PQ-encode element instead. + let mut hdr_encode_active = false; + if matches!( + state + .output_color + .get( + output + .name() + .as_str() + ) + .map( + |color| color.mode + ), + Some(crate::color::OutputColorMode::Hdr10 { .. }) + ) { + let output_name = output + .name(); + let mut pipeline = state.hdr_pipelines + .remove( + &output_name + ); + match crate::backend::hdr_pipeline::render_hdr_pipeline( + &mut backend.renderer, + &mut pipeline, + &output, + &elements, + CLEAR_COLOR, + ) { + Ok( + Some( + encode_element + ) + ) => { + elements = vec![TtyRenderElements::HdrEncode(encode_element)]; + hdr_encode_active = true; + } + Ok( + None + ) => {} + Err( + err + ) => { + // Fall through with the raw element list: the frame shows + // washed-out colors on the PQ signal but stays visible. + warn!( + output = %output_name, + ?err, + "HDR encode pipeline failed; rendering unencoded frame" + ); + } + } + if let Some( + pipeline + ) = pipeline { + state.hdr_pipelines + .insert( + output_name, + pipeline, + ); + } + } else { + state.hdr_pipelines + .remove( + output.name() + .as_str() + ); + } + let fullscreen_scanout_candidate = if fullscreen_overlay_visible { None } else { @@ -11690,10 +11761,22 @@ fn connector_connected( device, &connector ); - let color_mode = crate::color::resolve_output_mode( - &output_name, - edid_hdr.as_ref() - ); + let color_mode = if backend.supports_fp16 { + crate::color::resolve_output_mode( + &output_name, + edid_hdr.as_ref() + ) + } else { + // The PQ encode pass composites through an fp16 intermediate; + // without renderable fp16 targets HDR10 cannot be driven. + if crate::color::hdr_experiment_enabled() { + warn!( + output = %output_name, + "HDR requested but GPU lacks fp16 render targets; staying SDR" + ); + } + crate::color::OutputColorMode::Sdr + }; let hdr_metadata_blob = match color_mode { crate::color::OutputColorMode::Hdr10 { .. } => { match crate::color::drm_metadata::apply_hdr_connector_state( From d3b1aa6d7a40d2b01aa7fb597d55405ec0a2c382 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:40:19 +0100 Subject: [PATCH 26/60] Prevent HDR encode pipeline bypasses When HDR encoding is active, frame optimizations such as direct scanout or plane promotion would bypass the HDR encode pass. This could cause sRGB content to be misinterpreted as PQ (Perceptual Quantizer) by an HDR display, leading to incorrect colors. --- src/shojiwm/src/backend/tty.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 216957f9..4db4b979 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -5506,6 +5506,11 @@ fn render_surface( if should_tear { frame_flags = frame_flags.difference(FrameFlags::ALLOW_CURSOR_PLANE_SCANOUT); } + if hdr_encode_active { + // Nothing may bypass the encode pass: direct scanout or plane + // promotion would put sRGB pixels straight into the PQ signal. + frame_flags = FrameFlags::empty(); + } // Keep every real damage frame asynchronous for the whole tearing period. In // particular, a visible software-cursor update must not fall back to a synced flip: // alternating async game frames with vblank-bound cursor frames produces visibly uneven From f6defdd790c6e697706db95c4524502ade493439 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:42:07 +0100 Subject: [PATCH 27/60] Store per-output HDR10 composite and PQ encode pipelines --- src/shojiwm/src/state.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index 1f24e585..c30d98e9 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -341,6 +341,8 @@ pub struct ShojiWM { pub runtime_output_globals: HashMap, /// Per-output color mode/signal state, keyed by output name (tty only). pub output_color: HashMap, + /// Per-output fp16 composite + PQ encode state for HDR10 outputs. + pub hdr_pipelines: HashMap, pub managed_window_animations: HashMap>, pub managed_window_animation_sequence: u64, pub runtime_output_configs: std::collections::BTreeMap, From 47267a7f8ceb025a1c5bf24593cef6468cf845b2 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:44:06 +0100 Subject: [PATCH 28/60] Add HDR pipeline storage to main ShojiWM state --- src/shojiwm/src/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index c30d98e9..09362902 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -1301,6 +1301,7 @@ impl ShojiWM { runtime_animation_outputs: Default::default(), runtime_output_globals: Default::default(), output_color: Default::default(), + hdr_pipelines: Default::default(), managed_window_animations: Default::default(), managed_window_animation_sequence: 0, runtime_output_configs: Default::default(), From 1e526151128c41acc3f6f9122457a803fa0d6032 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 01:49:22 +0100 Subject: [PATCH 29/60] add the last few touches --- src/shojiwm/src/backend/tty.rs | 1 + src/shojiwm/src/state.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 4db4b979..2a1f135b 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11976,6 +11976,7 @@ fn connector_disconnected( // instance. Keep it across hot-unplug so a reconnected connector receives its mode, scale, // and position immediately. The TS runtime suppresses unchanged configuration payloads, so // deleting the Rust-side entry here would otherwise leave the new Output at scale 1. + state.hdr_pipelines.remove(&output_name); state.runtime_animation_outputs.remove(&output_name); state.damage_blink_visible.remove(&output_name); state.damage_blink_pending.remove(&output_name); diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index 09362902..8864af1a 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2456,6 +2456,7 @@ impl ShojiWM { smithay::wayland::image_copy_capture::CaptureFailureReason::Unknown, ); self.output_capture_mirrors.remove(&name); + self.hdr_pipelines.remove(&name); self.runtime_animation_outputs.remove(&name); self.layer_effect_evaluation_cache.remove(&name); self.popup_effect_evaluation_cache.remove(&name); From 72e817085abdb6a89b1ad8901c442ba78dede7a4 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 02:13:31 +0100 Subject: [PATCH 30/60] update comment --- src/shojiwm/src/color/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs index ae46c52d..cab1622d 100644 --- a/src/shojiwm/src/color/mod.rs +++ b/src/shojiwm/src/color/mod.rs @@ -172,9 +172,11 @@ impl OutputColorState { max_fall: None, }, }; - // Blending stays sRGB until the fp16 linear pipeline (phase 3) - // lands: an HDR-signaled output therefore shows incorrect - // (washed-out) colors and is only useful for hardware bring-up. + // Blending still happens on sRGB-encoded values; the HDR path + // (backend/hdr_pipeline.rs) composites into an fp16 intermediate + // and PQ-encodes as a final pass, so HDR outputs display correctly. + // BlendSpace::LinearBt2020 activates once per-element + // linearization lands. Self { mode, blend_space: BlendSpace::Srgb, From cecc2a5b7d8b8488fb6afd2d3dd42f417b26fe8d Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 20:16:13 +0100 Subject: [PATCH 31/60] Defer image description info delivery to prevent write-after-free --- src/shojiwm/src/protocols/color_management.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index 6c7460a1..6e368d4c 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -91,6 +91,20 @@ pub trait ColorManagementHandler { fn output_image_description(&mut self, output: &WlOutput) -> ImageDescription; /// The description the compositor prefers for this surface's content. fn surface_preferred_description(&mut self, surface: &WlSurface) -> ImageDescription; + /// Deliver [`send_information`] for `info` *after* the current dispatch + /// completes (e.g. from a calloop idle callback). + /// + /// This MUST NOT send synchronously: `done` is a destructor event, and + /// wayland-backend (up to at least 0.3.15) stores a newly created + /// object's data through a raw pointer *after* the request handler + /// returns, without checking that the object is still alive. Destroying + /// the info object inside the dispatch that created it is therefore a + /// write-after-free that corrupts the heap. + fn defer_image_description_info( + &mut self, + info: WpImageDescriptionInfoV1, + description: ImageDescription, + ); } /// Aggregate dispatch bound so each impl below doesn't repeat nine clauses. From db9ee51a76abc7243512034ba6186a57ad6675da Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 20:22:36 +0100 Subject: [PATCH 32/60] make send_information public some minor reformatting too --- src/shojiwm/src/protocols/color_management.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index 6e368d4c..0f841639 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -295,7 +295,13 @@ fn protocol_tf(tf: TransferCharacteristics) -> TransferFunction { } } -fn send_information(info: &WpImageDescriptionInfoV1, description: &ImageDescription) { +/// Send the full information event burst, ending with the `done` destructor +/// event. Only call this *outside* the dispatch that created `info` — see +/// [`ColorManagementHandler::defer_image_description_info`]. +pub fn send_information( + info: &WpImageDescriptionInfoV1, + description: &ImageDescription +) { let chroma = description.primaries.chromaticities(); let (r_x, r_y) = chroma.red.to_protocol(); let (g_x, g_y) = chroma.green.to_protocol(); From 2488d6977fc0aa0240ea01335a0ef48800aeaf14 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 20:27:44 +0100 Subject: [PATCH 33/60] Defer `GetInformation` response to prevent write-after-free The`wp_image_description_v1::Request::GetInformation` response must be deferred. Sending the response from inside the dispatch handler that created the `info` object would cause wayland-backend to write through a freed pointer, as the `done` destructor event may already have occurred. --- src/shojiwm/src/protocols/color_management.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index 0f841639..b07a6c0a 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -843,10 +843,17 @@ where wp_image_description_v1::Request::GetInformation { information } => { let info = data_init.init(information, ()); match &data.description { - Some(description) => send_information( - &info, - description - ), + // The info burst ends with the `done` destructor event; + // it must not be sent from inside this dispatch (which + // created `info`) or wayland-backend writes the new + // object's data through a freed pointer afterwards. + Some(description) => { + state + .defer_image_description_info( + info, + *description, + ); + } None => { description_obj.post_error( wp_image_description_v1::Error::NoInformation, From c7df46c96af0df9eacb4b3953eb03eda977fd5c6 Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 20:32:26 +0100 Subject: [PATCH 34/60] remove suppression --- src/shojiwm/src/protocols/color_management.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index b07a6c0a..5bcfb460 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -831,7 +831,7 @@ where D: ColorManagementDispatch, { fn request( - _state: &mut D, + state: &mut D, _client: &Client, description_obj: &WpImageDescriptionV1, request: wp_image_description_v1::Request, From 3f046b45b3293bd401dd33f022e215d0175f2ffc Mon Sep 17 00:00:00 2001 From: Seirra Date: Mon, 6 Jul 2026 20:44:08 +0100 Subject: [PATCH 35/60] Defer WpImageDescriptionInfoV1 response to prevent write-after-free The `wp_image_description_info_v1` response must be deferred. Sending the response from inside the dispatch handler that created the `info` object would cause wayland-backend to write through a freed pointer, as the `done` destructor event may already have occurred --- src/shojiwm/src/handlers/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/shojiwm/src/handlers/mod.rs b/src/shojiwm/src/handlers/mod.rs index 077d4f33..387a7aa7 100644 --- a/src/shojiwm/src/handlers/mod.rs +++ b/src/shojiwm/src/handlers/mod.rs @@ -897,6 +897,24 @@ impl crate::protocols::color_management::ColorManagementHandler for ShojiWM { // Revisit when the fp16 linear blend space (phase 3) lands. crate::color::ImageDescription::SRGB } + + fn defer_image_description_info( + &mut self, + info: smithay::reexports::wayland_protocols::wp::color_management::v1::server::wp_image_description_info_v1::WpImageDescriptionInfoV1, + description: crate::color::ImageDescription, + ) { + // Idle callbacks run after the current wayland dispatch completes, + // so the `done` destructor event cannot destroy the info object + // inside the dispatch that created it (wayland-backend would then + // write the object's data through a freed pointer; issue #1). If + // the client is gone by the time this runs, the sends are no-ops. + self.loop_handle.insert_idle(move |_state| { + crate::protocols::color_management::send_information( + &info, + &description, + ); + }); + } } crate::delegate_screencopy!(ShojiWM); From b12e12ecaa35aa00abe2b966cad1b5d76f4c4ac9 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 10:44:26 +0100 Subject: [PATCH 36/60] add hdr option to config --- src/shojiwm/src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shojiwm/src/config.rs b/src/shojiwm/src/config.rs index 5b355406..714dbcd9 100644 --- a/src/shojiwm/src/config.rs +++ b/src/shojiwm/src/config.rs @@ -35,6 +35,7 @@ pub struct RuntimeOutputConfig { pub resolution: Option, pub position: Option, pub scale: Option, + pub hdr: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] From 47efe933066f519581215bc7d4d0a1972dfd182f Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 10:57:17 +0100 Subject: [PATCH 37/60] Introduce dynamic HDR configuration state --- src/shojiwm/src/color/mod.rs | 44 +++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/shojiwm/src/color/mod.rs b/src/shojiwm/src/color/mod.rs index cab1622d..4e84eb67 100644 --- a/src/shojiwm/src/color/mod.rs +++ b/src/shojiwm/src/color/mod.rs @@ -187,15 +187,40 @@ impl OutputColorState { } } -/// Experimental gate: `SHOJI_HDR_OUTPUTS=DP-1,DP-2` (or `all`) opts -/// connectors into HDR10 signaling and widens the protocol advertisement -/// to PQ/BT.2020. Off by default because the render pipeline still -/// composites in sRGB. +/// True while the runtime display config opts any output into HDR +/// (`hdr: true`). Kept in an atomic because the protocol layer's +/// capability checks run per-request without access to compositor state. +static SESSION_HDR_CONFIGURED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Called whenever a runtime display config update lands, with "does any +/// output request HDR". Widens/narrows the protocol advertisement for +/// clients that bind afterwards. +pub fn set_session_hdr_configured(enabled: bool) { + SESSION_HDR_CONFIGURED + .store( + enabled, + std::sync::atomic::Ordering::Relaxed + ); +} + +/// HDR gate for the protocol advertisement (PQ/BT.2020 capabilities): +/// open when the runtime display config opts an output in, or via the +/// `SHOJI_HDR_OUTPUTS=DP-1,DP-2` (or `all`) env override. Off by default +/// because the render pipeline still composites in sRGB. pub fn hdr_experiment_enabled() -> bool { - std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| !value.trim().is_empty()) + SESSION_HDR_CONFIGURED + .load( + std::sync::atomic::Ordering::Relaxed + ) + || std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| !value.trim().is_empty()) } -fn hdr_output_requested(output_name: &str) -> bool { +/// Env-override opt-in for one output, independent of the runtime display +/// config (useful for `cargo run` sessions without a config). +pub fn hdr_output_requested_via_env( + output_name: &str +) -> bool { std::env::var("SHOJI_HDR_OUTPUTS").is_ok_and(|value| { value .split(',') @@ -205,12 +230,15 @@ fn hdr_output_requested(output_name: &str) -> bool { } /// Decide how to drive a connector: HDR10 only when the user opted the -/// output in *and* its EDID advertises ST 2084 support. +/// output in (runtime display config `hdr: true` or the env override, +/// resolved by the caller into `hdr_requested`) *and* its EDID advertises +/// ST 2084 support. pub fn resolve_output_mode( output_name: &str, + hdr_requested: bool, edid_hdr: Option<&EdidHdrMetadata>, ) -> OutputColorMode { - if !hdr_output_requested(output_name) { + if !hdr_requested { return OutputColorMode::Sdr; } let Some(edid) = edid_hdr else { From 90a1ca0a9d7530d482f3f374ed997eba555853ac Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 11:40:34 +0100 Subject: [PATCH 38/60] Drive HDR output color mode by explicit configuration request Previously, HDR output mode resolution relied on a generic experiment flag. This change introduces an explicit check against `runtime_output_configs` and environment variables for HDR requests, directly informing the color mode selection. This ensures user-defined HDR preferences are applied, with a re-resolution handled for config updates. --- src/shojiwm/src/backend/tty.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 2a1f135b..3e408c19 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11757,6 +11757,16 @@ fn connector_connected( // the connector properties (max bpc / Colorspace / HDR_OUTPUT_METADATA) // are already part of the connector state when initialize_output performs // the first atomic commit on this CRTC. + // Note: at startup and on hotplug the connector connects before the + // TypeScript config evaluates, so a config-side `hdr: true` usually + // isn't visible here yet — refresh_tty_output_color_modes re-resolves + // once the display config update lands. + let hdr_requested = state + .runtime_output_configs + .get(&output_name) + .and_then(|config| config.hdr) + .unwrap_or(false) + || crate::color::hdr_output_requested_via_env(&output_name); let color_state = { let backend = state.tty_backends .get(&node) @@ -11769,12 +11779,13 @@ fn connector_connected( let color_mode = if backend.supports_fp16 { crate::color::resolve_output_mode( &output_name, + hdr_requested, edid_hdr.as_ref() ) } else { // The PQ encode pass composites through an fp16 intermediate; // without renderable fp16 targets HDR10 cannot be driven. - if crate::color::hdr_experiment_enabled() { + if hdr_requested { warn!( output = %output_name, "HDR requested but GPU lacks fp16 render targets; staying SDR" From bc27bdf3d360b910f50a1de3120f6258017b3977 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 12:10:27 +0100 Subject: [PATCH 39/60] Implement dynamic color mode refresh for TTY outputs This introduces a mechanism to re-evaluate and apply requested color modes (e.g., HDR via runtime config or environment variables) to already connected TTY outputs. Outputs connect before the full runtime config is evaluated, so this allows a "live switch" into or out of HDR mode without requiring an output reconnect, by managing DRM connector state. --- src/shojiwm/src/backend/tty.rs | 156 +++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index 3e408c19..e0b59a69 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -12212,6 +12212,162 @@ pub fn tty_output_available_modes( None } +/// Re-resolve every connected output's color mode against the current +/// runtime display config (`hdr: true` opt-in). Outputs connect before the +/// TypeScript config evaluates — both at session startup and on hotplug — +/// so this is where a config-side HDR request actually takes effect. It is +/// a live switch: the connector properties persist across smithay's +/// commits (legacy SET_PROPERTY), and the render path re-reads +/// `output_color` every frame to engage or drop the PQ encode pass. +pub fn refresh_tty_output_color_modes( + state: &mut crate::state::ShojiWM +) { + let mut changed = false; + for backend in state.tty_backends + .values() { + let connectors = backend + .drm_scanner + .crtcs() + .map( + |(info, crtc)| ( + info + .clone(), + crtc, + ) + ).collect::>(); + for (connector, crtc) in connectors { + if !backend.surfaces.contains_key( + &crtc + ) { + continue; + } + let output_name = format!( + "{}-{}", + connector + .interface() + .as_str(), + connector + .interface_id(), + ); + let Some(current) = state.output_color + .get( + &output_name + ) + .copied() else { + continue; + }; + let hdr_requested = state + .runtime_output_configs + .get( + &output_name + ) + .and_then(|config| config.hdr) + .unwrap_or(false) + || crate::color::hdr_output_requested_via_env(&output_name); + let desired_mode = if backend.supports_fp16 { + crate::color::resolve_output_mode( + &output_name, + hdr_requested, + current.edid_hdr + .as_ref(), + ) + } else { + crate::color::OutputColorMode::Sdr + }; + if desired_mode == current.mode { + continue; + } + + let device = backend.drm_output_manager.device(); + if let Some(blob) = current.hdr_metadata_blob { + crate::color::drm_metadata::destroy_metadata_blob( + device, + blob, + ); + } + let ( + mode, + hdr_metadata_blob + ) = match desired_mode { + crate::color::OutputColorMode::Hdr10 { .. } => { + match crate::color::drm_metadata::apply_hdr_connector_state( + device, + &connector, + &desired_mode, + ) { + Ok(blob) => ( + desired_mode, + blob, + ), + Err(error) => { + warn!( + output = %output_name, + ?error, + "failed to apply HDR connector state; staying SDR" + ); + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector, + ); + ( + crate::color::OutputColorMode::Sdr, + None, + ) + } + } + } + crate::color::OutputColorMode::Sdr => { + crate::color::drm_metadata::reset_hdr_connector_state( + device, + &connector, + ); + ( + crate::color::OutputColorMode::Sdr, + None, + ) + } + }; + if mode == current.mode { + continue; + } + info!( + output = %output_name, + ?mode, + "output color mode changed by runtime display config" + ); + state.output_color + .insert( + output_name, + crate::color::OutputColorState::new( + mode, + current.edid_hdr, + hdr_metadata_blob + ), + ); + changed = true; + } + } + if changed { + // Force a full repaint on every output so the first frame after the + // switch is (de)PQ-encoded; the render path picks the pipeline from + // `output_color` per frame. + for output in state.space.outputs() { + if let Some(geometry) = state.space.output_geometry(output) { + state.pending_decoration_damage + .push( + LogicalRect::new( + geometry.loc.x, + geometry.loc.y, + geometry.size.w, + geometry.size.h, + ) + ); + } + } + state.schedule_redraw(); + } +} + pub fn tty_connected_outputs(state: &crate::state::ShojiWM) -> Vec { let mut outputs = Vec::new(); for backend in state.tty_backends.values() { From faed9f5a75cb1100d6af5f13b0ee53b5bcabbad3 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 17:04:48 +0100 Subject: [PATCH 40/60] Qualify `LogicalRect` with `crate::ssd` --- src/shojiwm/src/backend/tty.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shojiwm/src/backend/tty.rs b/src/shojiwm/src/backend/tty.rs index e0b59a69..528c3d73 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -12355,7 +12355,7 @@ pub fn refresh_tty_output_color_modes( if let Some(geometry) = state.space.output_geometry(output) { state.pending_decoration_damage .push( - LogicalRect::new( + crate::ssd::LogicalRect::new( geometry.loc.x, geometry.loc.y, geometry.size.w, From 26335706c83dc5248a71bf900b36ca169d1a9606 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 17:11:36 +0100 Subject: [PATCH 41/60] Configure session HDR and TTY color modes The HDR opt-in is determined by output configurations. This ensures the session-wide HDR advertisement and TTY output color modes are updated when output configs change, addressing timing issues where outputs connect before full config evaluation. --- src/shojiwm/src/state.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index 8864af1a..ad625cb9 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2340,6 +2340,15 @@ impl ShojiWM { } } } + // The HDR opt-in lives in this config: widen/narrow the protocol + // advertisement and re-resolve connected outputs, which connect + // before the TypeScript config evaluates. + crate::color::set_session_hdr_configured( + self.runtime_output_configs + .values() + .any(|config| config.hdr == Some(true)), + ); + crate::backend::tty::refresh_tty_output_color_modes(self); self.apply_runtime_display_configuration(); self.notify_runtime_outputs_changed(); } From 830ad743e985388f4aae577618a3bc7c4e6812d4 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 17:35:41 +0100 Subject: [PATCH 42/60] Introduce HDR configuration for outputs This option allows configuring whether a specific output should attempt to drive HDR10 (PQ/BT.2020 signaling) when its EDID advertises ST 2084 support. It is an experimental feature, and SDR content is composited in sRGB and PQ-encoded as a final pass. --- packages/shoji_wm/src/output.ts | 1 + packages/shoji_wm/src/types.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/shoji_wm/src/output.ts b/packages/shoji_wm/src/output.ts index 989531b1..d852cbfc 100644 --- a/packages/shoji_wm/src/output.ts +++ b/packages/shoji_wm/src/output.ts @@ -101,6 +101,7 @@ function cloneOutputConfigEntry(config: OutputConfigEntry): OutputConfigEntry { ? { ...config.position } : undefined, scale: config.scale, + hdr: config.hdr, }; } diff --git a/packages/shoji_wm/src/types.ts b/packages/shoji_wm/src/types.ts index 7962cc35..b468332d 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -979,6 +979,14 @@ export interface OutputExtendConfigEntry { resolution?: OutputResolutionPreference; position?: OutputPositionPreference; scale?: number; + /** + * Drive this output as HDR10 (PQ/BT.2020 signaling) when its EDID + * advertises ST 2084 support; ignored otherwise. Experimental: SDR + * content is composited in sRGB and PQ-encoded as a final pass. + * この出力の EDID が ST 2084 対応を示す場合、HDR10(PQ/BT.2020)で + * 駆動します。実験的機能です。 + */ + hdr?: boolean; } export interface OutputDisabledConfigEntry { From 29bd81ed4a33f0d813d83c2d5e89a0f737231a17 Mon Sep 17 00:00:00 2001 From: Seirra Date: Tue, 7 Jul 2026 17:50:11 +0100 Subject: [PATCH 43/60] Add test for HDR flag parsing in runtime output config Verify the correct deserialization of the `hdr` option in `RuntimeDisplayConfigUpdate`, ensuring `Some(true)` for an explicit `true` value and `None` when the key is absent to preserve SDR behavior for older configurations. --- src/shojiwm/src/config.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/shojiwm/src/config.rs b/src/shojiwm/src/config.rs index 714dbcd9..db534d42 100644 --- a/src/shojiwm/src/config.rs +++ b/src/shojiwm/src/config.rs @@ -112,3 +112,33 @@ fn normalize_tty_output_name(name: &str) -> &str { } name } + +#[cfg(test)] +mod tests { + use super::*; + + /// The `hdr` opt-in arrives from the TypeScript display config; missing + /// means None so older configs keep their SDR behavior. + #[test] + fn runtime_output_config_parses_hdr_flag() { + let update: RuntimeDisplayConfigUpdate = serde_json::from_str( + r#"{"outputs":{ + "HDMI-A-3":{"mode":"extend","resolution":"best","hdr":true}, + "eDP-1":{"mode":"extend","resolution":"best"} + }}"#, + ) + .expect("display config update should parse"); + assert_eq!( + update + .outputs["HDMI-A-3"] + .as_ref() + .unwrap() + .hdr, + Some(true) + ); + assert_eq!( + update.outputs["eDP-1"].as_ref().unwrap().hdr, + None, + ); + } +} From 92aa07413579a2c4da0a7a4bafb2b86a27faa339 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 9 Jul 2026 07:29:34 +0100 Subject: [PATCH 44/60] add ability to check if hdr is supported should be useful for testing --- packages/shoji_wm/src/output.ts | 2 ++ packages/shoji_wm/src/types.ts | 2 ++ src/shojiwm/src/ssd/window_model.rs | 3 +++ src/shojiwm/src/state.rs | 6 ++++++ 4 files changed, 13 insertions(+) diff --git a/packages/shoji_wm/src/output.ts b/packages/shoji_wm/src/output.ts index d852cbfc..d8f98f82 100644 --- a/packages/shoji_wm/src/output.ts +++ b/packages/shoji_wm/src/output.ts @@ -36,6 +36,7 @@ function cloneOutputState( position: { ...snapshot.position }, scale: snapshot.scale, availableModes: snapshot.availableModes.map((mode) => ({ ...mode })), + hdrSupported: snapshot.hdrSupported, }, ]), ); @@ -65,6 +66,7 @@ function normalizeOutputState( position: { ...snapshot.position }, scale: snapshot.scale, availableModes: snapshot.availableModes.map((mode) => ({ ...mode })), + hdrSupported: snapshot.hdrSupported, }, ]), ); diff --git a/packages/shoji_wm/src/types.ts b/packages/shoji_wm/src/types.ts index b468332d..560c6e6a 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -1018,6 +1018,8 @@ export interface OutputStateSnapshot { }; scale: number; availableModes: OutputMode[]; + /** EDID advertises HDR (CTA-861 static metadata block). */ + hdrSupported?: boolean; } export interface OutputInfo extends OutputStateSnapshot { diff --git a/src/shojiwm/src/ssd/window_model.rs b/src/shojiwm/src/ssd/window_model.rs index 94c855a9..1511c51e 100644 --- a/src/shojiwm/src/ssd/window_model.rs +++ b/src/shojiwm/src/ssd/window_model.rs @@ -518,6 +518,9 @@ pub struct WaylandOutputSnapshot { pub position: OutputPositionSnapshot, pub scale: f64, pub available_modes: Vec, + /// EDID advertises HDR (CTA-861 static metadata). Can + /// be used to check display capabilities. + pub hdr_supported: bool, } #[derive(Debug, Clone, Copy, PartialEq, Serialize)] diff --git a/src/shojiwm/src/state.rs b/src/shojiwm/src/state.rs index ad625cb9..3636b82f 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2207,6 +2207,12 @@ impl ShojiWM { }, scale: output.current_scale().fractional_scale(), available_modes, + hdr_supported: self + .output_color + .get( + &name, + ) + .is_some_and(|color| color.edid_hdr.is_some()), }, ) }) From 86fbd7605b74c51b3be6aeaa2bfe5d84e8298f6f Mon Sep 17 00:00:00 2001 From: Seirra Date: Wed, 29 Jul 2026 15:32:41 +0100 Subject: [PATCH 45/60] implement scrgb support this is a pre requisite for gamescope hdr support --- src/shojiwm/src/protocols/color_management.rs | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index 5bcfb460..ea307d02 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -206,6 +206,10 @@ fn send_supported(manager: &WpColorManagerV1) { manager.supported_primaries_named(Primaries::Bt2020); manager.supported_tf_named(TransferFunction::St2084Pq); manager.supported_tf_named(TransferFunction::ExtLinear); + // Gamescope >= 3.16.4 requires this and checks the feature before + // using it; without it the client either errors out on + // `create_windows_scrgb` or silently drops to SDR. + manager.supported_feature(Feature::WindowsScrgb); } manager.done(); } @@ -244,6 +248,11 @@ pub struct FeedbackData { #[derive(Debug)] pub struct ImageDescriptionData { description: Option, + /// Whether `get_information` may be answered. Windows-scRGB descriptions + /// are usable but not introspectable: the protocol forbids the request on + /// them because the encoding's reference white and target colour volume + /// are undefined, not merely unknown to us. + allow_information: bool, } /// Accumulator for `wp_image_description_creator_params_v1`. @@ -266,6 +275,7 @@ fn init_ready_description( data_init: &mut DataInit<'_, D>, id: New, description: ImageDescription, + allow_information: bool, ) -> WpImageDescriptionV1 where D: Dispatch + 'static, @@ -274,6 +284,7 @@ where id, ImageDescriptionData { description: Some(description), + allow_information, }, ); object.ready(next_identity()); @@ -438,15 +449,48 @@ where ); } wp_color_manager_v1::Request::CreateWindowsScrgb { image_description } => { - data_init.init( + // Advertised alongside the other HDR entries, so refuse it on + // the same terms: without the gate there is no extended-linear + // path through the render pipeline to honour it with. + if !hdr_experiment_enabled() { + data_init.init( + image_description, + ImageDescriptionData { + description: None, + allow_information: false, + }, + ); + manager.post_error( + wp_color_manager_v1::Error::UnsupportedFeature, + "windows_scrgb is not supported", + ); + return; + } + // Wholly fixed by the protocol: sRGB/BT.709 primaries and + // white point, extended-linear transfer, nominal R=G=B=1.0 at + // 80 cd/m² rising to 125.0 at 10k cd/m². The luminances have + // to be stated rather than defaulted — `ExtLinear`'s own + // defaults are the SDR 80 cd/m² set, which would silently + // clamp the whole point of the encoding. Reference white is + // formally *unknown* for Windows-scRGB; the protocol says to + // assume BT.2408's 203 cd/m² where one is needed, and the + // compositing path needs one. + init_ready_description( + data_init, image_description, - ImageDescriptionData { - description: None - } - ); - manager.post_error( - wp_color_manager_v1::Error::UnsupportedFeature, - "windows_scrgb is not supported", + ImageDescription { + primaries: ColorPrimaries::Srgb, + tf: TransferCharacteristics::ExtLinear, + luminances: Some(Luminances { + min: 0.0, + max: 10_000.0, + reference: 203.0, + }), + max_cll: None, + max_fall: None, + }, + // The protocol disallows `get_information` on the result. + false, ); } wp_color_manager_v1::Request::Destroy => {} @@ -476,7 +520,8 @@ where init_ready_description( data_init, image_description, - description + description, + true, ); } wp_color_management_output_v1::Request::Destroy => {} @@ -589,7 +634,13 @@ where } => { if !data.surface.is_alive() { data_init - .init(image_description, ImageDescriptionData { description: None }); + .init( + image_description, + ImageDescriptionData { + description: None, + allow_information: false, + }, + ); feedback.post_error( wp_color_management_surface_feedback_v1::Error::Inert, "the wl_surface has been destroyed", @@ -602,7 +653,8 @@ where init_ready_description( data_init, image_description, - description + description, + true, ); } wp_color_management_surface_feedback_v1::Request::Destroy => {} @@ -805,13 +857,15 @@ where max_cll: params.max_cll, max_fall: params.max_fall, }, + true, ); } _ => { data_init.init( image_description, ImageDescriptionData { - description: None + description: None, + allow_information: false, }, ); creator.post_error( @@ -847,14 +901,14 @@ where // it must not be sent from inside this dispatch (which // created `info`) or wayland-backend writes the new // object's data through a freed pointer afterwards. - Some(description) => { + Some(description) if data.allow_information => { state .defer_image_description_info( info, *description, ); } - None => { + _ => { description_obj.post_error( wp_image_description_v1::Error::NoInformation, "image description has no information", From e318c8b6fd00ff9fd68371cafd0e515f4fb9e52c Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 09:48:13 +0100 Subject: [PATCH 46/60] Add per-surface color management to fragment shader Compositing operates in sRGB-encoded BT.709. This change converts input surfaces tagged with other color profiles (e.g., ST 2084 PQ, extended linear, BT.2020 primaries) to the compositing space. HDR content is tone-mapped to SDR luminance to fit within the [0,1] range before final encoding. --- src/shojiwm/src/backend/clipped_surface.frag | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 35e82997..7aa62b26 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -28,6 +28,78 @@ uniform vec2 sample_buffer_size; uniform vec2 sample_uv_snap_axes; uniform float sample_uv_compensation_enabled; +// Per-surface color management. Compositing happens on sRGB-encoded BT.709 +// values, so content tagged with any other transfer/primaries via +// `wp_color_management_v1` has to be converted on the way in — otherwise it is +// decoded as if it were sRGB (which it never was) and again by +// `output_encode.frag`, which is what makes PQ video look washed out. +// +// This is the SDR-correct conversion: HDR content is tone-mapped down into the +// compositing range rather than carried at HDR luminance, because the encode +// pass clamps to [0,1] before its own transfer. Carrying real HDR through +// needs linear compositing and is a separate change. +// +// 0 = passthrough (untagged, already sRGB/BT.709), 1 = ST 2084 PQ, 2 = extended linear. +uniform float src_transfer; +// 0 = BT.709/sRGB primaries, 1 = BT.2020. +uniform float src_primaries; +// Content reference white and peak in cd/m², from the surface's `Luminances` +// (or the protocol's per-transfer-function defaults when unset). +uniform float src_ref_nits; +uniform float src_max_nits; + +// BT.2020 -> BT.709 linear-light gamut matrix, column-major. Inverse of the +// BT.2087 matrix in `output_encode.frag`; both are cross-checked against the +// CPU derivation in `color/colorimetry.rs`. +const mat3 BT2020_TO_BT709 = mat3( + 1.660491, -0.124550, -0.018151, + -0.587641, 1.132900, -0.100579, + -0.072850, -0.008349, 1.118730 +); + +// SMPTE ST 2084 (PQ) EOTF: PQ signal -> absolute cd/m². Inverse of +// `pq_inv_eotf` in `output_encode.frag`. +vec3 pq_eotf(vec3 e) { + const float m1 = 0.1593017578125; // 1305/8192 + const float m2 = 78.84375; // 2523/32 + const float c1 = 0.8359375; // 107/128 + const float c2 = 18.8515625; // 2413/128 + const float c3 = 18.6875; // 2392/128 + vec3 ep = pow(clamp(e, 0.0, 1.0), vec3(1.0 / m2)); + vec3 num = max(ep - vec3(c1), vec3(0.0)); + vec3 den = max(vec3(c2) - c3 * ep, vec3(0.000001)); + return 10000.0 * pow(num / den, vec3(1.0 / m1)); +} + +// sRGB inverse EOTF (IEC 61966-2-1 piecewise encode). +vec3 srgb_inv_eotf(vec3 c) { + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.0031308)))); +} + +// Roll the content's luminance range down so its reference white lands on +// compositing-space 1.0. Extended Reinhard: linear near the reference white, +// asymptotically approaching 1.0 at the content peak. Deliberately simple — +// tone mapping is where perceptual quality lives and this is the knob to +// iterate on with real content. +vec3 tonemap_to_sdr(vec3 nits) { + float ref_nits = max(src_ref_nits, 0.0001); + float peak = max(src_max_nits, ref_nits) / ref_nits; + vec3 n = nits / ref_nits; + return n * (vec3(1.0) + n / vec3(peak * peak)) / (vec3(1.0) + n); +} + +// Convert one sampled, *unpremultiplied* texel into the compositing space. +vec3 to_compositing_space(vec3 c) { + // Extended linear (scRGB) defines 1.0 as 80 cd/m²; PQ is absolute. + vec3 linear = (src_transfer > 1.5) ? c * 80.0 : pq_eotf(c); + if (src_primaries > 0.5) { + linear = BT2020_TO_BT709 * linear; + } + return srgb_inv_eotf(clamp(tonemap_to_sdr(linear), 0.0, 1.0)); +} + float rounded_alpha(vec2 coords, vec2 size) { if (coords.x < 0.0 || coords.y < 0.0 || coords.x > size.x || coords.y > size.y) { return 0.0; From 0570499bc43eba416c892ea800b4aaf01f0bce62 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 10:37:31 +0100 Subject: [PATCH 47/60] Correctly apply color transfer to premultiplied alpha Non-linear transfer functions, when applied to Wayland buffers with premultiplied alpha, can cause halo artifacts on transparent edges. Temporarily unpremultiply the color before applying the transfer function, then re-premultiply the result. --- src/shojiwm/src/backend/clipped_surface.frag | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 7aa62b26..58916a9a 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -152,6 +152,13 @@ void main() { } vec4 color = texture2D(tex, sample_coords); + if (src_transfer > 0.5) { + // Wayland buffers carry premultiplied alpha. A transfer function is + // non-linear, so it has to be applied to the unpremultiplied value or + // partially transparent edges pick up haloes; re-premultiply after. + float a = max(color.a, 0.0001); + color.rgb = to_compositing_space(color.rgb / a) * a; + } vec2 local_coords = (input_to_local * vec3(v_coords, 1.0)).xy; if (rect_bounds_enabled > 0.5) { vec2 slot_coords = local_coords - slot_origin; From 4142065ce92b44e0e9dd8b4a1ef554a7b55988f4 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 15:08:24 +0100 Subject: [PATCH 48/60] Add sRGB EOTF to surface color management The existing color management for input surfaces handled PQ and extended linear (scRGB) transfer functions. This change introduces explicit support for sRGB EOTF (Electro-Optical Transfer Function) to correctly decode sRGB-encoded values to linear light. This is crucial for surfaces tagged with an sRGB transfer function, especially when combined with non-sRGB primaries or specific reference nits, ensuring accurate color transformation to the compositing space. --- src/shojiwm/src/backend/clipped_surface.frag | 21 ++++++++++++++++-- src/shojiwm/src/backend/clipped_surface.rs | 23 ++++++++++++++++++++ src/shojiwm/src/backend/hdr_pipeline.rs | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 58916a9a..79bbbd7d 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -90,10 +90,27 @@ vec3 tonemap_to_sdr(vec3 nits) { return n * (vec3(1.0) + n / vec3(peak * peak)) / (vec3(1.0) + n); } +// sRGB EOTF (IEC 61966-2-1 piecewise decode). +vec3 srgb_eotf(vec3 c) { + vec3 lo = c / 12.92; + vec3 hi = pow((max(c, vec3(0.0)) + vec3(0.055)) / 1.055, vec3(2.4)); + return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.04045)))); +} + // Convert one sampled, *unpremultiplied* texel into the compositing space. vec3 to_compositing_space(vec3 c) { - // Extended linear (scRGB) defines 1.0 as 80 cd/m²; PQ is absolute. - vec3 linear = (src_transfer > 1.5) ? c * 80.0 : pq_eotf(c); + vec3 linear; + if (src_transfer > 2.5) { + // sRGB transfer, but non-sRGB primaries — decode only so the gamut + // matrix below has linear light to work on. + linear = srgb_eotf(c) * max(src_ref_nits, 0.0001); + } else if (src_transfer > 1.5) { + // Extended linear (scRGB): 1.0 is defined as 80 cd/m². + linear = c * 80.0; + } else { + // PQ is absolute luminance. + linear = pq_eotf(c); + } if (src_primaries > 0.5) { linear = BT2020_TO_BT709 * linear; } diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index 81c8a201..5c27adb8 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -146,6 +146,13 @@ pub struct ClippedSurfaceElement { sample_buffer_size: [f32; 2], sample_uv_snap_axes: [f32; 2], sample_uv_compensation_enabled: f32, + /// Per-surface color management, resolved once at element construction. + /// `src_transfer == 0.0` is the untagged fast path and makes the shader + /// skip the conversion entirely, so nothing changes for sRGB clients. + src_transfer: f32, + src_primaries: f32, + src_ref_nits: f32, + src_max_nits: f32, } #[derive(Debug)] @@ -273,6 +280,22 @@ impl ClippedSurfaceElement { "sample_uv_compensation_enabled", smithay::backend::renderer::gles::UniformType::_1f, ), + UniformName::new( + "src_transfer", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_primaries", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_ref_nits", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_max_nits", + smithay::backend::renderer::gles::UniformType::_1f, + ), ], )?); renderer diff --git a/src/shojiwm/src/backend/hdr_pipeline.rs b/src/shojiwm/src/backend/hdr_pipeline.rs index 66373297..261a960d 100644 --- a/src/shojiwm/src/backend/hdr_pipeline.rs +++ b/src/shojiwm/src/backend/hdr_pipeline.rs @@ -84,7 +84,7 @@ pub fn probe_fp16_render_support( /// Luminance that sRGB full white maps to on the PQ signal (cd/m²). /// ITU-R BT.2408 reference white by default; `SHOJI_SDR_NITS` overrides /// for taste/testing. -fn sdr_reference_nits() -> f32 { +pub(crate) fn sdr_reference_nits() -> f32 { static NITS: std::sync::OnceLock = std::sync::OnceLock::new(); *NITS.get_or_init(|| { std::env::var( From 9c77355edc3ff14ee1d1e31880891d988895577a Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 15:25:39 +0100 Subject: [PATCH 49/60] Resolve surface color tags into shader uniforms Pre-calculate color primaries, transfer characteristics, and luminance information from a surface's `ImageDescription` into shader uniforms. This centralizes the color tag resolution logic during `ClippedSurfaceElement` creation, making these values directly available to the fragment shader. Pre-calculating once per surface streamlines per-fragment color conversions, including an optimized path for sRGB surfaces. --- src/shojiwm/src/backend/clipped_surface.rs | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index 5c27adb8..5707d39c 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -214,6 +214,7 @@ impl ClippedSurfaceElement { clip: ContentClip, forced_geometry: Option>, debug_label: Option, + image_description: Option, ) -> Result { if renderer .egl_context() @@ -505,6 +506,37 @@ impl ClippedSurfaceElement { } } + // Resolve the surface's color-management tag into shader uniforms once, + // here, rather than per-fragment. `src_transfer == 0.0` is the untagged + // path and makes the shader skip the conversion, so sRGB clients are + // byte-identical to before. + let (src_transfer, src_primaries, src_ref_nits, src_max_nits) = match image_description { + Some(description) => { + let primaries = match description.primaries { + crate::color::ColorPrimaries::Srgb => 0.0, + crate::color::ColorPrimaries::Bt2020 => 1.0, + }; + let transfer = match description.tf { + // sRGB in sRGB primaries already *is* the compositing + // space. In a wider gamut it still needs the matrix, so it + // takes the decode-only branch instead of the fast path. + crate::color::TransferCharacteristics::Srgb if primaries == 0.0 => 0.0, + crate::color::TransferCharacteristics::Srgb => 3.0, + crate::color::TransferCharacteristics::St2084Pq => 1.0, + crate::color::TransferCharacteristics::ExtLinear => 2.0, + }; + let luminances = description.effective_luminances(); + // MaxCLL is the measured peak of the content; fall back to the + // description's declared maximum when the client didn't send it. + let max_nits = description + .max_cll + .map(|cll| cll as f32) + .unwrap_or(luminances.max); + (transfer, primaries, luminances.reference, max_nits) + } + None => (0.0, 0.0, 0.0, 0.0), + }; + Ok(Self { inner, geometry: render_geometry, @@ -530,6 +562,10 @@ impl ClippedSurfaceElement { } else { 0.0 }, + src_transfer, + src_primaries, + src_ref_nits, + src_max_nits, }) } From 3a3fa208d6186133e6858146f0a554ba4ce2c8d3 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 15:53:40 +0100 Subject: [PATCH 50/60] Propagate surface color tags to rendering elements This change retrieves the `ImageDescription` from Wayland window surfaces and passes its resolved components (transfer function, primaries, reference/max nits) to `ClippedSurfaceElement`s. These values are then made available as shader uniforms, enabling accurate per-surface color management during compositing. It also updates the `src_transfer` uniform documentation to specify a new option for sRGB content that only requires a gamut transformation. --- src/shojiwm/src/backend/clipped_surface.frag | 3 ++- src/shojiwm/src/backend/clipped_surface.rs | 4 ++++ src/shojiwm/src/backend/window.rs | 21 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 79bbbd7d..10a38ee5 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -39,7 +39,8 @@ uniform float sample_uv_compensation_enabled; // pass clamps to [0,1] before its own transfer. Carrying real HDR through // needs linear compositing and is a separate change. // -// 0 = passthrough (untagged, already sRGB/BT.709), 1 = ST 2084 PQ, 2 = extended linear. +// 0 = passthrough (untagged, or already sRGB in sRGB primaries), 1 = ST 2084 PQ, +// 2 = extended linear (scRGB), 3 = sRGB transfer needing only a gamut change. uniform float src_transfer; // 0 = BT.709/sRGB primaries, 1 = BT.2020. uniform float src_primaries; diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index 5707d39c..3a3d49bc 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -707,6 +707,10 @@ impl ClippedSurfaceElement { "sample_uv_compensation_enabled", self.sample_uv_compensation_enabled, ), + Uniform::new("src_transfer", self.src_transfer), + Uniform::new("src_primaries", self.src_primaries), + Uniform::new("src_ref_nits", self.src_ref_nits), + Uniform::new("src_max_nits", self.src_max_nits), ] } } diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index 2826c0c2..c4307faf 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -868,6 +868,20 @@ pub fn clipped_surface_elements( // SSD clip is allowed to crop the client surface tree. let clip = clip.filter(|clip| clip.clips_surface); + // Color-management tag for the window's root surface, applied to every + // element in its tree. Approximation: subsurfaces are distinct protocol + // surfaces and may carry their own descriptions, so a player that puts + // video on a subsurface while leaving the toplevel untagged is not handled + // yet. The common case — a client tagging its main surface — is. + let image_description = match window.underlying_surface() { + WindowSurface::Wayland(surface) => { + crate::protocols::color_management::surface_image_description(surface.wl_surface()) + } + // X11 has no color-management protocol, so XWayland clients are always + // untagged and take the passthrough path. + _ => None, + }; + let elements = surface_elements(window, renderer, location, output_scale, alpha); if clip.is_none() || std::env::var_os("SHOJI_GAP_BYPASS_CLIP").is_some() { return Ok(elements.into_iter().map(WindowClipElement::Raw).collect()); @@ -986,6 +1000,7 @@ pub fn clipped_surface_elements( clip, geometry, debug_label.clone(), + image_description, )?)); } else { output.push(WindowClipElement::Raw(element)); @@ -1019,6 +1034,7 @@ pub fn clipped_surface_elements( clip, geometry_override, debug_label.clone(), + image_description, ) .map(WindowClipElement::Clipped) } else { @@ -1052,6 +1068,11 @@ pub fn clipped_popup_elements( clip, None, Some("popup clipped by ManagedWindow.forceRectSize".to_owned()), + // Popups are separate protocol surfaces with their own + // descriptions; inheriting the toplevel's would be wrong. They + // are effectively never color-tagged, so leave them untagged + // rather than guess. + None, ) }) .collect() From aa0202b4746ac5430b4a1a9663e6a9eb455d35cf Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 16:56:29 +0100 Subject: [PATCH 51/60] Apply color management to subsurfaces Collect image descriptions for all surfaces in a window's tree, not just the toplevel. This correctly handles clients, such as video players, that tag subsurfaces for specific content while leaving the main surface untagged. Optimize the surface tree walk by skipping it if no surface has ever been tagged in the current session, using a sticky flag. --- src/shojiwm/src/backend/window.rs | 51 +++++++++++++++---- src/shojiwm/src/protocols/color_management.rs | 21 ++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index c4307faf..c9a419b4 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -22,7 +22,13 @@ use smithay::{ utils::{Logical, Physical, Point, Rectangle, Scale}, wayland::{ background_effect::BackgroundEffectSurfaceCachedState, - compositor::{RectangleKind, RegionAttributes, with_states}, + compositor::{ + RectangleKind, + RegionAttributes, + TraversalAction, + with_states, + with_surface_tree_downward, + }, session_lock::LockSurface, shell::wlr_layer::Layer as WlrLayer, shell::xdg::XdgToplevelSurfaceData, @@ -873,14 +879,41 @@ pub fn clipped_surface_elements( // surfaces and may carry their own descriptions, so a player that puts // video on a subsurface while leaving the toplevel untagged is not handled // yet. The common case — a client tagging its main surface — is. - let image_description = match window.underlying_surface() { - WindowSurface::Wayland(surface) => { - crate::protocols::color_management::surface_image_description(surface.wl_surface()) - } - // X11 has no color-management protocol, so XWayland clients are always - // untagged and take the passthrough path. - _ => None, - }; + // Color-management tags, keyed by the element id `WaylandSurfaceRenderElement` + // derives from each surface. Collected per-surface rather than taken from the + // toplevel, because a subsurface carries its own description — players that + // put video on a subsurface leave the toplevel untagged. + // + // Pairing by id rather than rebuilding the element list ourselves keeps + // smithay's surface-tree walk (and its view-offset handling) as the single + // source of truth for positioning. + let surface_descriptions: std::collections::HashMap = + match window.underlying_surface() { + // Skipped entirely until something in the session has ever been + // tagged, which is one relaxed atomic load in the common case. + WindowSurface::Wayland(toplevel) + if crate::protocols::color_management::any_surface_tagged() => + { + let mut found = std::collections::HashMap::new(); + with_surface_tree_downward( + toplevel.wl_surface(), + (), + |_, _, _| TraversalAction::DoChildren(()), + |surface, _, _| { + if let Some(description) = + crate::protocols::color_management::surface_image_description(surface) + { + found.insert(Id::from_wayland_resource(surface), description); + } + }, + |_, _, _| true, + ); + found + } + // X11 has no color-management protocol, so XWayland clients are + // always untagged and take the passthrough path. + _ => std::collections::HashMap::new(), + }; let elements = surface_elements(window, renderer, location, output_scale, alpha); if clip.is_none() || std::env::var_os("SHOJI_GAP_BYPASS_CLIP").is_some() { diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index ea307d02..dcbee9a8 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -159,6 +159,22 @@ fn with_color_surface_data( }) } +/// Set once any surface is ever tagged, and never cleared. +/// +/// The render path has to walk a window's surface tree to find per-subsurface +/// descriptions, and almost no session ever has a tagged surface at all. This +/// turns that walk into a single relaxed load in the common case. Deliberately +/// sticky rather than a live count: untagging is rare, the walk is cheap, and a +/// stale `true` only costs a walk that finds nothing — whereas a stale `false` +/// would silently skip the conversion. +static ANY_SURFACE_TAGGED: AtomicBool = AtomicBool::new(false); + +/// Whether any surface has ever carried an image description this session. +/// A `false` return means the render path can skip its per-window tree walk. +pub fn any_surface_tagged() -> bool { + ANY_SURFACE_TAGGED.load(Ordering::Relaxed) +} + /// Render-side read: the surface's committed image description, or `None` /// for untagged content (treat as sRGB). pub fn surface_image_description(surface: &WlSurface) -> Option { @@ -585,6 +601,11 @@ where with_color_surface_data(&data.surface, |surface_data| { *surface_data.description.lock().unwrap() = Some(description); }); + ANY_SURFACE_TAGGED + .store( + true, + Ordering::Relaxed, + ); } wp_color_management_surface_v1::Request::UnsetImageDescription => { if data.surface.is_alive() { From 7a69d15bb7415aefd00db1da30cb0b7400aefb8e Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 17:00:07 +0100 Subject: [PATCH 52/60] Use element's specific description for clipped elements The previous `image_description` was too generic. This change ensures that the image description retrieved from `surface_descriptions` for the *current element* is used. This is crucial for correctly applying per-surface color management and other attributes to individual clipped window elements, aligning with the goal of correctly handling subsurfaces. --- src/shojiwm/src/backend/window.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index c9a419b4..117ffb18 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -1024,6 +1024,14 @@ pub fn clipped_surface_elements( let mut output = Vec::with_capacity(elements.len()); for element in elements { if Element::id(&element) == &root_id { + // Resolve before the move: `element` is consumed below. + let element_description = + surface_descriptions + .get( + Element::id( + &element, + ), + ).copied(); output.push(WindowClipElement::Clipped(ClippedSurfaceElement::new( renderer, element, @@ -1033,7 +1041,7 @@ pub fn clipped_surface_elements( clip, geometry, debug_label.clone(), - image_description, + element_description, )?)); } else { output.push(WindowClipElement::Raw(element)); From 21354ae109f31d6adae8d3eaf9269b33f2e86383 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 17:02:30 +0100 Subject: [PATCH 53/60] Resolve element description before `element` is consumed This ensures the `element`'s ID is accessible to retrieve its specific description before the `element` value is moved or consumed later in the function. This prevents potential use-after-move issues and explicitly clarifies the data dependency. --- src/shojiwm/src/backend/window.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index 117ffb18..c2caded6 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -1065,6 +1065,13 @@ pub fn clipped_surface_elements( "gap debug clipped surface candidate", ); } + // Resolve before the move: `element` is consumed below. + let element_description = surface_descriptions + .get( + Element::id( + &element, + ) + ).copied(); if geometry_override.is_some() { ClippedSurfaceElement::new( renderer, @@ -1075,7 +1082,7 @@ pub fn clipped_surface_elements( clip, geometry_override, debug_label.clone(), - image_description, + element_description, ) .map(WindowClipElement::Clipped) } else { From 8b4fefcfed46751ffc58e495462c45022edd124e Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 17:38:17 +0100 Subject: [PATCH 54/60] Implement ITU-R BT.2390 EETF for HDR tone mapping Replaces the simpler Extended Reinhard curve with the perceptually uniform BT.2390 EETF. This improves highlight compression and midtone preservation for HDR content by operating in the PQ domain. --- src/shojiwm/src/backend/clipped_surface.frag | 69 ++++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 10a38ee5..bad8e24b 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -44,10 +44,17 @@ uniform float sample_uv_compensation_enabled; uniform float src_transfer; // 0 = BT.709/sRGB primaries, 1 = BT.2020. uniform float src_primaries; -// Content reference white and peak in cd/m², from the surface's `Luminances` -// (or the protocol's per-transfer-function defaults when unset). +// Content reference white in cd/m², from the surface's `Luminances` (or the +// protocol's per-transfer-function defaults when unset). Doubles as the +// compositing space's white point: 1.0 here means this many nits. uniform float src_ref_nits; -uniform float src_max_nits; +// Tone-mapping knee parameters, already in the PQ domain because that is where +// BT.2390 operates. Precomputed on the CPU via `color::colorimetry:: +// pq_inverse_eotf` — deriving them here would cost four extra pow-heavy calls +// per fragment for values that are constant across the whole surface. +uniform float src_pq_lo; // content black +uniform float src_pq_hi; // content peak +uniform float dst_pq_hi; // what compositing-space 1.0 can represent // BT.2020 -> BT.709 linear-light gamut matrix, column-major. Inverse of the // BT.2087 matrix in `output_encode.frag`; both are cross-checked against the @@ -79,16 +86,52 @@ vec3 srgb_inv_eotf(vec3 c) { return mix(hi, lo, vec3(lessThanEqual(c, vec3(0.0031308)))); } -// Roll the content's luminance range down so its reference white lands on -// compositing-space 1.0. Extended Reinhard: linear near the reference white, -// asymptotically approaching 1.0 at the content peak. Deliberately simple — -// tone mapping is where perceptual quality lives and this is the knob to -// iterate on with real content. -vec3 tonemap_to_sdr(vec3 nits) { - float ref_nits = max(src_ref_nits, 0.0001); - float peak = max(src_max_nits, ref_nits) / ref_nits; - vec3 n = nits / ref_nits; - return n * (vec3(1.0) + n / vec3(peak * peak)) / (vec3(1.0) + n); +// SMPTE ST 2084 (PQ) inverse EOTF: absolute cd/m² -> PQ signal. Mirrors the +// one in `output_encode.frag`; needed here to lift non-PQ sources into the +// domain BT.2390 works in. +vec3 pq_inv_eotf(vec3 nits) { + const float m1 = 0.1593017578125; + const float m2 = 78.84375; + const float c1 = 0.8359375; + const float c2 = 18.8515625; + const float c3 = 18.6875; + vec3 y = clamp(nits / 10000.0, 0.0, 1.0); + vec3 ym = pow(y, vec3(m1)); + return pow((vec3(c1) + c2 * ym) / (vec3(1.0) + c3 * ym), vec3(m2)); +} + +// ITU-R BT.2390 EETF, applied per channel on a PQ signal. +// +// Below the knee this is the *identity*, so content already inside the target +// range passes through untouched — the decisive advantage over a global curve +// like Reinhard, which pulls midtones down alongside highlights and leaves the +// whole image flat. Above the knee a Hermite spline rolls smoothly off to the +// target peak. Operating in PQ rather than linear light spreads the +// compression error evenly across *perceived* brightness. +float bt2390_channel(float e) { + float range = max(src_pq_hi - src_pq_lo, 0.000001); + float e1 = clamp((e - src_pq_lo) / range, 0.0, 1.0); + float max_lum = clamp((dst_pq_hi - src_pq_lo) / range, 0.0, 1.0); + float ks = 1.5 * max_lum - 0.5; + + float e2 = e1; + if (ks < 1.0 && e1 > ks) { + float t = (e1 - ks) / (1.0 - ks); + float t2 = t * t; + float t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + + (t3 - 2.0 * t2 + t) * (1.0 - ks) + + (-2.0 * t3 + 3.0 * t2) * max_lum; + } + return e2 * range + src_pq_lo; +} + +vec3 bt2390_eetf(vec3 pq) { + return vec3( + bt2390_channel(pq.r), + bt2390_channel(pq.g), + bt2390_channel(pq.b) + ); } // sRGB EOTF (IEC 61966-2-1 piecewise decode). From 510f7783f9706a5b701022ad1860c755a287aa2c Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 17:40:23 +0100 Subject: [PATCH 55/60] Integrate BT.2390 EETF with explicit tone mapping parameters Replaces the generic `src_max_nits` uniform with `src_pq_lo`, `src_pq_hi`, and `dst_pq_hi` to provide configurable knee points for the BT.2390 EETF. This refines the HDR tone mapping pipeline by correctly lifting source content into the PQ domain, applying the EETF, and ensuring proper normalization for SDR sources. --- src/shojiwm/src/backend/clipped_surface.frag | 25 +++++++++++++------- src/shojiwm/src/backend/clipped_surface.rs | 23 ++++++++++++++---- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index bad8e24b..b1e78057 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -143,22 +143,31 @@ vec3 srgb_eotf(vec3 c) { // Convert one sampled, *unpremultiplied* texel into the compositing space. vec3 to_compositing_space(vec3 c) { - vec3 linear; + float ref_nits = max(src_ref_nits, 0.0001); + + // Lift the source into the PQ domain, which is where the tone curve works. + vec3 pq; if (src_transfer > 2.5) { - // sRGB transfer, but non-sRGB primaries — decode only so the gamut - // matrix below has linear light to work on. - linear = srgb_eotf(c) * max(src_ref_nits, 0.0001); + // sRGB transfer in wider primaries: decode, scale to absolute nits. + pq = pq_inv_eotf(srgb_eotf(c) * ref_nits); } else if (src_transfer > 1.5) { // Extended linear (scRGB): 1.0 is defined as 80 cd/m². - linear = c * 80.0; + pq = pq_inv_eotf(max(c, vec3(0.0)) * 80.0); } else { - // PQ is absolute luminance. - linear = pq_eotf(c); + // Already a PQ signal. + pq = clamp(c, 0.0, 1.0); } + + // Compress the content's range into what the compositing space can hold. + // SDR sources land entirely below the knee and pass through unchanged. + vec3 linear = pq_eotf(bt2390_eetf(pq)); + if (src_primaries > 0.5) { linear = BT2020_TO_BT709 * linear; } - return srgb_inv_eotf(clamp(tonemap_to_sdr(linear), 0.0, 1.0)); + // Normalize against the content's own reference white so diffuse white + // lands on 1.0 rather than being scaled by an unrelated display value. + return srgb_inv_eotf(clamp(linear / ref_nits, 0.0, 1.0)); } float rounded_alpha(vec2 coords, vec2 size) { diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index 3a3d49bc..c97bb299 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -152,7 +152,10 @@ pub struct ClippedSurfaceElement { src_transfer: f32, src_primaries: f32, src_ref_nits: f32, - src_max_nits: f32, + /// Tone-mapping knee, already PQ-encoded on the CPU. + src_pq_lo: f32, + src_pq_hi: f32, + dst_pq_hi: f32, } #[derive(Debug)] @@ -294,7 +297,15 @@ impl ClippedSurfaceElement { smithay::backend::renderer::gles::UniformType::_1f, ), UniformName::new( - "src_max_nits", + "src_pq_lo", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "src_pq_hi", + smithay::backend::renderer::gles::UniformType::_1f, + ), + UniformName::new( + "dst_pq_hi", smithay::backend::renderer::gles::UniformType::_1f, ), ], @@ -565,7 +576,9 @@ impl ClippedSurfaceElement { src_transfer, src_primaries, src_ref_nits, - src_max_nits, + src_pq_lo, + src_pq_hi, + dst_pq_hi, }) } @@ -710,7 +723,9 @@ impl ClippedSurfaceElement { Uniform::new("src_transfer", self.src_transfer), Uniform::new("src_primaries", self.src_primaries), Uniform::new("src_ref_nits", self.src_ref_nits), - Uniform::new("src_max_nits", self.src_max_nits), + Uniform::new("src_pq_lo", self.src_pq_lo), + Uniform::new("src_pq_hi", self.src_pq_hi), + Uniform::new("dst_pq_hi", self.dst_pq_hi), ] } } From 5d800b72abcc99c006f8428b89e8bb588d2b0b29 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 17:52:12 +0100 Subject: [PATCH 56/60] Calculate BT.2390 PQ knee points from luminance metadata Pre-converts content luminance (min, max, reference nits) into the PQ domain to furnish `src_pq_lo`, `src_pq_hi`, and `dst_pq_hi` uniforms. This enables the BT.2390 EETF to operate directly on PQ signals and avoids redundant per-fragment `pq_inverse_eotf` calls. --- src/shojiwm/src/backend/clipped_surface.rs | 28 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.rs b/src/shojiwm/src/backend/clipped_surface.rs index c97bb299..09c6cdfd 100644 --- a/src/shojiwm/src/backend/clipped_surface.rs +++ b/src/shojiwm/src/backend/clipped_surface.rs @@ -521,7 +521,14 @@ impl ClippedSurfaceElement { // here, rather than per-fragment. `src_transfer == 0.0` is the untagged // path and makes the shader skip the conversion, so sRGB clients are // byte-identical to before. - let (src_transfer, src_primaries, src_ref_nits, src_max_nits) = match image_description { + let ( + src_transfer, + src_primaries, + src_ref_nits, + src_pq_lo, + src_pq_hi, + dst_pq_hi, + ) = match image_description { Some(description) => { let primaries = match description.primaries { crate::color::ColorPrimaries::Srgb => 0.0, @@ -543,9 +550,24 @@ impl ClippedSurfaceElement { .max_cll .map(|cll| cll as f32) .unwrap_or(luminances.max); - (transfer, primaries, luminances.reference, max_nits) + // BT.2390 operates on PQ signals, so encode the knee here + // rather than paying four pow() calls per fragment for values + // that are constant across the surface. The target peak is the + // content's own reference white, which is what compositing-space + // 1.0 represents. + let to_pq = |nits: f32| { + crate::color::colorimetry::pq_inverse_eotf(nits as f64) as f32 + }; + ( + transfer, + primaries, + luminances.reference, + to_pq(luminances.min), + to_pq(max_nits), + to_pq(luminances.reference), + ) } - None => (0.0, 0.0, 0.0, 0.0), + None => (0.0, 0.0, 0.0, 0.0, 0.0, 0.0), }; Ok(Self { From 4d94510dc97b8ff3ceed70c86fadf6ccb36ab008 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 19:56:36 +0100 Subject: [PATCH 57/60] fix declaration order --- src/shojiwm/src/backend/clipped_surface.frag | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index b1e78057..693b5fbf 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -1,18 +1,27 @@ -precision highp float; - -uniform float alpha; -varying vec2 v_coords; - +// Smithay substitutes the variant defines here via a plain +// `src.replace("//_DEFINES_", ...)` (gles/shaders/mod.rs). Without this marker +// the replace is a no-op and all three compiled variants get identical source +// with EXTERNAL and NO_ALPHA *undefined* — so an external-OES buffer would be +// sampled through a `sampler2D` declaration, and NO_ALPHA (which smithay +// documents as mandatory for custom texture shaders) would never be honoured. +//_DEFINES_ + +// `#extension` must precede every non-preprocessor token in GLSL ES 1.00, so +// it has to come before `precision` and any declaration. #if defined(EXTERNAL) #extension GL_OES_EGL_image_external : require #endif +precision highp float; + #if defined(EXTERNAL) uniform samplerExternalOES tex; #else uniform sampler2D tex; #endif +uniform float alpha; +varying vec2 v_coords; uniform float clip_scale; uniform vec2 slot_size; uniform vec2 slot_origin; From f02ec56db20807828b167a40700e1545e39e3b11 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 19:59:24 +0100 Subject: [PATCH 58/60] preserve rounded corners --- src/shojiwm/src/backend/clipped_surface.frag | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index 693b5fbf..e72bdd3d 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -231,6 +231,12 @@ void main() { } vec4 color = texture2D(tex, sample_coords); +#if defined(NO_ALPHA) + // Buffer carries no alpha channel. Force it here, immediately after the + // sample — *not* at the end, where it would overwrite the rounded-corner + // coverage written by `rounded_alpha` below and square off every corner. + color = vec4(color.rgb, 1.0); +#endif if (src_transfer > 0.5) { // Wayland buffers carry premultiplied alpha. A transfer function is // non-linear, so it has to be applied to the unpremultiplied value or From 2164ed576f5e15bbd1e9a13f45cd39b4e0e24369 Mon Sep 17 00:00:00 2001 From: Seirra Date: Thu, 30 Jul 2026 19:59:55 +0100 Subject: [PATCH 59/60] clarify comment --- src/shojiwm/src/backend/clipped_surface.frag | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/shojiwm/src/backend/clipped_surface.frag b/src/shojiwm/src/backend/clipped_surface.frag index e72bdd3d..25e18cfd 100644 --- a/src/shojiwm/src/backend/clipped_surface.frag +++ b/src/shojiwm/src/backend/clipped_surface.frag @@ -1,9 +1,13 @@ -// Smithay substitutes the variant defines here via a plain -// `src.replace("//_DEFINES_", ...)` (gles/shaders/mod.rs). Without this marker -// the replace is a no-op and all three compiled variants get identical source -// with EXTERNAL and NO_ALPHA *undefined* — so an external-OES buffer would be -// sampled through a `sampler2D` declaration, and NO_ALPHA (which smithay -// documents as mandatory for custom texture shaders) would never be honoured. +// Smithay substitutes the variant defines at the marker below, by plain string +// replacement (see gles/shaders/mod.rs). Without it the replacement is a no-op +// and all three compiled variants get identical source with EXTERNAL and +// NO_ALPHA *undefined* — an external-OES buffer would then be sampled through a +// `sampler2D` declaration, and NO_ALPHA (which smithay documents as mandatory +// for custom texture shaders) would never be honoured. +// +// Do not write the marker's name anywhere else in this file: the replacement is +// global, so a second occurrence — even inside a comment — would inject a +// `#define` mid-line and break the following line out of its comment. //_DEFINES_ // `#extension` must precede every non-preprocessor token in GLSL ES 1.00, so From c05076a1cf728cc203a4ecd3c902089929758c42 Mon Sep 17 00:00:00 2001 From: Seirra Date: Sun, 2 Aug 2026 00:58:29 +0100 Subject: [PATCH 60/60] avoid deadlock during surface tree traversal Smithay's surface user-data lock is not reentrant. Calling `surface_image_description` during a traversal attempts to re-acquire the lock already held by the traversal, deadlocking the compositor. This uses the provided `SurfaceData` directly to bypass the unnecessary lock acquisition. --- src/shojiwm/src/backend/window.rs | 12 +++++- src/shojiwm/src/protocols/color_management.rs | 37 +++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/shojiwm/src/backend/window.rs b/src/shojiwm/src/backend/window.rs index c2caded6..2e299eeb 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -899,9 +899,17 @@ pub fn clipped_surface_elements( toplevel.wl_surface(), (), |_, _, _| TraversalAction::DoChildren(()), - |surface, _, _| { + // Read the description out of the `SurfaceData` smithay + // hands us. Calling `surface_image_description` here instead + // would re-take the surface's user-data lock that the + // traversal is still holding, and that lock is not + // reentrant — it deadlocks the compositor on the first + // window that maps. + |surface, states, _| { if let Some(description) = - crate::protocols::color_management::surface_image_description(surface) + crate::protocols::color_management::image_description_from_states( + states, + ) { found.insert(Id::from_wayland_resource(surface), description); } diff --git a/src/shojiwm/src/protocols/color_management.rs b/src/shojiwm/src/protocols/color_management.rs index dcbee9a8..1546a5cb 100644 --- a/src/shojiwm/src/protocols/color_management.rs +++ b/src/shojiwm/src/protocols/color_management.rs @@ -73,7 +73,10 @@ use smithay::reexports::wayland_server::{ protocol::{wl_output::WlOutput, wl_surface::WlSurface}, }; -use smithay::wayland::compositor::with_states; +use smithay::wayland::compositor::{ + SurfaceData, + with_states, +}; use crate::color::{ ColorPrimaries, @@ -175,23 +178,33 @@ pub fn any_surface_tagged() -> bool { ANY_SURFACE_TAGGED.load(Ordering::Relaxed) } +/// Render-side read from `SurfaceData` the caller already holds. +/// +/// Use this — never [`surface_image_description`] — from inside a +/// `with_surface_tree_downward` (or `with_surface_tree_upward`) processor. +/// Smithay holds the surface's user-data lock for the whole traversal and calls +/// the processor while it is held, and that lock is a plain non-reentrant +/// `std::sync::Mutex`. Taking it again on the same thread deadlocks the +/// compositor outright: no panic, no error, no log line, just a thread parked in +/// `futex_wait` forever. The processor is already handed the `&SurfaceData` this +/// needs, so there is never a reason to lock again. +pub fn image_description_from_states(states: &SurfaceData) -> Option { + states + .data_map + .get::() + .and_then(|data| *data.description.lock().unwrap()) +} + /// Render-side read: the surface's committed image description, or `None` /// for untagged content (treat as sRGB). +/// +/// Takes the surface's user-data lock. Not safe to call from inside a surface +/// tree traversal — see [`image_description_from_states`]. pub fn surface_image_description(surface: &WlSurface) -> Option { if !surface.is_alive() { return None; } - with_states(surface, |states| { - states - .data_map - .get::() - .and_then( - |data| *data - .description - .lock() - .unwrap() - ) - }) + with_states(surface, image_description_from_states) } /// Manager state for the `wp_color_manager_v1` global.