Skip to main content

drmkit_display/
scanout.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Finding something to put a picture on.
5//!
6//! A connected connector, a mode it accepts, a CRTC its encoders can drive,
7//! and that CRTC's primary plane. Every consumer otherwise walks
8//! connector to encoder to CRTC to plane by hand, and the walk has two
9//! non-obvious steps in it -- so it lives here once.
10
11use drm::control::Device as _;
12use drm::control::connector::{Interface, State};
13use drm::control::{Mode, connector, crtc};
14
15use drmkit_core::{Device, Result};
16use drmkit_fmt::FormatTable;
17use drmkit_modeset::select_preferred_mode;
18use drmkit_planes::{PlaneRegistry, PlaneType};
19
20use crate::profile::errno;
21
22/// Which display to prefer when more than one is attached.
23///
24/// "The first connected connector" is wrong on every multi-output machine: a
25/// docked laptop with eDP and HDMI both live, or a board with DSI and HDMI,
26/// lands on whichever the kernel enumerated first. These are the policies
27/// worth having a name for, and [`discover_ranked`](ScanoutTarget::discover_ranked)
28/// takes any order a caller composes.
29pub mod rank {
30    use drm::control::connector::Interface;
31
32    /// Internal panels first, then cable-out, with VGA last.
33    ///
34    /// The right default for "give me the display the user means".
35    pub const MAIN: &[Interface] = &[
36        Interface::EmbeddedDisplayPort,
37        Interface::LVDS,
38        Interface::DSI,
39        Interface::DPI,
40        Interface::SPI,
41        Interface::HDMIA,
42        Interface::HDMIB,
43        Interface::DisplayPort,
44        Interface::DVID,
45        Interface::DVII,
46        Interface::DVIA,
47        Interface::VGA,
48        // Last, and present -- which the reference's equivalent is not. A
49        // virtual display is a real output; it is only never the one you
50        // would prefer if a physical panel were also attached. Leaving it out
51        // means finding no display on vkms and in every VM guest, since
52        // virtio-gpu enumerates as Virtual too. See
53        // [drm-cxx#253](https://github.com/jwinarske/drm-cxx/issues/253).
54        Interface::Virtual,
55    ];
56
57    /// Internal panels only.
58    ///
59    /// For something that must not grab an external monitor even when one is
60    /// plugged in -- an instrument cluster on a docked dev board.
61    pub const INTERNAL: &[Interface] = &[
62        Interface::EmbeddedDisplayPort,
63        Interface::LVDS,
64        Interface::DSI,
65        Interface::DPI,
66        Interface::SPI,
67    ];
68
69    /// Cable-out connectors only, and the virtual one.
70    ///
71    /// For signage that should land on the external display even when an
72    /// embedded panel is present. `Virtual` is here so the same policy works
73    /// in a VM, where there is nothing else.
74    pub const EXTERNAL: &[Interface] = &[
75        Interface::HDMIA,
76        Interface::HDMIB,
77        Interface::DisplayPort,
78        Interface::DVID,
79        Interface::DVII,
80        Interface::DVIA,
81        Interface::VGA,
82        Interface::Virtual,
83    ];
84}
85
86/// The best of `candidates` by `ranks`, as an index.
87///
88/// Walks the ranks in order and returns the first candidate matching each.
89/// A candidate whose type is not in `ranks` is skipped, and `None` means none
90/// matched -- which is a real answer for [`rank::INTERNAL`] on a machine with
91/// only external outputs.
92///
93/// Stable: two displays of the same kind resolve to the earlier candidate, so
94/// the kernel's enumeration order is the tiebreak and the answer does not
95/// move between runs.
96#[must_use]
97pub fn rank_pick(candidates: &[Interface], ranks: &[Interface]) -> Option<usize> {
98    ranks
99        .iter()
100        .find_map(|wanted| candidates.iter().position(|kind| kind == wanted))
101}
102
103/// A CRTC, and where it sits in the resource list.
104///
105/// Both halves are needed and they are not interchangeable: the id addresses
106/// the object in an atomic request, and the index is the bit position that
107/// encoder `possible_crtcs` and plane masks are expressed in. Mixing them up
108/// gives an allocator that offers planes to the wrong CRTC.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub struct CrtcChoice {
112    /// The CRTC's object id.
113    pub id: u32,
114    /// Its index in the resource list.
115    pub index: u32,
116}
117
118/// Somewhere to scan out.
119#[derive(Debug, Clone)]
120#[non_exhaustive]
121pub struct ScanoutTarget {
122    /// The connector with a display on it.
123    pub connector_id: u32,
124    /// The CRTC that will drive it.
125    pub crtc: CrtcChoice,
126    /// That CRTC's primary plane.
127    pub primary_plane_id: u32,
128    /// The mode to set.
129    pub mode: Mode,
130    /// What the primary plane will scan out.
131    ///
132    /// Synthesized as `LINEAR`-only when the driver exposes no `IN_FORMATS`,
133    /// so this is never empty on a plane that works -- see
134    /// [`PlaneRegistry::from_capabilities`].
135    pub primary_formats: FormatTable,
136}
137
138/// Why no output could be found.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
140#[non_exhaustive]
141pub enum ScanoutError {
142    /// Nothing is plugged in, or nothing plugged in advertises a mode.
143    #[error("no connected connector with any mode")]
144    NothingConnected,
145
146    /// The connector a caller asked for is not connected, advertises no
147    /// mode, or does not exist.
148    ///
149    /// Distinct from [`ScanoutError::NothingConnected`]: other outputs may be
150    /// up, and driving one of them instead is exactly what pinning a
151    /// connector is meant to rule out.
152    #[error("connector {connector_id} is not connected or has no mode")]
153    ConnectorUnavailable {
154        /// The connector asked for.
155        connector_id: u32,
156    },
157
158    /// A display is attached but no CRTC can drive it.
159    ///
160    /// None of its encoders lists a CRTC it can drive, or none of them could be
161    /// read. Whether a CRTC is already driving another output is not checked,
162    /// here or in the reference.
163    #[error("connector {connector_id} has a display but no CRTC its encoders can drive")]
164    NoCrtc {
165        /// The connector that could not be routed.
166        connector_id: u32,
167    },
168
169    /// A CRTC was found but has no primary plane.
170    ///
171    /// Almost always means `DRM_CLIENT_CAP_UNIVERSAL_PLANES` was never set,
172    /// which hides primary and cursor planes from the client.
173    #[error("CRTC {crtc_id} has no primary plane: was universal planes enabled?")]
174    NoPrimaryPlane {
175        /// The CRTC that had none.
176        crtc_id: u32,
177    },
178}
179
180impl ScanoutTarget {
181    /// Find an output with a display on it, preferring connector types in
182    /// [`rank::MAIN`] order.
183    ///
184    /// Enables universal planes, without which the kernel hides primary and
185    /// cursor planes and this could only ever fail. Most callers will have
186    /// set it already without meaning to: measured on amdgpu, a client with
187    /// neither capability sees 2 planes and one that has gone atomic sees all
188    /// 10, because the kernel turns universal planes on with atomic. It is
189    /// asked for here anyway, so a caller that has not gone atomic yet gets
190    /// an answer rather than a confusing `NoPrimaryPlane`.
191    ///
192    /// # Errors
193    ///
194    /// [`ScanoutError`] for each way there is nothing to drive, or
195    /// [`CoreError`](drmkit_core::CoreError) if the device cannot be read.
196    pub fn discover(device: &Device) -> Result<std::result::Result<Self, ScanoutError>> {
197        Self::discover_ranked(device, rank::MAIN)
198    }
199
200    /// As [`ScanoutTarget::discover`], preferring displays in `ranks` order.
201    ///
202    /// The rank is a preference, not a filter. When nothing connected
203    /// matches it, the first eligible connector is used anyway -- it is a
204    /// display, and refusing to drive it leaves a machine looking headless
205    /// with a picture on it. So [`rank::INTERNAL`] on a machine with only
206    /// external outputs still returns one of them.
207    ///
208    /// A caller that wants the strict reading -- an instrument cluster that
209    /// must show nothing rather than appear on the docking monitor -- ranks
210    /// the connectors itself with [`rank_pick`], which returns `None` when
211    /// none match.
212    ///
213    /// # Errors
214    ///
215    /// As [`ScanoutTarget::discover`].
216    pub fn discover_ranked(
217        device: &Device,
218        ranks: &[Interface],
219    ) -> Result<std::result::Result<Self, ScanoutError>> {
220        Self::discover_among(device, ranks, None)
221    }
222
223    /// As [`ScanoutTarget::discover`], for one connector; `0` is
224    /// [`ScanoutTarget::discover`] itself.
225    ///
226    /// For shared-display controllers, which publish one connected connector
227    /// per display consumer and light only the one actually presenting: the
228    /// first connected connector can be an unlit output that reports perfect
229    /// flips (drm-cxx `882dbf8`). Never falls back to another output.
230    ///
231    /// # Errors
232    ///
233    /// As [`ScanoutTarget::discover`], with
234    /// [`ScanoutError::ConnectorUnavailable`] when that connector is not
235    /// connected or has no mode.
236    pub fn discover_connector(
237        device: &Device,
238        connector_id: u32,
239    ) -> Result<std::result::Result<Self, ScanoutError>> {
240        if connector_id == 0 {
241            return Self::discover(device);
242        }
243        Self::discover_among(device, rank::MAIN, Some(connector_id))
244    }
245
246    fn discover_among(
247        device: &Device,
248        ranks: &[Interface],
249        only: Option<u32>,
250    ) -> Result<std::result::Result<Self, ScanoutError>> {
251        // Asked for, not required, as the reference does: a driver that refuses
252        // it shows what it shows, and a missing primary is reported as such.
253        let _ = device.enable_universal_planes();
254
255        let resources = device.resource_handles().map_err(|error| errno(&error))?;
256
257        // Connected *and* advertising a mode. A connected connector with no
258        // modes is a sink that failed to give an EDID, and choosing it means
259        // a modeset with nothing to set.
260        let mut eligible = Vec::new();
261        for handle in resources.connectors() {
262            if only.is_some_and(|id| id != u32::from(*handle)) {
263                continue;
264            }
265            let Ok(info) = device.get_connector(*handle, true) else {
266                continue;
267            };
268            if info.state() == State::Connected && !info.modes().is_empty() {
269                eligible.push(info);
270            }
271        }
272        if eligible.is_empty() {
273            return Ok(Err(only
274                .map_or(ScanoutError::NothingConnected, |connector_id| {
275                    ScanoutError::ConnectorUnavailable { connector_id }
276                })));
277        }
278
279        let kinds: Vec<Interface> = eligible.iter().map(connector::Info::interface).collect();
280        // Falling back to the first eligible one, which is what the kernel
281        // enumerated first -- the behavior a caller would have got with no
282        // ranking at all.
283        let chosen = rank_pick(&kinds, ranks).unwrap_or(0);
284        let connector = &eligible[chosen];
285        let connector_id: u32 = connector.handle().into();
286
287        let Ok(mode) = select_preferred_mode(connector.modes()) else {
288            return Ok(Err(ScanoutError::NothingConnected));
289        };
290
291        let Some(crtc) = crtc_for_connector(device, &resources, connector) else {
292            return Ok(Err(ScanoutError::NoCrtc { connector_id }));
293        };
294
295        let registry = PlaneRegistry::probe(device)?;
296        let Some(primary) = primary_plane_for_crtc(&registry, crtc.index) else {
297            return Ok(Err(ScanoutError::NoPrimaryPlane { crtc_id: crtc.id }));
298        };
299
300        Ok(Ok(Self {
301            connector_id,
302            crtc,
303            primary_plane_id: primary.id,
304            mode,
305            primary_formats: primary.format_table.clone(),
306        }))
307    }
308}
309
310/// The primary plane that can feed `crtc_index`, if there is one.
311///
312/// Taken over a registry rather than a device so it can be reasoned about,
313/// and tested, without one. `None` means either that the CRTC has no plane
314/// wired to it, or -- far more often -- that the client never set
315/// `DRM_CLIENT_CAP_UNIVERSAL_PLANES` and the kernel is hiding every primary.
316#[must_use]
317pub fn primary_plane_for_crtc(
318    registry: &PlaneRegistry,
319    crtc_index: u32,
320) -> Option<&drmkit_planes::PlaneCapabilities> {
321    registry
322        .for_crtc(crtc_index)
323        .find(|plane| plane.plane_type == PlaneType::Primary)
324}
325
326/// A CRTC that can drive `connector`.
327///
328/// Prefers the one already bound to it through its active encoder, which is
329/// the warm case: the display is lit and taking over the pipe it is already
330/// on avoids a mode change the user would see. Falls back to the first any of
331/// its possible encoders allows, which is the cold case -- nothing bound yet,
332/// and the first atomic commit wires it up.
333#[must_use]
334pub fn crtc_for_connector(
335    device: &Device,
336    resources: &drm::control::ResourceHandles,
337    connector: &connector::Info,
338) -> Option<CrtcChoice> {
339    let index_of = |crtc: crtc::Handle| {
340        resources
341            .crtcs()
342            .iter()
343            .position(|candidate| *candidate == crtc)
344            .and_then(|index| u32::try_from(index).ok())
345    };
346
347    let bound = connector
348        .current_encoder()
349        .and_then(|encoder| device.get_encoder(encoder).ok())
350        .and_then(|encoder| encoder.crtc())
351        .and_then(|crtc| Some((crtc, index_of(crtc)?)));
352    if let Some((crtc, index)) = bound {
353        return Some(CrtcChoice {
354            id: crtc.into(),
355            index,
356        });
357    }
358
359    for handle in connector.encoders() {
360        let Ok(encoder) = device.get_encoder(*handle) else {
361            continue;
362        };
363        let allowed = resources.filter_crtcs(encoder.possible_crtcs());
364        for crtc in allowed {
365            if let Some(index) = index_of(crtc) {
366                return Some(CrtcChoice {
367                    id: crtc.into(),
368                    index,
369                });
370            }
371        }
372    }
373    None
374}
375
376#[cfg(test)]
377mod tests {
378    use super::primary_plane_for_crtc;
379    use super::{rank, rank_pick};
380    use drm::control::connector::Interface;
381    use drmkit_planes::{PlaneCapabilities, PlaneRegistry, PlaneType};
382
383    fn plane(id: u32, plane_type: PlaneType, crtcs: u32) -> PlaneCapabilities {
384        PlaneCapabilities {
385            id,
386            plane_type,
387            possible_crtcs: crtcs,
388            formats: vec![drmkit_fmt::fourcc::ARGB8888],
389            ..PlaneCapabilities::default()
390        }
391    }
392
393    fn registry() -> PlaneRegistry {
394        PlaneRegistry::from_capabilities(vec![
395            plane(10, PlaneType::Overlay, 0b01),
396            plane(11, PlaneType::Primary, 0b01),
397            plane(12, PlaneType::Cursor, 0b01),
398            plane(13, PlaneType::Primary, 0b10),
399        ])
400    }
401
402    #[test]
403    fn each_crtc_gets_its_own_primary() {
404        let registry = registry();
405        assert_eq!(
406            primary_plane_for_crtc(&registry, 0).map(|plane| plane.id),
407            Some(11)
408        );
409        assert_eq!(
410            primary_plane_for_crtc(&registry, 1).map(|plane| plane.id),
411            Some(13)
412        );
413    }
414
415    #[test]
416    fn an_overlay_is_never_mistaken_for_a_primary() {
417        // Plane 10 comes first and can feed CRTC 0. Taking the first
418        // compatible plane rather than the first *primary* would put the
419        // backing store on an overlay, which on hardware that cannot reorder
420        // planes puts it above the layers it belongs behind.
421        let registry = registry();
422        assert_ne!(
423            primary_plane_for_crtc(&registry, 0).map(|plane| plane.id),
424            Some(10)
425        );
426    }
427
428    #[test]
429    fn a_crtc_with_nothing_wired_to_it_has_no_primary() {
430        assert!(primary_plane_for_crtc(&registry(), 2).is_none());
431    }
432
433    #[test]
434    fn a_registry_with_no_primary_at_all_yields_none() {
435        // What a client that never set universal planes sees: the kernel
436        // hands out overlays only, and every CRTC looks unusable.
437        let registry = PlaneRegistry::from_capabilities(vec![
438            plane(10, PlaneType::Overlay, 0b11),
439            plane(11, PlaneType::Overlay, 0b11),
440        ]);
441        assert!(primary_plane_for_crtc(&registry, 0).is_none());
442        assert!(primary_plane_for_crtc(&registry, 1).is_none());
443    }
444
445    /// The docked-laptop case: HDMI enumerated first, eDP second.
446    #[test]
447    fn an_internal_panel_wins_under_the_main_rank() {
448        let candidates = [Interface::HDMIA, Interface::EmbeddedDisplayPort];
449        assert_eq!(rank_pick(&candidates, rank::MAIN), Some(1));
450    }
451
452    #[test]
453    fn cable_out_is_taken_when_there_is_no_panel() {
454        let candidates = [Interface::VGA, Interface::HDMIA];
455        assert_eq!(rank_pick(&candidates, rank::MAIN), Some(1), "HDMI over VGA");
456    }
457
458    #[test]
459    fn the_internal_rank_ignores_external_connectors() {
460        let candidates = [Interface::HDMIA, Interface::DSI, Interface::DisplayPort];
461        assert_eq!(rank_pick(&candidates, rank::INTERNAL), Some(1));
462    }
463
464    #[test]
465    fn the_internal_rank_finds_nothing_when_only_cables_are_attached() {
466        // A real answer, not a failure: an instrument cluster that must not
467        // grab the external monitor would rather have nothing.
468        let candidates = [Interface::HDMIA, Interface::DisplayPort];
469        assert_eq!(rank_pick(&candidates, rank::INTERNAL), None);
470    }
471
472    #[test]
473    fn the_external_rank_ignores_embedded_panels() {
474        let candidates = [Interface::EmbeddedDisplayPort, Interface::HDMIA];
475        assert_eq!(rank_pick(&candidates, rank::EXTERNAL), Some(1));
476    }
477
478    #[test]
479    fn a_tie_goes_to_the_earlier_connector() {
480        // Two HDMI outputs. The kernel's enumeration order is the tiebreak,
481        // so the same machine picks the same display every boot.
482        let candidates = [Interface::HDMIA, Interface::HDMIA];
483        assert_eq!(rank_pick(&candidates, rank::MAIN), Some(0));
484    }
485
486    #[test]
487    fn nothing_to_choose_from_chooses_nothing() {
488        assert_eq!(rank_pick(&[], rank::MAIN), None);
489        assert_eq!(rank_pick(&[Interface::HDMIA], &[]), None);
490    }
491
492    #[test]
493    fn a_connector_kind_the_rank_does_not_name_is_skipped() {
494        let candidates = [Interface::Composite, Interface::TV];
495        assert_eq!(rank_pick(&candidates, rank::MAIN), None);
496    }
497
498    #[test]
499    fn a_caller_can_impose_its_own_order() {
500        let candidates = [Interface::HDMIA, Interface::EmbeddedDisplayPort];
501        let ranks = [Interface::HDMIA, Interface::EmbeddedDisplayPort];
502        assert_eq!(rank_pick(&candidates, &ranks), Some(0));
503    }
504
505    #[test]
506    fn a_virtual_display_is_still_a_display() {
507        // vkms exposes exactly one connector and it is Virtual, as does
508        // virtio-gpu in a VM. The reference's rank arrays name none of the
509        // three, so every one of its examples reports no display on both --
510        // see drm-cxx#253.
511        assert_eq!(rank_pick(&[Interface::Virtual], rank::MAIN), Some(0));
512        assert_eq!(rank_pick(&[Interface::Virtual], rank::EXTERNAL), Some(0));
513    }
514
515    #[test]
516    fn a_real_panel_still_outranks_a_virtual_one() {
517        // Virtual sits last, so a VM that also has a physical passthrough
518        // panel does not land on the virtual one.
519        let candidates = [Interface::Virtual, Interface::EmbeddedDisplayPort];
520        assert_eq!(rank_pick(&candidates, rank::MAIN), Some(1));
521    }
522}