Skip to main content

drmkit_planes/
registry.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What the hardware's display planes can do.
5
6use drmkit_fmt::{BandwidthClass, FormatTable, Modifier, classify};
7
8/// The kernel's plane type.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
10#[non_exhaustive]
11pub enum PlaneType {
12    /// The CRTC's primary plane.
13    Primary,
14    /// An overlay plane.
15    #[default]
16    Overlay,
17    /// The cursor plane.
18    Cursor,
19}
20
21/// Plane `COLOR_ENCODING`: the YCbCr-to-RGB matrix applied when scanning out a
22/// YUV format. Ignored by the kernel for RGB.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24#[allow(
25    clippy::exhaustive_enums,
26    reason = "drmkit-scene resolves it with an exhaustive match, so a new encoding fails to compile until it is mapped"
27)]
28pub enum ColorEncoding {
29    /// BT.601.
30    Bt601,
31    /// BT.709.
32    Bt709,
33    /// BT.2020.
34    Bt2020,
35}
36
37/// Plane `COLOR_RANGE`: whether 8-bit YCbCr is full-range (0..255) or
38/// limited-range (16..235 luma, 16..240 chroma).
39///
40/// Most camera and broadcast YCbCr is limited-range; PC-generated YCbCr is
41/// sometimes full.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[allow(
44    clippy::exhaustive_enums,
45    reason = "a closed set: there is no further variant to add"
46)]
47pub enum ColorRange {
48    /// Limited range.
49    Limited,
50    /// Full range.
51    Full,
52}
53
54/// Driver-assigned enum integers for a property's named values.
55///
56/// The kernel hands back enum values in the order the driver registered them,
57/// so a caller must look up by name and cache the integer — which is what this
58/// holds. `None` means the plane does not advertise that value at all.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60#[allow(
61    clippy::exhaustive_structs,
62    reason = "built by callers with `..Default::default()`, which a new field does not break"
63)]
64pub struct BlendModeValues {
65    /// `"Pre-multiplied"`. Prefer this: it matches the canvas's premultiplied
66    /// output convention.
67    pub premultiplied: Option<u64>,
68    /// `"Coverage"` — straight-alpha `SRC_OVER`.
69    pub coverage: Option<u64>,
70    /// `"None"` — pixel replace, no blending.
71    pub none: Option<u64>,
72}
73
74/// Driver-assigned enum integers for `COLOR_ENCODING`.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76#[allow(
77    clippy::exhaustive_structs,
78    reason = "built by callers with `..Default::default()`, which a new field does not break"
79)]
80pub struct ColorEncodingValues {
81    /// BT.601, if advertised.
82    pub bt601: Option<u64>,
83    /// BT.709, if advertised.
84    pub bt709: Option<u64>,
85    /// BT.2020, if advertised. Older drivers often omit it.
86    pub bt2020: Option<u64>,
87}
88
89/// Driver-assigned enum integers for `COLOR_RANGE`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91#[allow(
92    clippy::exhaustive_structs,
93    reason = "built by callers with `..Default::default()`, which a new field does not break"
94)]
95pub struct ColorRangeValues {
96    /// Limited range, if advertised.
97    pub limited: Option<u64>,
98    /// Full range, if advertised.
99    pub full: Option<u64>,
100}
101
102/// Per-plane color pipeline support (pre-blend CMS).
103///
104/// When all three are present the plane can apply per-layer EOTF inversion,
105/// gamut conversion, and an SDR-luminance multiplier *before* the kernel blends
106/// in linear light — the only correct way to mix HDR and SDR layers on one
107/// CRTC. Hardware without them (RDNA2, vkms, older i915) leaves them false and
108/// the scene falls back to the CRTC pipeline or to pre-composition.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110#[allow(
111    clippy::exhaustive_structs,
112    reason = "built by callers with `..Default::default()`, which a new field does not break"
113)]
114pub struct ColorPipeline {
115    /// `PLANE_DEGAMMA_LUT` is present.
116    pub has_degamma_lut: bool,
117    /// `PLANE_CTM` is present.
118    pub has_ctm: bool,
119    /// `PLANE_HDR_MULT` is present — a 16.16 fixed-point scalar applied to
120    /// post-degamma linear values, used to lift SDR luminance into an HDR
121    /// blend space.
122    pub has_hdr_mult: bool,
123    /// How many `drm_color_lut` entries `PLANE_DEGAMMA_LUT` expects. Zero when
124    /// the property is absent.
125    pub degamma_lut_size: u32,
126}
127
128/// Everything the allocator needs to know about one plane.
129///
130/// The `has_*` flags mirror the kernel's own property presence one-for-one, so
131/// they stay independent booleans rather than a bitfield: a caller building a
132/// fixture writes exactly the properties the hardware it is modeling exposes,
133/// and packing them would make that unreadable.
134#[expect(
135    clippy::struct_excessive_bools,
136    reason = "one flag per kernel property; see the type documentation"
137)]
138#[derive(Debug, Clone, Default)]
139#[allow(
140    clippy::exhaustive_structs,
141    reason = "built by callers with `..Default::default()`, which a new field does not break"
142)]
143pub struct PlaneCapabilities {
144    /// The plane's KMS object id.
145    pub id: u32,
146    /// Bitmask of CRTC indices this plane can feed.
147    pub possible_crtcs: u32,
148    /// Primary, overlay, or cursor.
149    pub plane_type: PlaneType,
150    /// Advertised `FourCC`s, from the bare `formats` list.
151    pub formats: Vec<u32>,
152    /// The queryable view of `IN_FORMATS`.
153    ///
154    /// Built from the blob at enumeration. When the driver exposes no
155    /// `IN_FORMATS`, [`build_format_metadata`](Self::build_format_metadata)
156    /// synthesizes `LINEAR` for each advertised format. The allocator's edge
157    /// predicate queries this through `drmkit_fmt::layer_fits_plane`.
158    pub format_table: FormatTable,
159    /// Minimum `zpos`, if the plane exposes the property.
160    pub zpos_min: Option<u64>,
161    /// Maximum `zpos`, if the plane exposes the property.
162    pub zpos_max: Option<u64>,
163    /// For a multirect "virtual" plane -- the second rectangle of a hardware
164    /// pipe, which some controllers publish as its own plane -- the id of the
165    /// parent plane it is only valid alongside. `None` for ordinary planes.
166    /// Read from the driver's read-only `capabilities` blob
167    /// (`primary_smart_plane_id=`); see [`parse_multirect_parent`](crate::parse_multirect_parent).
168    pub multirect_parent: Option<u32>,
169    /// Supported rotate/reflect angles as a `DRM_MODE_ROTATE_*` /
170    /// `DRM_MODE_REFLECT_*` mask; 0 when the plane exposes no rotation.
171    ///
172    /// A plane can have [`supports_rotation`](Self::supports_rotation) true and
173    /// still omit 90/270 here — 0/180- or reflect-only hardware — which is why
174    /// the allocator gates on these bits rather than the flag. Not
175    /// hypothetical: measured on a Raspberry Pi 5, every one of vc4's 56
176    /// planes reports `0x35` — 0, 180, and both reflects, with no 90 or 270.
177    /// vkms reports `0x3f` and amdgpu `0xf` on the same probe.
178    pub rotation_bits: u64,
179    /// Whether the plane exposes a rotation property at all.
180    pub supports_rotation: bool,
181    /// Whether the plane may be asked to scale.
182    ///
183    /// Not a measured capability, and the name is the most optimistic reading
184    /// of what KMS offers. `PlaneRegistry::probe` sets it from the presence
185    /// of `SRC_W`, which every atomic plane has -- so on a real device it is
186    /// true everywhere, vkms and its absent scaler included. There is no
187    /// property that answers the question; the `TEST_ONLY` commit is what
188    /// finds out, and the allocator's search is built around that.
189    ///
190    /// It stays because a fixture, a replay, or a caller that knows its
191    /// hardware can set it false and have the matcher skip those planes
192    /// before spending a test commit on them. See
193    /// [drm-cxx#252](https://github.com/jwinarske/drm-cxx/issues/252).
194    pub supports_scaling: bool,
195    /// Whether the driver exposed `IN_FORMATS`.
196    pub has_format_modifiers: bool,
197    /// Whether the plane exposes `"pixel blend mode"`.
198    ///
199    /// Absence means scanout blending is hardwired by the driver — typically
200    /// opaque pixel-replace, so a transparent source pixel paints opaque black
201    /// over whatever is beneath it.
202    pub has_pixel_blend_mode: bool,
203    /// Whether the plane exposes `FB_DAMAGE_CLIPS`.
204    ///
205    /// Per plane, not per device, because it is not all-or-nothing: amdgpu
206    /// attaches it to six of its ten planes. A scene that reports partial
207    /// damage on a plane without it gets full-frame repaints there and
208    /// partial ones elsewhere, which is correct but worth being able to see.
209    ///
210    /// Narrower than it looks. `drm_plane_enable_fb_damage_clips` is opt-in
211    /// per driver: amdgpu calls it, vkms and vc4 do not.
212    pub has_fb_damage_clips: bool,
213
214    /// Whether the plane exposes the `"alpha"` per-plane alpha property.
215    /// Independent of [`has_pixel_blend_mode`](Self::has_pixel_blend_mode) —
216    /// some hardware has one without the other.
217    pub has_per_plane_alpha: bool,
218    /// Cached blend-mode enum integers.
219    pub blend_modes: BlendModeValues,
220    /// Whether the plane exposes `COLOR_ENCODING`. Only YCbCr-capable planes
221    /// do; the scene skips the write for the rest.
222    pub has_color_encoding: bool,
223    /// Cached `COLOR_ENCODING` enum integers.
224    pub color_encodings: ColorEncodingValues,
225    /// Whether the plane exposes `COLOR_RANGE`.
226    pub has_color_range: bool,
227    /// Cached `COLOR_RANGE` enum integers.
228    pub color_ranges: ColorRangeValues,
229    /// Per-plane color pipeline support.
230    pub color_pipeline: ColorPipeline,
231    /// The device's `DRM_CAP_CURSOR_WIDTH`, on a cursor plane. Zero on any
232    /// other plane, and where the driver reports none; zero leaves the
233    /// allocator's cursor-size guard off.
234    pub cursor_max_w: u32,
235    /// The device's `DRM_CAP_CURSOR_HEIGHT`, on a cursor plane, as
236    /// [`cursor_max_w`](Self::cursor_max_w).
237    pub cursor_max_h: u32,
238    /// One bandwidth class per distinct advertised modifier, sorted by
239    /// modifier, so the allocator hot path never re-decodes modifier bits.
240    ///
241    /// Filled by [`build_format_metadata`](Self::build_format_metadata); query
242    /// through [`bandwidth_class`](Self::bandwidth_class).
243    ///
244    /// Public because a caller building a fixture constructs
245    /// `PlaneCapabilities` with struct-update syntax, which a private field
246    /// would block — the same reason the C++ leaves it a public member. It is
247    /// derived state: leave it empty and let
248    /// [`PlaneRegistry::from_capabilities`] fill it.
249    pub modifier_classes: Vec<(u64, BandwidthClass)>,
250}
251
252impl PlaneCapabilities {
253    /// Whether the bare `formats` list advertises a `FourCC`.
254    ///
255    /// This is the pre-`IN_FORMATS` check and ignores modifiers entirely, so
256    /// it does not say whether a given buffer will scan out: a plane can list
257    /// a format it takes only compressed. Ask [`scans_out`](Self::scans_out)
258    /// that.
259    #[must_use]
260    pub fn supports_format(&self, fourcc: u32) -> bool {
261        self.formats.contains(&fourcc)
262    }
263
264    /// Whether the plane scans out `fourcc` in the `modifier` layout.
265    ///
266    /// By the plane's `IN_FORMATS` table, with `layer_fits_plane`'s
267    /// equivalences, or by the bare format list on a plane without one. The
268    /// RK3566's Cluster windows are why the list alone is not enough: they
269    /// list `ARGB8888` and take it only as AFBC, and refuse a linear buffer at
270    /// check time (P-48).
271    #[must_use]
272    pub fn scans_out(&self, fourcc: u32, modifier: Modifier) -> bool {
273        if self.format_table.is_empty() {
274            self.supports_format(fourcc)
275        } else {
276            drmkit_fmt::layer_fits_plane(&self.format_table, fourcc, modifier)
277        }
278    }
279
280    /// Whether the plane scans out a linear buffer of `fourcc`: what a dumb
281    /// buffer is.
282    #[must_use]
283    pub fn scans_out_linear(&self, fourcc: u32) -> bool {
284        self.scans_out(fourcc, Modifier::LINEAR)
285    }
286
287    /// Whether this plane can feed the CRTC at `crtc_index`.
288    #[must_use]
289    pub const fn compatible_with_crtc(&self, crtc_index: u32) -> bool {
290        if crtc_index >= u32::BITS {
291            return false;
292        }
293        self.possible_crtcs & (1 << crtc_index) != 0
294    }
295
296    /// The precomputed bandwidth class for a modifier.
297    ///
298    /// Falls back to a live `classify` for a modifier outside the advertised
299    /// set — decoding it rather than guessing.
300    #[must_use]
301    pub fn bandwidth_class(&self, modifier: u64) -> BandwidthClass {
302        self.modifier_classes
303            .binary_search_by_key(&modifier, |(m, _)| *m)
304            .map_or_else(
305                |_| classify(Modifier(modifier)),
306                |index| self.modifier_classes[index].1,
307            )
308    }
309
310    /// Populate the format table and bandwidth-class index.
311    ///
312    /// Called once per plane at registry construction, and safe to re-run. A
313    /// plane exposing no `IN_FORMATS` gets `LINEAR` synthesized for each
314    /// advertised format, so the allocator can still place linear buffers on it.
315    pub fn build_format_metadata(&mut self) {
316        if self.format_table.is_empty() && !self.formats.is_empty() {
317            self.format_table = FormatTable::from_pairs(
318                self.formats
319                    .iter()
320                    .map(|&fourcc| (fourcc, Modifier::LINEAR)),
321            );
322        }
323
324        self.modifier_classes.clear();
325        self.modifier_classes.extend(
326            self.format_table
327                .all()
328                .iter()
329                .map(|fm| (fm.modifier.0, classify(fm.modifier))),
330        );
331        self.modifier_classes.sort_unstable_by_key(|(m, _)| *m);
332        self.modifier_classes.dedup_by_key(|(m, _)| *m);
333    }
334
335    /// How many distinct modifiers have a precomputed class.
336    #[must_use]
337    pub fn distinct_modifier_count(&self) -> usize {
338        self.modifier_classes.len()
339    }
340}
341
342/// Every display plane the device exposes.
343///
344/// # Deviation: no interior-mutability cache
345///
346/// The C++ memoizes `for_crtc` in a `mutable` map of pointers into its own
347/// vector, and deletes the copy constructor so those pointers cannot dangle.
348/// Here [`for_crtc`](Self::for_crtc) returns a borrowed iterator computed on
349/// demand: the filter is a bitmask test over a handful of planes, so the cache
350/// bought little, and dropping it removes both the interior mutability and the
351/// reason copying had to be forbidden.
352#[derive(Debug, Clone, Default)]
353pub struct PlaneRegistry {
354    planes: Vec<PlaneCapabilities>,
355    /// How many planes each CRTC can stack, by CRTC index, where the driver
356    /// says. See [`blend_stage_limit`](Self::blend_stage_limit).
357    stage_limits: Vec<Option<usize>>,
358}
359
360impl PlaneRegistry {
361    /// An empty registry.
362    #[must_use]
363    pub const fn new() -> Self {
364        Self {
365            planes: Vec::new(),
366            stage_limits: Vec::new(),
367        }
368    }
369
370    /// Build a registry from a caller-supplied capability list.
371    ///
372    /// The linchpin for testing: it lets the whole allocator run against
373    /// synthetic fixtures with no device, which is how the ported allocator
374    /// suite reaches its coverage. Also used by replay and snapshot tools that
375    /// hold a serialized capability set.
376    ///
377    /// The result behaves identically to an enumerated registry — the format
378    /// table and bandwidth-class metadata are derived the same way.
379    #[must_use]
380    pub fn from_capabilities(mut capabilities: Vec<PlaneCapabilities>) -> Self {
381        for plane in &mut capabilities {
382            plane.build_format_metadata();
383        }
384        Self {
385            planes: capabilities,
386            stage_limits: Vec::new(),
387        }
388    }
389
390    /// How many planes the CRTC at `crtc_index` can stack at once, where the
391    /// driver publishes it; `None` for no limit known.
392    ///
393    /// Not the plane count, and not the width of the advertised zpos range:
394    /// the SA8155P offers more planes than it has blend stages, and
395    /// advertises zpos up to 10 while only 0 to 8 are usable. The allocator
396    /// never asks the kernel about more armed planes than this. Read by
397    /// `probe` from the CRTC's `capabilities` blob
398    /// ([`parse_blend_stage_limit`](crate::parse_blend_stage_limit)).
399    #[must_use]
400    pub fn blend_stage_limit(&self, crtc_index: u32) -> Option<usize> {
401        self.stage_limits
402            .get(usize::try_from(crtc_index).ok()?)
403            .copied()
404            .flatten()
405    }
406
407    /// Set [`blend_stage_limit`](Self::blend_stage_limit) for one CRTC, for
408    /// a registry built with
409    /// [`from_capabilities`](Self::from_capabilities).
410    pub fn set_blend_stage_limit(&mut self, crtc_index: u32, limit: Option<usize>) {
411        let Ok(index) = usize::try_from(crtc_index) else {
412            return;
413        };
414        if self.stage_limits.len() <= index {
415            self.stage_limits.resize(index + 1, None);
416        }
417        self.stage_limits[index] = limit;
418    }
419
420    /// Every plane.
421    #[must_use]
422    pub fn all(&self) -> &[PlaneCapabilities] {
423        &self.planes
424    }
425
426    /// Planes compatible with a CRTC.
427    pub fn for_crtc(&self, crtc_index: u32) -> impl Iterator<Item = &PlaneCapabilities> + '_ {
428        self.planes
429            .iter()
430            .filter(move |plane| plane.compatible_with_crtc(crtc_index))
431    }
432
433    /// Every modifier any non-cursor plane on `crtc_index` can scan `fourcc`
434    /// out with, deduplicated and in ascending order.
435    ///
436    /// The union across all of them, not the primary's `IN_FORMATS` alone.
437    /// On a split `SoC` the primary can advertise only a compressed layout while
438    /// the producer exports `LINEAR`: intersecting against the primary would
439    /// come back empty and miss the `LINEAR`-capable overlay the allocator was
440    /// always going to place the layer on. The `TEST_ONLY` commit during
441    /// placement remains the real arbiter of which plane takes it.
442    ///
443    /// Cursor planes are excluded for the same reason they are excluded from
444    /// [`force_disable_candidates`](Self::force_disable_candidates): the cursor
445    /// path owns them, and a scanout layer is never placed there.
446    #[must_use]
447    pub fn candidate_modifiers(&self, crtc_index: u32, fourcc: u32) -> Vec<u64> {
448        let mut modifiers: Vec<u64> = self
449            .for_crtc(crtc_index)
450            .filter(|plane| plane.plane_type != PlaneType::Cursor)
451            .flat_map(|plane| plane.format_table.modifiers_for(fourcc))
452            .map(|modifier| modifier.0)
453            .collect();
454        modifiers.sort_unstable();
455        modifiers.dedup();
456        modifiers
457    }
458
459    /// Planes on `crtc_index` that a commit may force-disable.
460    ///
461    /// [`for_crtc`](Self::for_crtc) minus the cursor planes. A cursor plane is
462    /// owned by the cursor path, not the scene: clearing it here would erase
463    /// the cursor on every frame the scene commits. Upstream carves out the
464    /// same exception in `disable_unused_planes`.
465    pub fn force_disable_candidates(
466        &self,
467        crtc_index: u32,
468    ) -> impl Iterator<Item = &PlaneCapabilities> + '_ {
469        self.for_crtc(crtc_index)
470            .filter(|plane| plane.plane_type != PlaneType::Cursor)
471    }
472
473    /// A plane by its KMS object id.
474    #[must_use]
475    pub fn by_id(&self, plane_id: u32) -> Option<&PlaneCapabilities> {
476        self.planes.iter().find(|plane| plane.id == plane_id)
477    }
478
479    /// How many planes the registry holds.
480    #[must_use]
481    pub fn len(&self) -> usize {
482        self.planes.len()
483    }
484
485    /// Whether the registry holds no planes.
486    #[must_use]
487    pub fn is_empty(&self) -> bool {
488        self.planes.is_empty()
489    }
490}
491
492/// Map a 16-bit layer alpha onto the range a plane advertises.
493///
494/// Layers carry a 16-bit alpha because that is what the DRM documentation
495/// describes, but the range is the driver's to declare: vendor drivers ship
496/// 8-bit alpha advertising `[0, 255]`, and writing `0xFFFF` to one of those
497/// fails the whole atomic commit with `EINVAL` -- which a `TEST_ONLY` reports
498/// only as "this assignment does not fit", so it surfaces as a dropped layer
499/// that mentions nothing about alpha (drm-cxx#265).
500///
501/// Rescaled rather than clamped: alpha is a fraction of full opacity, so half
502/// alpha must become half of `alpha_max`. Clamping would turn every partially
503/// transparent layer opaque on an 8-bit plane, and would agree with this on
504/// exactly one value, full opacity, which is the one value most testing uses.
505/// Rounds to nearest, so full opacity lands exactly on `alpha_max`; the
506/// identity for a plane advertising the full 16 bits; saturates an oversized
507/// input rather than scaling past the maximum.
508#[must_use]
509pub const fn rescale_alpha(value: u64, alpha_max: u64) -> u64 {
510    const FULL: u64 = 0xFFFF;
511    let clamped = if value < FULL { value } else { FULL };
512    if alpha_max >= FULL {
513        return clamped;
514    }
515    (clamped * alpha_max + FULL / 2) / FULL
516}