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}