drmkit_planes/scoring.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Placement scoring and static compatibility.
5//!
6//! Port of the pure decision functions from `src/planes/allocator.cpp`. None of
7//! these touch a device: they are the part of the allocator that can be tested
8//! exhaustively against synthetic fixtures.
9
10use drmkit_fmt::{BandwidthClass, Modifier, layer_fits_plane, scanout_cost_bytes};
11
12use crate::layer::{ContentType, Layer, Rect};
13use crate::prop::PropTag;
14use crate::registry::{PlaneCapabilities, PlaneType};
15
16/// The default worst-case compression ratio the cost model scores with.
17const COMPRESSED_RATIO: f32 = drmkit_fmt::DEFAULT_COMPRESSED_RATIO;
18
19/// Power-aware placement bonus for a buffer's bandwidth class.
20///
21/// Keeps compressed and tiled layers on planes — compositing one costs a GPU
22/// decompress — and lets the cheap `LINEAR` ones composite when planes are
23/// contested. A small tiebreak below the structural scores.
24#[must_use]
25pub const fn bandwidth_class_bonus(class: BandwidthClass) -> i32 {
26 match class {
27 // DCC / AFBC / UBWC: the biggest bandwidth and GPU-decompress saving.
28 BandwidthClass::Compression => 2,
29 // Better DRAM locality, same byte count.
30 BandwidthClass::Tiling => 1,
31 BandwidthClass::Linear => 0,
32 }
33}
34
35/// Bounded tiebreak by a layer's per-frame scanout byte cost.
36///
37/// A layer that moves more bytes is costlier to demote to composition, which
38/// re-reads both the source and the canvas. Bucketed to 0..=3 so it stays below
39/// the structural scores.
40#[must_use]
41pub const fn cost_bias(scanout_bytes: u64) -> i32 {
42 const MB: u64 = 1024 * 1024;
43 if scanout_bytes >= 16 * MB {
44 3 // ~4K RGBA and up
45 } else if scanout_bytes >= 4 * MB {
46 2 // ~1080p RGBA
47 } else if scanout_bytes >= MB {
48 1 // small overlay
49 } else {
50 0
51 }
52}
53
54/// Content-class placement priority.
55#[must_use]
56pub fn layer_priority(layer: &Layer) -> i32 {
57 if layer.content_type() == ContentType::Video {
58 return 100;
59 }
60 if layer.update_hz() > 30 {
61 return 80;
62 }
63 if layer.update_hz() > 0 {
64 return 50;
65 }
66 10
67}
68
69/// Keep-or-drop ordering under plane pressure.
70///
71/// Content class dominates; the application's own priority only breaks ties
72/// within a class. `layer_priority` is scaled above the `u8` range so a higher
73/// content class always outranks any `app_priority`.
74#[must_use]
75pub fn keep_priority(layer: &Layer) -> i32 {
76 layer_priority(layer) * 256 + i32::from(layer.app_priority())
77}
78
79/// The bonus for keeping a layer on the plane it held last frame.
80///
81/// Deliberately larger than the sum of every structural term below it, so that
82/// **among equal-cardinality matchings** the previous assignment wins.
83///
84/// This is the v2.0.1 warm-start stability bonus (plan §4.6). It is safe
85/// precisely because the matcher maximizes cardinality first and uses score
86/// only to order tie-breaks: it can never place fewer layers, and never
87/// overrides validity — [`plane_statically_compatible`] gates the edges and the
88/// `TEST_ONLY` commit is the arbiter.
89///
90/// Without it, any change to the layer *set* — a compositor inserting a UI
91/// backing-store slice while the platform-view overlays stay put — drops
92/// warm-start, makes the full search re-solve from scratch, and reshuffles
93/// already-placed layers onto different planes. Every overlay reprograms in one
94/// commit, which is seen as flicker.
95pub const WARM_STABILITY_BONUS: i32 = 100;
96
97/// Everything `score_pair` needs that is not the plane or the layer.
98#[derive(Debug, Clone, Copy, Default)]
99#[allow(
100 clippy::exhaustive_structs,
101 reason = "built by callers with `..Default::default()`, which a new field does not break"
102)]
103pub struct ScoreContext {
104 /// Whether the layer held this plane last frame.
105 pub held_last_frame: bool,
106 /// How many times this `(plane, property_hash)` pair has failed a test
107 /// commit. Subtracted from the score.
108 pub failure_hits: u32,
109}
110
111/// Score a candidate placement. Higher is more preferred.
112///
113/// Score orders the search only — it can never change how many layers get
114/// placed. See [`WARM_STABILITY_BONUS`].
115#[must_use]
116pub fn score_pair(plane: &PlaneCapabilities, layer: &Layer, context: ScoreContext) -> i32 {
117 let mut score = 0;
118
119 // A plane advertising the layer's exact (format, modifier) is strictly
120 // preferable to one that supports the format only via linear fallback.
121 if let Some(fourcc) = layer.format()
122 && layer_fits_plane(&plane.format_table, fourcc, Modifier(layer.modifier()))
123 {
124 score += 4;
125 }
126
127 let is_composition = layer.is_composition_layer();
128
129 // A composition layer strongly prefers the primary plane.
130 if is_composition && plane.plane_type == PlaneType::Primary {
131 score += 8;
132 }
133
134 let zpos = layer.property(PropTag::Zpos);
135
136 // A non-composition layer whose zpos matches primary's minimum slot is
137 // explicitly asking for direct scanout on primary -- the bottommost
138 // toolkit layer, with zpos pinned to primary.zpos_min. Without this the
139 // overlay bonus below wins, primary stays framebuffer-less, and whatever
140 // was on it before (typically the fbcon console) stays on scanout.
141 if !is_composition
142 && plane.plane_type == PlaneType::Primary
143 && let Some(z) = zpos
144 {
145 match plane.zpos_min {
146 Some(min) if z == min => score += 10,
147 // Platforms with no kernel-side zpos property (Tegra Orin display
148 // among others) cannot honor per-plane zpos, so the caller
149 // convention is zpos 0 == bottom-most == primary.
150 None if z == 0 => score += 10,
151 _ => {}
152 }
153 }
154
155 // Non-composition layers otherwise prefer overlays.
156 if !is_composition && plane.plane_type == PlaneType::Overlay {
157 score += 2;
158 // Pairs with the zpos-0-means-primary convention above: with no kernel
159 // zpos and a positive request, overlay-as-natural-top applies, so bump
160 // enough to beat primary's bare base score.
161 if let Some(z) = zpos
162 && plane.zpos_min.is_none()
163 && z > 0
164 {
165 score += 8;
166 }
167 }
168
169 // Bandwidth-aware bias: two nudges toward keeping the costliest layers on
170 // planes when planes are contested. The class comes from the scanout
171 // framebuffer's own modifier -- never a producer-internal compression
172 // state, which would double-count a saving on a bus this allocator does
173 // not manage.
174 if let Some(fourcc) = layer.format() {
175 let class = plane.bandwidth_class(layer.modifier());
176 score += bandwidth_class_bonus(class);
177 score += cost_bias(scanout_cost_bytes(
178 layer.width(),
179 layer.height(),
180 fourcc,
181 class,
182 COMPRESSED_RATIO,
183 ));
184 }
185
186 score += layer_priority(layer) / 10;
187
188 if context.held_last_frame {
189 score += WARM_STABILITY_BONUS;
190 }
191
192 // Penalize combinations a previous test commit rejected.
193 score -= i32::try_from(context.failure_hits).unwrap_or(i32::MAX);
194
195 score
196}
197
198/// Whether a plane could possibly scan out a layer.
199///
200/// **Necessary conditions only.** Passing does not mean the commit will
201/// succeed — the atomic `TEST_ONLY` remains the arbiter. Failing means there is
202/// no point asking.
203#[must_use]
204pub fn plane_statically_compatible(
205 plane: &PlaneCapabilities,
206 layer: &Layer,
207 crtc_index: u32,
208) -> bool {
209 // A force-composited layer is incompatible with every plane. The caller --
210 // a test rig, a debug overlay, an EGL Streams workaround -- has asked for
211 // the composition fallback, and short-circuiting here propagates that
212 // through every downstream path without each re-checking the flag.
213 if layer.is_force_composited() || layer.is_transient_composited() {
214 return false;
215 }
216 if !plane.compatible_with_crtc(crtc_index) {
217 return false;
218 }
219
220 let Some(fourcc) = layer.format() else {
221 // No format means no buffer this commit -- typically an EAGAIN skip
222 // where the layer was never lowered. The permissive reading ("no
223 // format fits anywhere") would phantom-place it: counted as assigned,
224 // but with an empty property map nothing reaches the kernel. The
225 // frame would then report the layer as both assigned and skipped,
226 // breaking the commit report's accounting.
227 return false;
228 };
229
230 // Honor the modifier: AFBC, DCC, and vendor tilings only land on planes
231 // whose IN_FORMATS advertises the pair. An untagged layer falls through
232 // the LINEAR/INVALID equivalence inside `layer_fits_plane`.
233 if !layer_fits_plane(&plane.format_table, fourcc, Modifier(layer.modifier())) {
234 return false;
235 }
236
237 // The plane must expose every requested rotate/reflect bit, not merely
238 // have a rotation property: a plane can advertise rotation and implement
239 // only 0/180 or reflect, silently dropping a 90/270 request.
240 let rotation = layer.rotation();
241 if rotation != 0 && plane.rotation_bits & rotation != rotation {
242 return false;
243 }
244
245 if layer.requires_scaling() && !plane.supports_scaling {
246 return false;
247 }
248
249 // No zpos range check: the requested value is not what gets written. The
250 // scene writes `stacked_zpos`'s dense numbering, which fits each plane's
251 // range, so a layer asking zpos 3 can sit on a `[0, 1]` plane (tidss on a
252 // BeaglePlay, where the gate kept every layer above 1 off the overlay).
253 // Where no dense numbering fits, the requested values go out and the
254 // TEST decides. Stacking order is checked per assignment instead
255 // (drm-cxx `ad47fa9`, `8bf20e6`).
256
257 if plane.plane_type == PlaneType::Cursor {
258 if plane.cursor_max_w > 0 && layer.width() > plane.cursor_max_w {
259 return false;
260 }
261 if plane.cursor_max_h > 0 && layer.height() > plane.cursor_max_h {
262 return false;
263 }
264 }
265
266 true
267}
268
269/// Whether two destination rectangles overlap.
270///
271/// Computed in `i64` so a rectangle placed near `i32::MAX` cannot wrap into a
272/// false negative.
273#[must_use]
274pub fn rects_intersect(a: Rect, b: Rect) -> bool {
275 let (ax, ay) = (i64::from(a.x), i64::from(a.y));
276 let (bx, by) = (i64::from(b.x), i64::from(b.y));
277 ax + i64::from(a.w) > bx
278 && bx + i64::from(b.w) > ax
279 && ay + i64::from(a.h) > by
280 && by + i64::from(b.h) > ay
281}
282
283/// Whether two layers overlap on the CRTC.
284#[must_use]
285pub fn layers_intersect(a: &Layer, b: &Layer) -> bool {
286 rects_intersect(a.crtc_rect(), b.crtc_rect())
287}
288
289/// Union-find root with path halving.
290///
291/// Every index passed in comes from `0..parent.len()`, so the indexing cannot
292/// go out of range.
293fn union_find_root(parent: &mut [usize], mut x: usize) -> usize {
294 while parent[x] != x {
295 parent[x] = parent[parent[x]]; // path halving
296 x = parent[x];
297 }
298 x
299}
300
301/// Partition layer indices into spatially independent groups.
302///
303/// Layers that do not overlap cannot contend for the same stacking slot, so
304/// each group can be solved on its own — a much smaller search than the whole
305/// scene at once.
306///
307/// The groups draw on one **shared** plane pool, so their order decides which
308/// group gets a contested plane. Highest [`keep_priority`] first, so the rule
309/// that holds inside a group (a video layer outranks a generic one) also holds
310/// between groups; ties in input order. Upstream's order was unspecified and
311/// ignored priority until drm-cxx#236.
312#[must_use]
313pub fn split_independent_groups(layers: &[&Layer]) -> Vec<Vec<usize>> {
314 independent_groups(
315 layers,
316 |a, b| layers_intersect(a, b),
317 |layer| keep_priority(layer),
318 )
319}
320
321/// Split `items` into groups connected by `intersect`, as indices into
322/// `items`, highest `priority` group first.
323///
324/// Port of upstream's `detail::independent_groups`: a group's priority is its
325/// highest member's; ties keep input order, by each group's first member; and
326/// members keep input order. Generic so the ordering can be pinned on plain
327/// values rather than layers.
328pub(crate) fn independent_groups<T, K: Ord>(
329 items: &[T],
330 intersect: impl Fn(&T, &T) -> bool,
331 priority: impl Fn(&T) -> K,
332) -> Vec<Vec<usize>> {
333 let mut parent: Vec<usize> = (0..items.len()).collect();
334 for i in 0..items.len() {
335 for j in (i + 1)..items.len() {
336 if intersect(&items[i], &items[j]) {
337 let (a, b) = (
338 union_find_root(&mut parent, i),
339 union_find_root(&mut parent, j),
340 );
341 // Root is the lowest index, so groups collect in order of
342 // their first member.
343 if a != b {
344 parent[a.max(b)] = a.min(b);
345 }
346 }
347 }
348 }
349
350 let mut groups: Vec<Vec<usize>> = Vec::new();
351 let mut slot = vec![usize::MAX; items.len()];
352 for i in 0..items.len() {
353 let root = union_find_root(&mut parent, i);
354 if slot[root] == usize::MAX {
355 slot[root] = groups.len();
356 groups.push(Vec::new());
357 }
358 groups[slot[root]].push(i);
359 }
360
361 // Stable, so equal priorities keep first-member order; cached, so each
362 // group's priority is computed once rather than on every comparison.
363 groups.sort_by_cached_key(|group| {
364 std::cmp::Reverse(group.iter().map(|&i| priority(&items[i])).max())
365 });
366 groups
367}