Skip to main content

drmkit_display/
capabilities.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What a connector and a CRTC can be asked to do.
5//!
6//! Both are property caches, and both exist for the same reason: the integer
7//! a driver assigns to a named enum entry is the driver's business, so the
8//! only way to write `Colorspace = BT2020_RGB` is to have asked that
9//! connector what it calls it. Doing that per frame is an ioctl per property;
10//! doing it once is this.
11
12use drmkit_core::{Device, Result};
13use drmkit_core::{ObjectType, PropertyStore};
14
15/// A connector's `Colorspace` entries, by the kernel's names.
16///
17/// What the sink is told the pixels mean. Writing the wrong one is not an
18/// error the kernel can catch -- the picture just comes out in the wrong
19/// gamut.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21#[non_exhaustive]
22pub enum Colorspace {
23    /// `Default`
24    Default,
25    /// `SMPTE_170M_YCC`
26    Smpte170mYcc,
27    /// `BT709_YCC`
28    Bt709Ycc,
29    /// `XVYCC_601`
30    XvYcc601,
31    /// `XVYCC_709`
32    XvYcc709,
33    /// `SYCC_601`
34    SYcc601,
35    /// `opYCC_601`
36    OpYcc601,
37    /// `opRGB`
38    OpRgb,
39    /// `BT2020_CYCC`
40    Bt2020Cycc,
41    /// `BT2020_RGB`
42    Bt2020Rgb,
43    /// `BT2020_YCC`
44    Bt2020Ycc,
45    /// `DCI-P3_RGB_D65`
46    DciP3RgbD65,
47    /// `DCI-P3_RGB_Theater`
48    DciP3RgbTheater,
49    /// `RGB_Wide_Gamut_Fixed_Point`
50    RgbWideFixed,
51    /// `RGB_Wide_Gamut_Floating_Point`
52    RgbWideFloat,
53}
54
55impl Colorspace {
56    /// Every variant, in the order the cache stores them.
57    pub const ALL: [Self; 15] = [
58        Self::Default,
59        Self::Smpte170mYcc,
60        Self::Bt709Ycc,
61        Self::XvYcc601,
62        Self::XvYcc709,
63        Self::SYcc601,
64        Self::OpYcc601,
65        Self::OpRgb,
66        Self::Bt2020Cycc,
67        Self::Bt2020Rgb,
68        Self::Bt2020Ycc,
69        Self::DciP3RgbD65,
70        Self::DciP3RgbTheater,
71        Self::RgbWideFixed,
72        Self::RgbWideFloat,
73    ];
74
75    /// The name the kernel gives this entry.
76    #[must_use]
77    pub const fn name(self) -> &'static str {
78        match self {
79            Self::Default => "Default",
80            Self::Smpte170mYcc => "SMPTE_170M_YCC",
81            Self::Bt709Ycc => "BT709_YCC",
82            Self::XvYcc601 => "XVYCC_601",
83            Self::XvYcc709 => "XVYCC_709",
84            Self::SYcc601 => "SYCC_601",
85            Self::OpYcc601 => "opYCC_601",
86            Self::OpRgb => "opRGB",
87            Self::Bt2020Cycc => "BT2020_CYCC",
88            Self::Bt2020Rgb => "BT2020_RGB",
89            Self::Bt2020Ycc => "BT2020_YCC",
90            Self::DciP3RgbD65 => "DCI-P3_RGB_D65",
91            Self::DciP3RgbTheater => "DCI-P3_RGB_Theater",
92            Self::RgbWideFixed => "RGB_Wide_Gamut_Fixed_Point",
93            Self::RgbWideFloat => "RGB_Wide_Gamut_Floating_Point",
94        }
95    }
96
97    const fn index(self) -> usize {
98        self as usize
99    }
100}
101
102/// A connector's `Broadcast RGB` entries.
103///
104/// The RGB quantization range, which is a separate question from the YCbCr
105/// `COLOR_RANGE` on a plane. Getting it wrong is the classic washed-out or
106/// crushed-black HDMI picture.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
108#[non_exhaustive]
109pub enum BroadcastRgb {
110    /// `Automatic`
111    Automatic,
112    /// `Full`
113    Full,
114    /// `Limited 16:235`
115    Limited,
116}
117
118impl BroadcastRgb {
119    /// Every variant, in the order the cache stores them.
120    pub const ALL: [Self; 3] = [Self::Automatic, Self::Full, Self::Limited];
121
122    /// The name the kernel gives this entry.
123    #[must_use]
124    pub const fn name(self) -> &'static str {
125        match self {
126            Self::Automatic => "Automatic",
127            Self::Full => "Full",
128            Self::Limited => "Limited 16:235",
129        }
130    }
131
132    const fn index(self) -> usize {
133        self as usize
134    }
135}
136
137/// The `max bpc` range, and where it currently sits.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[allow(
140    clippy::exhaustive_structs,
141    reason = "a value defined by its fields; there is nothing to add to it"
142)]
143pub struct MaxBpc {
144    /// The lowest the driver will negotiate. Usually 6 or 8.
145    pub min: u64,
146    /// The highest. Usually 8, 10, 12 or 16.
147    pub max: u64,
148    /// What the property reads now.
149    pub current: u64,
150}
151
152/// What one connector's colour properties can be set to.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct ConnectorCapabilities {
155    /// The connector this describes.
156    pub connector_id: u32,
157    /// The driver's integer for each `Colorspace` entry it exposes.
158    ///
159    /// Absent entries are the common case, not a defect: the kernel reports
160    /// only what the hardware supports. Measured on amdgpu (kernel 7.1.9), a
161    /// DP connector carries five of the fifteen -- `Default`,
162    /// `BT709_YCC`, `opRGB`, `BT2020_RGB`, `BT2020_YCC` -- and none of the
163    /// DCI-P3 or xvYCC ones.
164    colorspace: [Option<u64>; Colorspace::ALL.len()],
165    /// As `colorspace`, for `Broadcast RGB`.
166    broadcast_rgb: [Option<u64>; BroadcastRgb::ALL.len()],
167    /// The `max bpc` range, if the connector has one.
168    pub max_bpc: Option<MaxBpc>,
169    /// Whether `HDR_OUTPUT_METADATA` is exposed.
170    pub has_hdr_output_metadata: bool,
171}
172
173impl ConnectorCapabilities {
174    /// A connector with none of these properties.
175    ///
176    /// The starting point for a synthetic one -- a fixture, a test, a
177    /// capability set read from somewhere other than a live device.
178    #[must_use]
179    pub const fn new(connector_id: u32) -> Self {
180        Self {
181            connector_id,
182            colorspace: [None; Colorspace::ALL.len()],
183            broadcast_rgb: [None; BroadcastRgb::ALL.len()],
184            max_bpc: None,
185            has_hdr_output_metadata: false,
186        }
187    }
188
189    /// Give a `Colorspace` entry the driver integer `value`.
190    #[must_use]
191    pub const fn with_colorspace(mut self, entry: Colorspace, value: u64) -> Self {
192        self.colorspace[entry.index()] = Some(value);
193        self
194    }
195
196    /// Give a `Broadcast RGB` entry the driver integer `value`.
197    #[must_use]
198    pub const fn with_broadcast_rgb(mut self, entry: BroadcastRgb, value: u64) -> Self {
199        self.broadcast_rgb[entry.index()] = Some(value);
200        self
201    }
202
203    /// Give the connector a `max bpc` range.
204    #[must_use]
205    pub const fn with_max_bpc(mut self, max_bpc: MaxBpc) -> Self {
206        self.max_bpc = Some(max_bpc);
207        self
208    }
209
210    /// Say whether the connector exposes `HDR_OUTPUT_METADATA`.
211    #[must_use]
212    pub const fn with_hdr_output_metadata(mut self, present: bool) -> Self {
213        self.has_hdr_output_metadata = present;
214        self
215    }
216
217    /// Read a connector's colour properties.
218    ///
219    /// A property the connector does not have is simply absent. The only
220    /// error is not being able to read the property set at all.
221    ///
222    /// # Errors
223    ///
224    /// [`CoreError::Io`](drmkit_core::CoreError::Io) if the connector's
225    /// properties cannot be read.
226    pub fn probe(device: &Device, connector_id: u32) -> Result<Self> {
227        let mut properties = PropertyStore::new();
228        properties.cache_properties(device, connector_id, ObjectType::Connector)?;
229        Ok(Self::from_properties(device, &properties, connector_id))
230    }
231
232    /// As [`ConnectorCapabilities::probe`], from a cache the caller already
233    /// filled in.
234    #[must_use]
235    pub fn from_properties(device: &Device, properties: &PropertyStore, connector_id: u32) -> Self {
236        let mut colorspace = [None; Colorspace::ALL.len()];
237        for entry in Colorspace::ALL {
238            colorspace[entry.index()] = properties
239                .enum_value(device, connector_id, "Colorspace", entry.name())
240                .ok();
241        }
242        let mut broadcast_rgb = [None; BroadcastRgb::ALL.len()];
243        for entry in BroadcastRgb::ALL {
244            broadcast_rgb[entry.index()] = properties
245                .enum_value(device, connector_id, "Broadcast RGB", entry.name())
246                .ok();
247        }
248
249        let max_bpc = properties
250            .range(connector_id, "max bpc")
251            .ok()
252            .flatten()
253            .and_then(|(min, max)| {
254                Some(MaxBpc {
255                    min,
256                    max,
257                    current: properties.property_value(connector_id, "max bpc").ok()?,
258                })
259            });
260
261        Self {
262            connector_id,
263            colorspace,
264            broadcast_rgb,
265            max_bpc,
266            has_hdr_output_metadata: properties
267                .property_id(connector_id, "HDR_OUTPUT_METADATA")
268                .is_ok(),
269        }
270    }
271
272    /// The driver's integer for a `Colorspace` entry, if it has one.
273    #[must_use]
274    pub const fn colorspace(&self, entry: Colorspace) -> Option<u64> {
275        self.colorspace[entry.index()]
276    }
277
278    /// The driver's integer for a `Broadcast RGB` entry, if it has one.
279    #[must_use]
280    pub const fn broadcast_rgb(&self, entry: BroadcastRgb) -> Option<u64> {
281        self.broadcast_rgb[entry.index()]
282    }
283
284    /// Whether the connector exposes `Colorspace` at all.
285    #[must_use]
286    pub fn has_colorspace(&self) -> bool {
287        self.colorspace.iter().any(Option::is_some)
288    }
289
290    /// Whether the connector exposes `Broadcast RGB` at all.
291    #[must_use]
292    pub fn has_broadcast_rgb(&self) -> bool {
293        self.broadcast_rgb.iter().any(Option::is_some)
294    }
295
296    /// Whether this connector could carry an HDR signal.
297    ///
298    /// Both halves are needed. `HDR_OUTPUT_METADATA` alone at eight bits per
299    /// component gives a sink that accepts the metadata and displays banded
300    /// SDR; ten bits without the metadata is a wider SDR signal. A caller
301    /// that checks only one ships a picture that looks wrong for reasons no
302    /// error reports.
303    #[must_use]
304    pub fn can_signal_hdr(&self) -> bool {
305        self.has_hdr_output_metadata && self.max_bpc.is_some_and(|bpc| bpc.max >= 10)
306    }
307}
308
309/// What one CRTC's colour pipeline can be asked to do.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
311#[non_exhaustive]
312pub struct CrtcCapabilities {
313    /// The CRTC this describes.
314    pub crtc_id: u32,
315    /// Rows the driver expects in a `DEGAMMA_LUT` blob. Zero if absent.
316    pub degamma_lut_size: u32,
317    /// Rows the driver expects in a `GAMMA_LUT` blob. Zero if absent.
318    pub gamma_lut_size: u32,
319    /// Whether `DEGAMMA_LUT` is exposed.
320    pub has_degamma_lut: bool,
321    /// Whether `CTM` is exposed.
322    pub has_ctm: bool,
323    /// Whether `GAMMA_LUT` is exposed.
324    pub has_gamma_lut: bool,
325}
326
327impl CrtcCapabilities {
328    /// Read a CRTC's colour-pipeline properties.
329    ///
330    /// # Errors
331    ///
332    /// As [`ConnectorCapabilities::probe`].
333    pub fn probe(device: &Device, crtc_id: u32) -> Result<Self> {
334        let mut properties = PropertyStore::new();
335        properties.cache_properties(device, crtc_id, ObjectType::Crtc)?;
336        Ok(Self::from_properties(&properties, crtc_id))
337    }
338
339    /// As [`CrtcCapabilities::probe`], from a cache the caller already filled
340    /// in.
341    #[must_use]
342    pub fn from_properties(properties: &PropertyStore, crtc_id: u32) -> Self {
343        // The size properties are what say how long a blob the driver wants.
344        // Their presence is what says the stage exists at all: a driver that
345        // exposes DEGAMMA_LUT without DEGAMMA_LUT_SIZE has told the caller
346        // nothing it can act on, since a LUT of unknown length cannot be
347        // built.
348        let size = |name: &str| {
349            properties
350                .property_value(crtc_id, name)
351                .ok()
352                .and_then(|value| u32::try_from(value).ok())
353                .unwrap_or(0)
354        };
355        let has = |name: &str| properties.property_id(crtc_id, name).is_ok();
356
357        Self {
358            crtc_id,
359            degamma_lut_size: size("DEGAMMA_LUT_SIZE"),
360            gamma_lut_size: size("GAMMA_LUT_SIZE"),
361            has_degamma_lut: has("DEGAMMA_LUT"),
362            has_ctm: has("CTM"),
363            has_gamma_lut: has("GAMMA_LUT"),
364        }
365    }
366
367    /// Whether the CRTC has the whole degamma, matrix, gamma pipeline.
368    ///
369    /// False on a driver exposing only part of it -- vkms has `GAMMA_LUT` and
370    /// nothing else -- even though the stages it does have are usable.
371    #[must_use]
372    pub const fn has_full_pipeline(&self) -> bool {
373        self.has_degamma_lut && self.has_ctm && self.has_gamma_lut
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::{BroadcastRgb, Colorspace, ConnectorCapabilities, CrtcCapabilities, MaxBpc};
380
381    /// A connector shaped like the amdgpu one this was measured against.
382    fn amdgpu() -> ConnectorCapabilities {
383        ConnectorCapabilities::new(383)
384            .with_colorspace(Colorspace::Default, 0)
385            .with_colorspace(Colorspace::Bt709Ycc, 1)
386            .with_colorspace(Colorspace::OpRgb, 2)
387            .with_colorspace(Colorspace::Bt2020Rgb, 3)
388            .with_colorspace(Colorspace::Bt2020Ycc, 4)
389            .with_max_bpc(MaxBpc {
390                min: 8,
391                max: 16,
392                current: 16,
393            })
394            .with_hdr_output_metadata(true)
395    }
396
397    #[test]
398    fn an_entry_the_driver_does_not_have_is_absent_rather_than_zero() {
399        // Zero is a real enum value -- amdgpu's `Default` -- so a cache that
400        // answered zero for a missing entry would silently write Default
401        // wherever DCI-P3 was asked for.
402        let connector = amdgpu();
403        assert_eq!(connector.colorspace(Colorspace::Default), Some(0));
404        assert_eq!(connector.colorspace(Colorspace::DciP3RgbD65), None);
405    }
406
407    #[test]
408    fn every_entry_is_stored_under_its_own_name() {
409        // The index is derived from the discriminant, so a variant inserted
410        // in the middle of the enum would shift everything after it.
411        let mut connector = ConnectorCapabilities::new(1);
412        for (value, entry) in Colorspace::ALL.into_iter().enumerate() {
413            connector = connector.with_colorspace(entry, value as u64);
414        }
415        for (value, entry) in Colorspace::ALL.into_iter().enumerate() {
416            assert_eq!(
417                connector.colorspace(entry),
418                Some(value as u64),
419                "{}",
420                entry.name()
421            );
422        }
423    }
424
425    #[test]
426    fn broadcast_rgb_entries_are_stored_the_same_way() {
427        let mut connector = ConnectorCapabilities::new(1);
428        for (value, entry) in BroadcastRgb::ALL.into_iter().enumerate() {
429            connector = connector.with_broadcast_rgb(entry, 10 + value as u64);
430        }
431        for (value, entry) in BroadcastRgb::ALL.into_iter().enumerate() {
432            assert_eq!(connector.broadcast_rgb(entry), Some(10 + value as u64));
433        }
434        assert!(connector.has_broadcast_rgb());
435    }
436
437    #[test]
438    fn hdr_needs_the_metadata_and_the_bit_depth() {
439        assert!(amdgpu().can_signal_hdr());
440
441        // Metadata at eight bits is a sink that accepts the blob and shows
442        // banded SDR.
443        assert!(
444            !ConnectorCapabilities::new(1)
445                .with_hdr_output_metadata(true)
446                .with_max_bpc(MaxBpc {
447                    min: 6,
448                    max: 8,
449                    current: 8,
450                })
451                .can_signal_hdr()
452        );
453
454        // Ten bits without the blob is a wider SDR signal.
455        assert!(
456            !ConnectorCapabilities::new(1)
457                .with_max_bpc(MaxBpc {
458                    min: 8,
459                    max: 10,
460                    current: 8,
461                })
462                .can_signal_hdr()
463        );
464
465        // And a connector with no `max bpc` at all says nothing about depth,
466        // which is not the same as saying ten.
467        assert!(
468            !ConnectorCapabilities::new(1)
469                .with_hdr_output_metadata(true)
470                .can_signal_hdr()
471        );
472    }
473
474    #[test]
475    fn the_current_bpc_is_what_is_set_not_what_is_possible() {
476        // A connector that could do sixteen but is sitting at eight can still
477        // signal HDR -- it is the ceiling that decides, and the current value
478        // is what a caller would have to change.
479        let connector = ConnectorCapabilities::new(1)
480            .with_hdr_output_metadata(true)
481            .with_max_bpc(MaxBpc {
482                min: 8,
483                max: 16,
484                current: 8,
485            });
486        assert!(connector.can_signal_hdr());
487        assert_eq!(connector.max_bpc.map(|bpc| bpc.current), Some(8));
488    }
489
490    #[test]
491    fn a_connector_with_nothing_on_it_says_so() {
492        let connector = ConnectorCapabilities::new(7);
493        assert!(!connector.has_colorspace());
494        assert!(!connector.has_broadcast_rgb());
495        assert!(!connector.can_signal_hdr());
496        assert_eq!(connector.connector_id, 7);
497    }
498
499    #[test]
500    fn a_partial_colour_pipeline_is_not_a_full_one() {
501        // vkms exposes GAMMA_LUT and nothing else. The stage is usable; the
502        // pipeline is not, and a caller programming a CTM into it gets EINVAL
503        // on the whole commit.
504        let vkms = CrtcCapabilities {
505            crtc_id: 34,
506            gamma_lut_size: 256,
507            has_gamma_lut: true,
508            ..CrtcCapabilities::default()
509        };
510        assert!(!vkms.has_full_pipeline());
511
512        let amdgpu = CrtcCapabilities {
513            crtc_id: 369,
514            degamma_lut_size: 4096,
515            gamma_lut_size: 4096,
516            has_degamma_lut: true,
517            has_ctm: true,
518            has_gamma_lut: true,
519        };
520        assert!(amdgpu.has_full_pipeline());
521    }
522}