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, on a plane without one, by the bare format list as
268 /// linear only, as the reference's does: such a plane says nothing about
269 /// any other layout. The RK3566's Cluster windows are why the list alone
270 /// is not enough: they list `ARGB8888` and take it only as AFBC, and
271 /// refuse a linear buffer at check time (P-48).
272 #[must_use]
273 pub fn scans_out(&self, fourcc: u32, modifier: Modifier) -> bool {
274 if self.has_format_modifiers {
275 drmkit_fmt::layer_fits_plane(&self.format_table, fourcc, modifier)
276 } else {
277 (modifier.is_linear() || modifier.is_invalid()) && self.supports_format(fourcc)
278 }
279 }
280
281 /// Whether the plane scans out a linear buffer of `fourcc`: what a dumb
282 /// buffer is.
283 #[must_use]
284 pub fn scans_out_linear(&self, fourcc: u32) -> bool {
285 self.scans_out(fourcc, Modifier::LINEAR)
286 }
287
288 /// Whether this plane can feed the CRTC at `crtc_index`.
289 #[must_use]
290 pub const fn compatible_with_crtc(&self, crtc_index: u32) -> bool {
291 if crtc_index >= u32::BITS {
292 return false;
293 }
294 self.possible_crtcs & (1 << crtc_index) != 0
295 }
296
297 /// The precomputed bandwidth class for a modifier.
298 ///
299 /// Falls back to a live `classify` for a modifier outside the advertised
300 /// set — decoding it rather than guessing.
301 #[must_use]
302 pub fn bandwidth_class(&self, modifier: u64) -> BandwidthClass {
303 self.modifier_classes
304 .binary_search_by_key(&modifier, |(m, _)| *m)
305 .map_or_else(
306 |_| classify(Modifier(modifier)),
307 |index| self.modifier_classes[index].1,
308 )
309 }
310
311 /// Populate the format table and bandwidth-class index.
312 ///
313 /// Called once per plane at registry construction, and safe to re-run. A
314 /// plane exposing no `IN_FORMATS` gets `LINEAR` synthesized for each
315 /// advertised format, so the allocator can still place linear buffers on it.
316 pub fn build_format_metadata(&mut self) {
317 if self.format_table.is_empty() && !self.formats.is_empty() {
318 self.format_table = FormatTable::from_pairs(
319 self.formats
320 .iter()
321 .map(|&fourcc| (fourcc, Modifier::LINEAR)),
322 );
323 }
324
325 self.modifier_classes.clear();
326 self.modifier_classes.extend(
327 self.format_table
328 .all()
329 .iter()
330 .map(|fm| (fm.modifier.0, classify(fm.modifier))),
331 );
332 self.modifier_classes.sort_unstable_by_key(|(m, _)| *m);
333 self.modifier_classes.dedup_by_key(|(m, _)| *m);
334 }
335
336 /// How many distinct modifiers have a precomputed class.
337 #[must_use]
338 pub fn distinct_modifier_count(&self) -> usize {
339 self.modifier_classes.len()
340 }
341}
342
343/// Every display plane the device exposes.
344///
345/// # Deviation: no interior-mutability cache
346///
347/// The C++ memoizes `for_crtc` in a `mutable` map of pointers into its own
348/// vector, and deletes the copy constructor so those pointers cannot dangle.
349/// Here [`for_crtc`](Self::for_crtc) returns a borrowed iterator computed on
350/// demand: the filter is a bitmask test over a handful of planes, so the cache
351/// bought little, and dropping it removes both the interior mutability and the
352/// reason copying had to be forbidden.
353#[derive(Debug, Clone, Default)]
354pub struct PlaneRegistry {
355 planes: Vec<PlaneCapabilities>,
356 /// How many planes each CRTC can stack, by CRTC index, where the driver
357 /// says. See [`blend_stage_limit`](Self::blend_stage_limit).
358 stage_limits: Vec<Option<usize>>,
359}
360
361impl PlaneRegistry {
362 /// An empty registry.
363 #[must_use]
364 pub const fn new() -> Self {
365 Self {
366 planes: Vec::new(),
367 stage_limits: Vec::new(),
368 }
369 }
370
371 /// Build a registry from a caller-supplied capability list.
372 ///
373 /// The linchpin for testing: it lets the whole allocator run against
374 /// synthetic fixtures with no device, which is how the ported allocator
375 /// suite reaches its coverage. Also used by replay and snapshot tools that
376 /// hold a serialized capability set.
377 ///
378 /// The result behaves identically to an enumerated registry — the format
379 /// table and bandwidth-class metadata are derived the same way.
380 #[must_use]
381 pub fn from_capabilities(mut capabilities: Vec<PlaneCapabilities>) -> Self {
382 for plane in &mut capabilities {
383 plane.build_format_metadata();
384 }
385 Self {
386 planes: capabilities,
387 stage_limits: Vec::new(),
388 }
389 }
390
391 /// How many planes the CRTC at `crtc_index` can stack at once, where the
392 /// driver publishes it; `None` for no limit known.
393 ///
394 /// Not the plane count, and not the width of the advertised zpos range:
395 /// the SA8155P offers more planes than it has blend stages, and
396 /// advertises zpos up to 10 while only 0 to 8 are usable. The allocator
397 /// never asks the kernel about more armed planes than this. Read by
398 /// `probe` from the CRTC's `capabilities` blob
399 /// ([`parse_blend_stage_limit`](crate::parse_blend_stage_limit)).
400 #[must_use]
401 pub fn blend_stage_limit(&self, crtc_index: u32) -> Option<usize> {
402 self.stage_limits
403 .get(usize::try_from(crtc_index).ok()?)
404 .copied()
405 .flatten()
406 }
407
408 /// Set [`blend_stage_limit`](Self::blend_stage_limit) for one CRTC, for
409 /// a registry built with
410 /// [`from_capabilities`](Self::from_capabilities).
411 pub fn set_blend_stage_limit(&mut self, crtc_index: u32, limit: Option<usize>) {
412 let Ok(index) = usize::try_from(crtc_index) else {
413 return;
414 };
415 if self.stage_limits.len() <= index {
416 self.stage_limits.resize(index + 1, None);
417 }
418 self.stage_limits[index] = limit;
419 }
420
421 /// Every plane.
422 #[must_use]
423 pub fn all(&self) -> &[PlaneCapabilities] {
424 &self.planes
425 }
426
427 /// Planes compatible with a CRTC.
428 pub fn for_crtc(&self, crtc_index: u32) -> impl Iterator<Item = &PlaneCapabilities> + '_ {
429 self.planes
430 .iter()
431 .filter(move |plane| plane.compatible_with_crtc(crtc_index))
432 }
433
434 /// Every modifier any non-cursor plane on `crtc_index` can scan `fourcc`
435 /// out with, deduplicated and in ascending order.
436 ///
437 /// The union across all of them, not the primary's `IN_FORMATS` alone.
438 /// On a split `SoC` the primary can advertise only a compressed layout while
439 /// the producer exports `LINEAR`: intersecting against the primary would
440 /// come back empty and miss the `LINEAR`-capable overlay the allocator was
441 /// always going to place the layer on. The `TEST_ONLY` commit during
442 /// placement remains the real arbiter of which plane takes it.
443 ///
444 /// Cursor planes are excluded for the same reason they are excluded from
445 /// [`force_disable_candidates`](Self::force_disable_candidates): the cursor
446 /// path owns them, and a scanout layer is never placed there.
447 #[must_use]
448 pub fn candidate_modifiers(&self, crtc_index: u32, fourcc: u32) -> Vec<u64> {
449 let mut modifiers: Vec<u64> = self
450 .for_crtc(crtc_index)
451 .filter(|plane| plane.plane_type != PlaneType::Cursor)
452 .flat_map(|plane| plane.format_table.modifiers_for(fourcc))
453 .map(|modifier| modifier.0)
454 .collect();
455 modifiers.sort_unstable();
456 modifiers.dedup();
457 modifiers
458 }
459
460 /// Planes on `crtc_index` that a commit may force-disable.
461 ///
462 /// [`for_crtc`](Self::for_crtc) minus the cursor planes. A cursor plane is
463 /// owned by the cursor path, not the scene: clearing it here would erase
464 /// the cursor on every frame the scene commits. Upstream carves out the
465 /// same exception in `disable_unused_planes`.
466 pub fn force_disable_candidates(
467 &self,
468 crtc_index: u32,
469 ) -> impl Iterator<Item = &PlaneCapabilities> + '_ {
470 self.for_crtc(crtc_index)
471 .filter(|plane| plane.plane_type != PlaneType::Cursor)
472 }
473
474 /// A plane by its KMS object id.
475 #[must_use]
476 pub fn by_id(&self, plane_id: u32) -> Option<&PlaneCapabilities> {
477 self.planes.iter().find(|plane| plane.id == plane_id)
478 }
479
480 /// How many planes the registry holds.
481 #[must_use]
482 pub fn len(&self) -> usize {
483 self.planes.len()
484 }
485
486 /// Whether the registry holds no planes.
487 #[must_use]
488 pub fn is_empty(&self) -> bool {
489 self.planes.is_empty()
490 }
491}
492
493/// Map a 16-bit layer alpha onto the range a plane advertises.
494///
495/// Layers carry a 16-bit alpha because that is what the DRM documentation
496/// describes, but the range is the driver's to declare: vendor drivers ship
497/// 8-bit alpha advertising `[0, 255]`, and writing `0xFFFF` to one of those
498/// fails the whole atomic commit with `EINVAL` -- which a `TEST_ONLY` reports
499/// only as "this assignment does not fit", so it surfaces as a dropped layer
500/// that mentions nothing about alpha (drm-cxx#265).
501///
502/// Rescaled rather than clamped: alpha is a fraction of full opacity, so half
503/// alpha must become half of `alpha_max`. Clamping would turn every partially
504/// transparent layer opaque on an 8-bit plane, and would agree with this on
505/// exactly one value, full opacity, which is the one value most testing uses.
506/// Rounds to nearest, so full opacity lands exactly on `alpha_max`; the
507/// identity for a plane advertising the full 16 bits; saturates an oversized
508/// input rather than scaling past the maximum.
509#[must_use]
510pub const fn rescale_alpha(value: u64, alpha_max: u64) -> u64 {
511 const FULL: u64 = 0xFFFF;
512 let clamped = if value < FULL { value } else { FULL };
513 if alpha_max >= FULL {
514 return clamped;
515 }
516 (clamped * alpha_max + FULL / 2) / FULL
517}