From c5b38c6107980c127249b273c70b73c07e8c8a0c 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 c35283ec5d7e748610f76d49299ab3040a70c385 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 ee6a20b6..ab63924b 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -898,7 +898,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 ae12e951..e0c36e34 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -970,6 +970,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 5a206720a6f344b831862985a2048f0886190f76 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 3505a0c57c646120985c1ee99735c5fd14ea2a64 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 2b98ffdfa233dba85f6f9a79f1abd2c93ec22953 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 b025539932249c99834f618514befdb16aa946d8 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 ca3c2cde241e2e60efc75399c2098a8540eb5906 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 2b52b00f631dc67830d67732d9f129f20e5aa4dc 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 2a51927d..e84f8fec 100644 --- a/src/shojiwm/src/main.rs +++ b/src/shojiwm/src/main.rs @@ -12,6 +12,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 f3676a84ed0ceb3653272e5df9fa78782b1fb020 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 e0c36e34..6ac199ee 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -252,6 +252,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: @@ -328,6 +329,8 @@ pub struct ShojiWM { pub runtime_scheduler_kick_active: bool, 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, @@ -1161,6 +1164,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, @@ -1224,6 +1228,7 @@ impl ShojiWM { runtime_scheduler_kick_active: false, 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 2de7d14ee7581177e87ccdb3a4de701b2e7c223f 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 ab63924b..f4128d87 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11340,6 +11340,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 7fd89fd7..99d482ce 100644 --- a/src/shojiwm/src/handlers/mod.rs +++ b/src/shojiwm/src/handlers/mod.rs @@ -864,7 +864,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 6cce69715fc6f6f0ac5e61abe3ff2ac1d209b213 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 f4128d87..57dbbd19 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11525,6 +11525,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 2f885fc78ff7c49f4873f0c4d6bc494e2c257fc4 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 57dbbd19..d2b57911 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11536,12 +11536,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 d66a8289bdfc93e7b5d297eeb1b1c7860d39d818 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 c1a16a1b88dc4486f4bfaa3b212f2f8fc167e097 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 c76d1444b0c483feb6bb03c2c22f3883d48291ec 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 1d11e62a16017bb6d225034de736fa04f5cd7021 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 f2e2e545b57a5f635811e6a56408fbf938fe0840 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 824a91cda7d9e5023d8f6bcd0a508c8ba82347d2 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 e67dfd5bb2c35612106ddc02dd131f415f0a30be 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 363514dbb67686e9cba362acec07e8b7782b9a7a 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 bf1b038a..c441f606 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 c418271d1c73db8f0045fa30bf761e5e3ebccdfd 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 d2b57911..69d64b52 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1341,6 +1341,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 8b7e5793398ced34a7c60dd2c444cf51ced443e6 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 69d64b52..7d9c4a87 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -1365,6 +1365,7 @@ fn tty_render_element_name(element: &TtyRenderElements) -> &'static str { TtyRenderElements::RelocatedBackdrop(_) => "RelocatedBackdrop", TtyRenderElements::TransformedBackdrop(_) => "TransformedBackdrop", TtyRenderElements::Cursor(_) => "Cursor", + TtyRenderElements::HdrEncode(_) => "HdrEncode", _ => "Generic", } } From d49174d450febee1e24e624cbe3c0e9d9f287c00 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 7d9c4a87..80ebfcc2 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -788,6 +788,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, } From 7f78a73a7db04d1953ffb092493dfde57f257e76 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 80ebfcc2..5f11a0e3 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -909,10 +909,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(), }; state.tty_backends.insert(node.clone(), backend); From bd9aacf192b511100c71bb0fdd923092d6428b7f 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 5f11a0e3..f9388a6f 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -5188,6 +5188,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 { @@ -11369,10 +11440,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 a8ee3c82118b68fbd66e025ba628a658914603ac 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 f9388a6f..2ae1445b 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -5332,6 +5332,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 67c0cf6dff55c1c6795d0efd65b8066948d28d84 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 6ac199ee..de4703d3 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -331,6 +331,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 adccabec1d8e3f672de355fede3fa9ae41fdc360 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 de4703d3..bf405f8a 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -1231,6 +1231,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 f75d704eee106cb39c902587196c30616de29da8 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 2ae1445b..c0970520 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11655,6 +11655,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 bf405f8a..775fa35f 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2343,6 +2343,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 e1fac98ec33e478b17c7a45818a3573029b99824 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 eeb44416819dc29b904b2f5ae61aea50721a3459 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 579ddc1ac0bc0755618e36c7b3bc1eb99c131976 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 e9b564e382f28259cf87d2f58e6001524b7bb49d 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 7ee5c391506e0464a3a62faf35e6db7fc8ec9a18 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 73259b5cdb5037744202d579085dedde1d42d6d2 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 99d482ce..b6110241 100644 --- a/src/shojiwm/src/handlers/mod.rs +++ b/src/shojiwm/src/handlers/mod.rs @@ -887,6 +887,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 eed7e3bb94cae6477b69427dc1ad78e949c1424e 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 9ee05f1cb0cd711bcd211bede1b663323fe044ab 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 e081f3f4cb33399732c040cd65516ee2404034fa 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 c0970520..d18a7b32 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11436,6 +11436,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) @@ -11448,12 +11458,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 49fbb6e2c5f91713c532fd7d6d01934e3758b93d 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 d18a7b32..ef5b4089 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11778,6 +11778,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 c56f44f357ab6f32c1c98ac6460c249ba8e29da9 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 ef5b4089..be754f99 100644 --- a/src/shojiwm/src/backend/tty.rs +++ b/src/shojiwm/src/backend/tty.rs @@ -11921,7 +11921,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 207a5f0843c435d71224575ff684e392c78ca982 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 775fa35f..2fcf591c 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2227,6 +2227,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 87af74f4bf2e8c9674399f4a867f5e48c951f2c1 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 21511f2b..7e8fc1ab 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -919,6 +919,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 48a1992da3f67983d7f68150c3a1f5bc8b0e3328 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 a536444b9e584b2a82554a14a856861453b45168 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 7e8fc1ab..a4c0d41b 100644 --- a/packages/shoji_wm/src/types.ts +++ b/packages/shoji_wm/src/types.ts @@ -958,6 +958,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 68a8c5ba..19e5618b 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 2fcf591c..760c4ca3 100644 --- a/src/shojiwm/src/state.rs +++ b/src/shojiwm/src/state.rs @@ -2094,6 +2094,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 1a3073a166f865317f58ea8481404df73dac5912 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 cc12223b1cba26c21e859c8334ae41f545ced40d 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 38eb506cf17864d8b6e29ced86c2e0ba2950aec6 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 1e78e93403108b9647f25e622229ddea0d84a3df 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 71791f3dbda60ee29d0d2f52fcf9e3087831cb62 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 c8d63c1cb929dad7e4b8d4828b70ba462bb149ce 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 ad7bef08..87e672b4 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -799,6 +799,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()); @@ -917,6 +931,7 @@ pub fn clipped_surface_elements( clip, geometry, debug_label.clone(), + image_description, )?)); } else { output.push(WindowClipElement::Raw(element)); @@ -950,6 +965,7 @@ pub fn clipped_surface_elements( clip, geometry_override, debug_label.clone(), + image_description, ) .map(WindowClipElement::Clipped) } else { @@ -983,6 +999,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 04d7a903ee68d88c7aaf8440b2a5e7e50a1d93cb 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 87e672b4..0a4a4403 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -18,7 +18,13 @@ use smithay::{ reexports::wayland_server::Resource, utils::{Logical, Physical, Point, Rectangle, Scale}, wayland::{ - 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, @@ -804,14 +810,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 9e6651a29d3c90a54127b50d295f21a480f6297c 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 0a4a4403..cdf92461 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -955,6 +955,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, @@ -964,7 +972,7 @@ pub fn clipped_surface_elements( clip, geometry, debug_label.clone(), - image_description, + element_description, )?)); } else { output.push(WindowClipElement::Raw(element)); From c89b58636737f3772602e2ba8dd17440d6d50dee 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 cdf92461..94feb777 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -996,6 +996,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, @@ -1006,7 +1013,7 @@ pub fn clipped_surface_elements( clip, geometry_override, debug_label.clone(), - image_description, + element_description, ) .map(WindowClipElement::Clipped) } else { From 8dd22f74ee5629e3a6d28b2d6547e7ce09a358aa 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 0af1c406f322a0c11fa8007a2d5b5043a9279f2f 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 bc444426400199e2512dd08f0f16fec904ba616a 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 b86747fb5067418662f8e176cf13331544c78b38 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 52fa674fec40ef5ad6c362c3938562050ec5e4e5 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 1ece4bed589c8d8445a70d777b9d4a3854506f9a 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 6714c1378da3a4e9967ccbde48de6a83b08aea84 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 94feb777..2970c76f 100644 --- a/src/shojiwm/src/backend/window.rs +++ b/src/shojiwm/src/backend/window.rs @@ -830,9 +830,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.