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 color 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 color 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::NoSuchObject`](drmkit_core::CoreError::NoSuchObject) for
225    /// connector id 0; otherwise [`CoreError::Io`](drmkit_core::CoreError::Io)
226    /// if the connector's properties cannot be read.
227    pub fn probe(device: &Device, connector_id: u32) -> Result<Self> {
228        let mut properties = PropertyStore::new();
229        properties.cache_properties(device, connector_id, ObjectType::Connector)?;
230        Ok(Self::from_properties(device, &properties, connector_id))
231    }
232
233    /// As [`ConnectorCapabilities::probe`], from a cache the caller already
234    /// filled in.
235    #[must_use]
236    pub fn from_properties(device: &Device, properties: &PropertyStore, connector_id: u32) -> Self {
237        let mut colorspace = [None; Colorspace::ALL.len()];
238        for entry in Colorspace::ALL {
239            colorspace[entry.index()] = properties
240                .enum_value(device, connector_id, "Colorspace", entry.name())
241                .ok();
242        }
243        let mut broadcast_rgb = [None; BroadcastRgb::ALL.len()];
244        for entry in BroadcastRgb::ALL {
245            broadcast_rgb[entry.index()] = properties
246                .enum_value(device, connector_id, "Broadcast RGB", entry.name())
247                .ok();
248        }
249
250        let max_bpc = properties
251            .range(connector_id, "max bpc")
252            .ok()
253            .flatten()
254            .and_then(|(min, max)| {
255                Some(MaxBpc {
256                    min,
257                    max,
258                    current: properties.property_value(connector_id, "max bpc").ok()?,
259                })
260            });
261
262        Self {
263            connector_id,
264            colorspace,
265            broadcast_rgb,
266            max_bpc,
267            has_hdr_output_metadata: properties
268                .property_id(connector_id, "HDR_OUTPUT_METADATA")
269                .is_ok(),
270        }
271    }
272
273    /// The driver's integer for a `Colorspace` entry, if it has one.
274    #[must_use]
275    pub const fn colorspace(&self, entry: Colorspace) -> Option<u64> {
276        self.colorspace[entry.index()]
277    }
278
279    /// The driver's integer for a `Broadcast RGB` entry, if it has one.
280    #[must_use]
281    pub const fn broadcast_rgb(&self, entry: BroadcastRgb) -> Option<u64> {
282        self.broadcast_rgb[entry.index()]
283    }
284
285    /// Whether the connector exposes `Colorspace` at all.
286    #[must_use]
287    pub fn has_colorspace(&self) -> bool {
288        self.colorspace.iter().any(Option::is_some)
289    }
290
291    /// Whether the connector exposes `Broadcast RGB` at all.
292    #[must_use]
293    pub fn has_broadcast_rgb(&self) -> bool {
294        self.broadcast_rgb.iter().any(Option::is_some)
295    }
296
297    /// Whether this connector could carry an HDR signal.
298    ///
299    /// Both halves are needed. `HDR_OUTPUT_METADATA` alone at eight bits per
300    /// component gives a sink that accepts the metadata and displays banded
301    /// SDR; ten bits without the metadata is a wider SDR signal. A caller
302    /// that checks only one ships a picture that looks wrong for reasons no
303    /// error reports.
304    #[must_use]
305    pub fn can_signal_hdr(&self) -> bool {
306        self.has_hdr_output_metadata && self.max_bpc.is_some_and(|bpc| bpc.max >= 10)
307    }
308}
309
310/// What one CRTC's color pipeline can be asked to do.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
312#[non_exhaustive]
313pub struct CrtcCapabilities {
314    /// The CRTC this describes.
315    pub crtc_id: u32,
316    /// Rows the driver expects in a `DEGAMMA_LUT` blob. Zero if absent.
317    pub degamma_lut_size: u32,
318    /// Rows the driver expects in a `GAMMA_LUT` blob. Zero if absent.
319    pub gamma_lut_size: u32,
320    /// Whether `DEGAMMA_LUT` is exposed.
321    pub has_degamma_lut: bool,
322    /// Whether `CTM` is exposed.
323    pub has_ctm: bool,
324    /// Whether `GAMMA_LUT` is exposed.
325    pub has_gamma_lut: bool,
326}
327
328impl CrtcCapabilities {
329    /// Read a CRTC's color-pipeline properties.
330    ///
331    /// # Errors
332    ///
333    /// As [`ConnectorCapabilities::probe`].
334    pub fn probe(device: &Device, crtc_id: u32) -> Result<Self> {
335        let mut properties = PropertyStore::new();
336        properties.cache_properties(device, crtc_id, ObjectType::Crtc)?;
337        Ok(Self::from_properties(&properties, crtc_id))
338    }
339
340    /// As [`CrtcCapabilities::probe`], from a cache the caller already filled
341    /// in.
342    #[must_use]
343    pub fn from_properties(properties: &PropertyStore, crtc_id: u32) -> Self {
344        // The size properties are what say how long a blob the driver wants.
345        // Their presence is what says the stage exists at all: a driver that
346        // exposes DEGAMMA_LUT without DEGAMMA_LUT_SIZE has told the caller
347        // nothing it can act on, since a LUT of unknown length cannot be
348        // built.
349        let size = |name: &str| {
350            properties
351                .property_value(crtc_id, name)
352                .ok()
353                .and_then(|value| u32::try_from(value).ok())
354                .unwrap_or(0)
355        };
356        let has = |name: &str| properties.property_id(crtc_id, name).is_ok();
357
358        Self {
359            crtc_id,
360            degamma_lut_size: size("DEGAMMA_LUT_SIZE"),
361            gamma_lut_size: size("GAMMA_LUT_SIZE"),
362            has_degamma_lut: has("DEGAMMA_LUT"),
363            has_ctm: has("CTM"),
364            has_gamma_lut: has("GAMMA_LUT"),
365        }
366    }
367
368    /// Whether the CRTC has the whole degamma, matrix, gamma pipeline.
369    ///
370    /// False on a driver exposing only part of it -- vkms has `GAMMA_LUT` and
371    /// nothing else -- even though the stages it does have are usable.
372    #[must_use]
373    pub const fn has_full_pipeline(&self) -> bool {
374        self.has_degamma_lut && self.has_ctm && self.has_gamma_lut
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::{BroadcastRgb, Colorspace, ConnectorCapabilities, CrtcCapabilities, MaxBpc};
381
382    /// A connector shaped like the amdgpu one this was measured against.
383    fn amdgpu() -> ConnectorCapabilities {
384        ConnectorCapabilities::new(383)
385            .with_colorspace(Colorspace::Default, 0)
386            .with_colorspace(Colorspace::Bt709Ycc, 1)
387            .with_colorspace(Colorspace::OpRgb, 2)
388            .with_colorspace(Colorspace::Bt2020Rgb, 3)
389            .with_colorspace(Colorspace::Bt2020Ycc, 4)
390            .with_max_bpc(MaxBpc {
391                min: 8,
392                max: 16,
393                current: 16,
394            })
395            .with_hdr_output_metadata(true)
396    }
397
398    #[test]
399    fn an_entry_the_driver_does_not_have_is_absent_rather_than_zero() {
400        // Zero is a real enum value -- amdgpu's `Default` -- so a cache that
401        // answered zero for a missing entry would silently write Default
402        // wherever DCI-P3 was asked for.
403        let connector = amdgpu();
404        assert_eq!(connector.colorspace(Colorspace::Default), Some(0));
405        assert_eq!(connector.colorspace(Colorspace::DciP3RgbD65), None);
406    }
407
408    #[test]
409    fn every_entry_is_stored_under_its_own_name() {
410        // The index is derived from the discriminant, so a variant inserted
411        // in the middle of the enum would shift everything after it.
412        let mut connector = ConnectorCapabilities::new(1);
413        for (value, entry) in Colorspace::ALL.into_iter().enumerate() {
414            connector = connector.with_colorspace(entry, value as u64);
415        }
416        for (value, entry) in Colorspace::ALL.into_iter().enumerate() {
417            assert_eq!(
418                connector.colorspace(entry),
419                Some(value as u64),
420                "{}",
421                entry.name()
422            );
423        }
424    }
425
426    #[test]
427    fn broadcast_rgb_entries_are_stored_the_same_way() {
428        let mut connector = ConnectorCapabilities::new(1);
429        for (value, entry) in BroadcastRgb::ALL.into_iter().enumerate() {
430            connector = connector.with_broadcast_rgb(entry, 10 + value as u64);
431        }
432        for (value, entry) in BroadcastRgb::ALL.into_iter().enumerate() {
433            assert_eq!(connector.broadcast_rgb(entry), Some(10 + value as u64));
434        }
435        assert!(connector.has_broadcast_rgb());
436    }
437
438    #[test]
439    fn hdr_needs_the_metadata_and_the_bit_depth() {
440        assert!(amdgpu().can_signal_hdr());
441
442        // Metadata at eight bits is a sink that accepts the blob and shows
443        // banded SDR.
444        assert!(
445            !ConnectorCapabilities::new(1)
446                .with_hdr_output_metadata(true)
447                .with_max_bpc(MaxBpc {
448                    min: 6,
449                    max: 8,
450                    current: 8,
451                })
452                .can_signal_hdr()
453        );
454
455        // Ten bits without the blob is a wider SDR signal.
456        assert!(
457            !ConnectorCapabilities::new(1)
458                .with_max_bpc(MaxBpc {
459                    min: 8,
460                    max: 10,
461                    current: 8,
462                })
463                .can_signal_hdr()
464        );
465
466        // And a connector with no `max bpc` at all says nothing about depth,
467        // which is not the same as saying ten.
468        assert!(
469            !ConnectorCapabilities::new(1)
470                .with_hdr_output_metadata(true)
471                .can_signal_hdr()
472        );
473    }
474
475    #[test]
476    fn the_current_bpc_is_what_is_set_not_what_is_possible() {
477        // A connector that could do sixteen but is sitting at eight can still
478        // signal HDR -- it is the ceiling that decides, and the current value
479        // is what a caller would have to change.
480        let connector = ConnectorCapabilities::new(1)
481            .with_hdr_output_metadata(true)
482            .with_max_bpc(MaxBpc {
483                min: 8,
484                max: 16,
485                current: 8,
486            });
487        assert!(connector.can_signal_hdr());
488        assert_eq!(connector.max_bpc.map(|bpc| bpc.current), Some(8));
489    }
490
491    #[test]
492    fn a_connector_with_nothing_on_it_says_so() {
493        let connector = ConnectorCapabilities::new(7);
494        assert!(!connector.has_colorspace());
495        assert!(!connector.has_broadcast_rgb());
496        assert!(!connector.can_signal_hdr());
497        assert_eq!(connector.connector_id, 7);
498    }
499
500    #[test]
501    fn a_partial_color_pipeline_is_not_a_full_one() {
502        // vkms exposes GAMMA_LUT and nothing else. The stage is usable; the
503        // pipeline is not, and a caller programming a CTM into it gets EINVAL
504        // on the whole commit.
505        let vkms = CrtcCapabilities {
506            crtc_id: 34,
507            gamma_lut_size: 256,
508            has_gamma_lut: true,
509            ..CrtcCapabilities::default()
510        };
511        assert!(!vkms.has_full_pipeline());
512
513        let amdgpu = CrtcCapabilities {
514            crtc_id: 369,
515            degamma_lut_size: 4096,
516            gamma_lut_size: 4096,
517            has_degamma_lut: true,
518            has_ctm: true,
519            has_gamma_lut: true,
520        };
521        assert!(amdgpu.has_full_pipeline());
522    }
523}