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(®istry, 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(®istry, 0).map(|plane| plane.id),
407 Some(11)
408 );
409 assert_eq!(
410 primary_plane_for_crtc(®istry, 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(®istry, 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(®istry(), 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(®istry, 0).is_none());
442 assert!(primary_plane_for_crtc(®istry, 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}