drmkit_scene/report.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What one scene commit did.
5//!
6//! Port of `src/scene/commit_report.hpp`.
7
8use drmkit_planes::LayerId;
9
10/// How a layer reached scanout.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
12#[non_exhaustive]
13pub enum Placement {
14 /// On its own hardware plane.
15 AssignedToPlane,
16 /// Composited into the canvas plane.
17 Composited,
18 /// Dropped this frame — no plane available, no way to composite it, or the
19 /// canvas could not be allocated.
20 #[default]
21 Unassigned,
22}
23
24/// One layer's outcome.
25///
26/// The default is the safe one: unassigned, on no plane. A placement nothing
27/// filled in describes a layer that did not reach the screen, which is a
28/// smaller claim than naming a plane it does not have.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct LayerPlacement {
32 /// Which layer.
33 pub layer: LayerId,
34 /// How it reached scanout.
35 pub placement: Placement,
36 /// The plane its content scanned out on, or `None` when unassigned.
37 ///
38 /// For a composited layer this is the **canvas** plane, not a plane of its
39 /// own.
40 pub plane_id: Option<u32>,
41 /// The assigned plane's supported rotate/reflect mask, for an
42 /// [`AssignedToPlane`](Placement::AssignedToPlane) layer; 0 otherwise.
43 ///
44 /// Lets a producer decide whether its requested angle can land on the plane
45 /// it was given or needs pre-rotation.
46 pub plane_rotation_bits: u64,
47}
48
49impl Default for LayerPlacement {
50 /// Unassigned, on no plane, for no layer.
51 ///
52 /// Written by hand rather than derived so `LayerId` does not have to gain
53 /// a `Default` it has no use for elsewhere -- an id is something the scene
54 /// hands out, and one a caller could conjure would be a way to address a
55 /// layer that does not exist.
56 fn default() -> Self {
57 Self {
58 layer: LayerId(0),
59 placement: Placement::Unassigned,
60 plane_id: None,
61 plane_rotation_bits: 0,
62 }
63 }
64}
65
66/// A snapshot of a single commit.
67///
68/// Every field starts at zero, false or empty. `#[non_exhaustive]` because the
69/// C++ has grown counters additively — `layers_skipped_no_frame`,
70/// `fb_delta_fast_path`, the fence counters — and keeping that freedom means a
71/// new counter is not a breaking change.
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73#[non_exhaustive]
74pub struct CommitReport {
75 /// Layers in the scene at commit time.
76 ///
77 /// # Accounting invariant
78 ///
79 /// ```text
80 /// layers_total == layers_assigned
81 /// + layers_composited
82 /// + layers_unassigned
83 /// + layers_skipped_no_frame
84 /// ```
85 ///
86 /// Checked by [`accounting_balances`](Self::accounting_balances). This is
87 /// why a layer with no format is never placed: counting it as both assigned
88 /// and skipped would break this.
89 pub layers_total: usize,
90
91 /// Layers the allocator put directly on a hardware plane.
92 pub layers_assigned: usize,
93
94 /// Layers rescued by composition into the canvas. They do reach hardware —
95 /// via the canvas plane rather than one of their own.
96 pub layers_composited: usize,
97
98 /// Layers neither placed nor composited: dropped this frame.
99 pub layers_unassigned: usize,
100
101 /// Layers whose source returned [`WouldBlock`](crate::SourceError::WouldBlock),
102 /// and which held their previous frame -- or stayed absent, if they had
103 /// none.
104 ///
105 /// Flow control, not failure — a live source with nothing to contribute
106 /// this vblank. Graphable as a per-layer frame-stall signal.
107 pub layers_skipped_no_frame: usize,
108
109 /// Composition buckets emitted. 0 or 1 with a single full-screen canvas.
110 pub composition_buckets: usize,
111
112 /// Layers that asked to be pinned to a plane but could not be.
113 ///
114 /// The pin target was not on this CRTC, did not support the format, or was
115 /// already claimed. **The layer is not dropped** — it falls back to normal
116 /// allocation and is still counted in one of the placement tallies, so this
117 /// does not affect the accounting invariant. Nonzero is a loud signal that
118 /// a caller's deterministic-plane assumption was violated, as opposed to a
119 /// silent drop.
120 pub pin_requests_unhonored: usize,
121
122 /// Properties enqueued on the atomic request — plane, CRTC, and connector
123 /// state together.
124 ///
125 /// A regression signal for property minimization: it should drop
126 /// meaningfully on a commit whose layers did not change.
127 pub properties_written: usize,
128
129 /// Subset of `properties_written` that are `FB_ID` attachments.
130 pub fbs_attached: usize,
131
132 /// Subset of `properties_written`: layers that got an `FB_DAMAGE_CLIPS`
133 /// blob.
134 ///
135 /// Zero on a full-frame commit — no damage reported, over budget,
136 /// degenerate clips, or a plane without the property. Tells a
137 /// bandwidth-sensitive consumer whether partial updates actually reach the
138 /// kernel.
139 pub damaged_layers: usize,
140
141 /// Layers whose acquire fence was handed to the kernel as `IN_FENCE_FD` —
142 /// the real explicit-sync path, with no CPU stall.
143 ///
144 /// The honest signal that explicit sync is live on this driver: a source
145 /// may always produce a fence and the plane still not accept it, which
146 /// shows up in [`in_fence_cpu_waits`](Self::in_fence_cpu_waits) instead.
147 pub in_fences_armed: usize,
148
149 /// Layers that carried a fence the plane could not take, so the scene
150 /// blocked on a CPU wait before committing.
151 ///
152 /// Real commits only; a test never waits. Both counters zero means no
153 /// source produced a fence at all.
154 pub in_fence_cpu_waits: usize,
155
156 /// Internal test commits the allocator issued while searching.
157 ///
158 /// A cold start can spend the whole budget; a warm start is 1; the FB-only
159 /// fast path is 0.
160 pub test_commits_issued: usize,
161
162 /// Whether this commit took the FB-only fast path — only content changed on
163 /// already-placed layers, so the cached assignment was reused and the
164 /// `TEST_ONLY` skipped entirely.
165 ///
166 /// **Invariant 4's observable signal.** Implies
167 /// `test_commits_issued == 0`, which a steady-state consumer asserts to
168 /// confirm it is paying one atomic commit per frame rather than two.
169 pub fb_delta_fast_path: bool,
170
171 /// Names this commit for
172 /// [`LayerScene::release_displaced`](crate::LayerScene::release_displaced):
173 /// real commits that the kernel took count from 1. 0 for a test commit
174 /// and for a rejected one.
175 pub commit_seq: u64,
176
177 /// Whether the per-frame test-commit budget, not the hardware, is what
178 /// stopped more layers reaching planes.
179 ///
180 /// When this is set, some of `layers_composited` is a capacity decision
181 /// rather than a plane shortage: there were compatible planes free and the
182 /// allocator ran out of permitted round trips to the kernel before it
183 /// could offer them. Composited layers still reach the screen, so this is
184 /// not an error -- but a caller tuning a scene needs to tell "this device
185 /// has no more planes" from "raise the budget", and `layers_composited`
186 /// alone cannot. Raise it with `Allocator::set_max_test_commits`.
187 pub budget_exhausted: bool,
188
189 /// Whether the frame was suppressed as idle -- no commit was issued at all.
190 ///
191 /// Set only by a caller that gates on content changing, such as
192 /// `ScanoutBackend::present_if_changed`. **When it is set every other field
193 /// is zero**, because there was no commit to count: no layers were walked,
194 /// no properties written, no placements decided. A consumer reading
195 /// `layers_assigned == 0` without checking this would conclude the frame
196 /// failed to reach a plane, when in fact the previous frame is still on
197 /// screen and correct.
198 ///
199 /// The first frame is never suppressed, whatever the caller says about
200 /// content: nothing is on screen yet, so "unchanged" describes nothing.
201 pub skipped_idle: bool,
202
203 /// Per-layer outcomes.
204 pub placements: Vec<LayerPlacement>,
205}
206
207impl CommitReport {
208 /// The report for a frame that was suppressed as idle.
209 ///
210 /// Every count is zero because there was no commit to count, and
211 /// [`skipped_idle`](Self::skipped_idle) says why. A constructor rather
212 /// than a struct literal because this type is `#[non_exhaustive]`: a
213 /// caller outside this crate cannot build one, and the suppressed frame is
214 /// the one shape it legitimately needs to.
215 #[must_use]
216 pub fn suppressed() -> Self {
217 Self {
218 skipped_idle: true,
219 ..Self::default()
220 }
221 }
222
223 /// What became of one layer this commit.
224 ///
225 /// `None` when the layer was not in the frame at all -- it was never
226 /// added, it was removed, or the handle is stale. A stale one cannot
227 /// match by accident: [`LayerHandle::layer_id`](crate::LayerHandle::layer_id)
228 /// packs the slot's generation, so a recycled slot yields a different id
229 /// and a handle to the layer that used to live there resolves to nothing.
230 #[must_use]
231 pub fn placement_of(&self, layer: impl Into<LayerId>) -> Option<&LayerPlacement> {
232 let id = layer.into();
233 self.placements.iter().find(|entry| entry.layer == id)
234 }
235
236 /// Whether the layer went into the canvas rather than onto a plane of its
237 /// own.
238 ///
239 /// False for a layer that was dropped as well as for one that got a
240 /// plane: composited means it reached the screen the slow way, and
241 /// unassigned means it did not reach the screen at all. A caller
242 /// throttling its repaints on this distinction wants them separated.
243 #[must_use]
244 pub fn was_composited(&self, layer: impl Into<LayerId>) -> bool {
245 self.placement_of(layer)
246 .is_some_and(|entry| entry.placement == Placement::Composited)
247 }
248
249 /// Whether the layer tallies add up to [`layers_total`](Self::layers_total).
250 ///
251 /// The C++ states this as a comment on the field. Making it checkable means
252 /// the scene's own tests can assert it every commit, which is how a layer
253 /// counted twice — or not at all — gets caught at the point it happens.
254 #[must_use]
255 pub const fn accounting_balances(&self) -> bool {
256 self.layers_total
257 == self.layers_assigned
258 + self.layers_composited
259 + self.layers_unassigned
260 + self.layers_skipped_no_frame
261 }
262
263 /// Whether the fast path's implied contract holds: taking it means issuing
264 /// no test commits.
265 #[must_use]
266 pub const fn fast_path_consistent(&self) -> bool {
267 !self.fb_delta_fast_path || self.test_commits_issued == 0
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::{CommitReport, LayerPlacement, Placement};
274 use drmkit_planes::LayerId;
275
276 /// A report with one layer of each outcome.
277 fn report() -> CommitReport {
278 CommitReport {
279 placements: vec![
280 LayerPlacement {
281 layer: LayerId(1),
282 placement: Placement::AssignedToPlane,
283 plane_id: Some(31),
284 plane_rotation_bits: 0,
285 },
286 LayerPlacement {
287 layer: LayerId(2),
288 placement: Placement::Composited,
289 plane_id: Some(42),
290 plane_rotation_bits: 0,
291 },
292 LayerPlacement {
293 layer: LayerId(3),
294 placement: Placement::Unassigned,
295 plane_id: None,
296 plane_rotation_bits: 0,
297 },
298 ],
299 ..CommitReport::default()
300 }
301 }
302
303 #[test]
304 fn each_outcome_is_found_with_the_plane_it_landed_on() {
305 let report = report();
306
307 let assigned = report.placement_of(LayerId(1)).expect("assigned");
308 assert_eq!(assigned.placement, Placement::AssignedToPlane);
309 assert_eq!(assigned.plane_id, Some(31));
310
311 // A composited layer names the *canvas* plane, not one of its own --
312 // a caller reading this as "the layer got plane 42" would think it had
313 // a plane it does not have.
314 let composited = report.placement_of(LayerId(2)).expect("composited");
315 assert_eq!(composited.placement, Placement::Composited);
316 assert_eq!(composited.plane_id, Some(42));
317
318 let dropped = report.placement_of(LayerId(3)).expect("unassigned");
319 assert_eq!(dropped.placement, Placement::Unassigned);
320 assert_eq!(dropped.plane_id, None, "it reached no plane at all");
321 }
322
323 #[test]
324 fn a_layer_that_was_not_in_the_frame_has_no_placement() {
325 assert!(report().placement_of(LayerId(4)).is_none());
326 assert!(CommitReport::default().placement_of(LayerId(1)).is_none());
327 }
328
329 #[test]
330 fn a_recycled_slot_does_not_answer_for_the_layer_that_left_it() {
331 // The identity a handle resolves to packs the slot's generation, so
332 // the layer that took a freed slot has a different id and a stale
333 // handle finds nothing. Without that, a caller holding a handle to a
334 // removed layer would read the new occupant's outcome as its own.
335 let slot_1_generation_1 = LayerId((1 << 32) | 1);
336 let slot_1_generation_2 = LayerId((1 << 32) | 2);
337 assert_ne!(slot_1_generation_1, slot_1_generation_2);
338
339 let report = CommitReport {
340 placements: vec![LayerPlacement {
341 layer: slot_1_generation_2,
342 placement: Placement::AssignedToPlane,
343 plane_id: Some(31),
344 plane_rotation_bits: 0,
345 }],
346 ..CommitReport::default()
347 };
348 assert!(report.placement_of(slot_1_generation_2).is_some());
349 assert!(report.placement_of(slot_1_generation_1).is_none());
350 }
351
352 #[test]
353 fn only_a_composited_layer_was_composited() {
354 let report = report();
355 assert!(!report.was_composited(LayerId(1)), "it got a plane");
356 assert!(report.was_composited(LayerId(2)));
357 // Dropped is not composited: it never reached the screen, and a
358 // caller treating the two alike would stop repainting a layer nobody
359 // can see.
360 assert!(!report.was_composited(LayerId(3)));
361 assert!(!report.was_composited(LayerId(9)), "absent");
362 }
363}