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 is a count that starts at zero. `#[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 were skipped.
103 ///
104 /// Flow control, not failure — a live source with nothing to contribute
105 /// this vblank. Graphable as a per-layer frame-stall signal.
106 pub layers_skipped_no_frame: usize,
107
108 /// Composition buckets emitted. 0 or 1 with a single full-screen canvas.
109 pub composition_buckets: usize,
110
111 /// Layers that asked to be pinned to a plane but could not be.
112 ///
113 /// The pin target was not on this CRTC, did not support the format, or was
114 /// already claimed. **The layer is not dropped** — it falls back to normal
115 /// allocation and is still counted in one of the placement tallies, so this
116 /// does not affect the accounting invariant. Nonzero is a loud signal that
117 /// a caller's deterministic-plane assumption was violated, as opposed to a
118 /// silent drop.
119 pub pin_requests_unhonored: usize,
120
121 /// Properties enqueued on the atomic request — plane, CRTC, and connector
122 /// state together.
123 ///
124 /// A regression signal for property minimization: it should drop
125 /// meaningfully on a commit whose layers did not change.
126 pub properties_written: usize,
127
128 /// Subset of `properties_written` that are `FB_ID` attachments.
129 pub fbs_attached: usize,
130
131 /// Subset of `properties_written`: layers that got an `FB_DAMAGE_CLIPS`
132 /// blob.
133 ///
134 /// Zero on a full-frame commit — no damage reported, over budget,
135 /// degenerate clips, or a plane without the property. Tells a
136 /// bandwidth-sensitive consumer whether partial updates actually reach the
137 /// kernel.
138 pub damaged_layers: usize,
139
140 /// Layers whose acquire fence was handed to the kernel as `IN_FENCE_FD` —
141 /// the real explicit-sync path, with no CPU stall.
142 ///
143 /// The honest signal that explicit sync is live on this driver: a source
144 /// may always produce a fence and the plane still not accept it, which
145 /// shows up in [`in_fence_cpu_waits`](Self::in_fence_cpu_waits) instead.
146 pub in_fences_armed: usize,
147
148 /// Layers that carried a fence the plane could not take, so the scene
149 /// blocked on a CPU wait before committing.
150 ///
151 /// Real commits only; a test never waits. Both counters zero means no
152 /// source produced a fence at all.
153 pub in_fence_cpu_waits: usize,
154
155 /// Internal test commits the allocator issued while searching.
156 ///
157 /// A cold start can spend the whole budget; a warm start is 1; the FB-only
158 /// fast path is 0.
159 pub test_commits_issued: usize,
160
161 /// Whether this commit took the FB-only fast path — only content changed on
162 /// already-placed layers, so the cached assignment was reused and the
163 /// `TEST_ONLY` skipped entirely.
164 ///
165 /// **Invariant 4's observable signal.** Implies
166 /// `test_commits_issued == 0`, which a steady-state consumer asserts to
167 /// confirm it is paying one atomic commit per frame rather than two.
168 pub fb_delta_fast_path: bool,
169
170 /// Names this commit for
171 /// [`LayerScene::release_displaced`](crate::LayerScene::release_displaced):
172 /// real commits that the kernel took count from 1. 0 for a test commit
173 /// and for a rejected one.
174 pub commit_seq: u64,
175
176 /// Whether the per-frame test-commit budget, not the hardware, is what
177 /// stopped more layers reaching planes.
178 ///
179 /// When this is set, some of `layers_composited` is a capacity decision
180 /// rather than a plane shortage: there were compatible planes free and the
181 /// allocator ran out of permitted round trips to the kernel before it
182 /// could offer them. Composited layers still reach the screen, so this is
183 /// not an error -- but a caller tuning a scene needs to tell "this device
184 /// has no more planes" from "raise the budget", and `layers_composited`
185 /// alone cannot. Raise it with `Allocator::set_max_test_commits`.
186 pub budget_exhausted: bool,
187
188 /// Whether the frame was suppressed as idle -- no commit was issued at all.
189 ///
190 /// Set only by a caller that gates on content changing, such as
191 /// `ScanoutBackend::present_if_changed`. **When it is set every other field
192 /// is zero**, because there was no commit to count: no layers were walked,
193 /// no properties written, no placements decided. A consumer reading
194 /// `layers_assigned == 0` without checking this would conclude the frame
195 /// failed to reach a plane, when in fact the previous frame is still on
196 /// screen and correct.
197 ///
198 /// The first frame is never suppressed, whatever the caller says about
199 /// content: nothing is on screen yet, so "unchanged" describes nothing.
200 pub skipped_idle: bool,
201
202 /// Per-layer outcomes.
203 pub placements: Vec<LayerPlacement>,
204}
205
206impl CommitReport {
207 /// The report for a frame that was suppressed as idle.
208 ///
209 /// Every count is zero because there was no commit to count, and
210 /// [`skipped_idle`](Self::skipped_idle) says why. A constructor rather
211 /// than a struct literal because this type is `#[non_exhaustive]`: a
212 /// caller outside this crate cannot build one, and the suppressed frame is
213 /// the one shape it legitimately needs to.
214 #[must_use]
215 pub fn suppressed() -> Self {
216 Self {
217 skipped_idle: true,
218 ..Self::default()
219 }
220 }
221
222 /// What became of one layer this commit.
223 ///
224 /// `None` when the layer was not in the frame at all -- it was never
225 /// added, it was removed, or the handle is stale. A stale one cannot
226 /// match by accident: [`LayerHandle::layer_id`](crate::LayerHandle::layer_id)
227 /// packs the slot's generation, so a recycled slot yields a different id
228 /// and a handle to the layer that used to live there resolves to nothing.
229 #[must_use]
230 pub fn placement_of(&self, layer: impl Into<LayerId>) -> Option<&LayerPlacement> {
231 let id = layer.into();
232 self.placements.iter().find(|entry| entry.layer == id)
233 }
234
235 /// Whether the layer went into the canvas rather than onto a plane of its
236 /// own.
237 ///
238 /// False for a layer that was dropped as well as for one that got a
239 /// plane: composited means it reached the screen the slow way, and
240 /// unassigned means it did not reach the screen at all. A caller
241 /// throttling its repaints on this distinction wants them separated.
242 #[must_use]
243 pub fn was_composited(&self, layer: impl Into<LayerId>) -> bool {
244 self.placement_of(layer)
245 .is_some_and(|entry| entry.placement == Placement::Composited)
246 }
247
248 /// Whether the layer tallies add up to [`layers_total`](Self::layers_total).
249 ///
250 /// The C++ states this as a comment on the field. Making it checkable means
251 /// the scene's own tests can assert it every commit, which is how a layer
252 /// counted twice — or not at all — gets caught at the point it happens.
253 #[must_use]
254 pub const fn accounting_balances(&self) -> bool {
255 self.layers_total
256 == self.layers_assigned
257 + self.layers_composited
258 + self.layers_unassigned
259 + self.layers_skipped_no_frame
260 }
261
262 /// Whether the fast path's implied contract holds: taking it means issuing
263 /// no test commits.
264 #[must_use]
265 pub const fn fast_path_consistent(&self) -> bool {
266 !self.fb_delta_fast_path || self.test_commits_issued == 0
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{CommitReport, LayerPlacement, Placement};
273 use drmkit_planes::LayerId;
274
275 /// A report with one layer of each outcome.
276 fn report() -> CommitReport {
277 CommitReport {
278 placements: vec![
279 LayerPlacement {
280 layer: LayerId(1),
281 placement: Placement::AssignedToPlane,
282 plane_id: Some(31),
283 plane_rotation_bits: 0,
284 },
285 LayerPlacement {
286 layer: LayerId(2),
287 placement: Placement::Composited,
288 plane_id: Some(42),
289 plane_rotation_bits: 0,
290 },
291 LayerPlacement {
292 layer: LayerId(3),
293 placement: Placement::Unassigned,
294 plane_id: None,
295 plane_rotation_bits: 0,
296 },
297 ],
298 ..CommitReport::default()
299 }
300 }
301
302 #[test]
303 fn each_outcome_is_found_with_the_plane_it_landed_on() {
304 let report = report();
305
306 let assigned = report.placement_of(LayerId(1)).expect("assigned");
307 assert_eq!(assigned.placement, Placement::AssignedToPlane);
308 assert_eq!(assigned.plane_id, Some(31));
309
310 // A composited layer names the *canvas* plane, not one of its own --
311 // a caller reading this as "the layer got plane 42" would think it had
312 // a plane it does not have.
313 let composited = report.placement_of(LayerId(2)).expect("composited");
314 assert_eq!(composited.placement, Placement::Composited);
315 assert_eq!(composited.plane_id, Some(42));
316
317 let dropped = report.placement_of(LayerId(3)).expect("unassigned");
318 assert_eq!(dropped.placement, Placement::Unassigned);
319 assert_eq!(dropped.plane_id, None, "it reached no plane at all");
320 }
321
322 #[test]
323 fn a_layer_that_was_not_in_the_frame_has_no_placement() {
324 assert!(report().placement_of(LayerId(4)).is_none());
325 assert!(CommitReport::default().placement_of(LayerId(1)).is_none());
326 }
327
328 #[test]
329 fn a_recycled_slot_does_not_answer_for_the_layer_that_left_it() {
330 // The identity a handle resolves to packs the slot's generation, so
331 // the layer that took a freed slot has a different id and a stale
332 // handle finds nothing. Without that, a caller holding a handle to a
333 // removed layer would read the new occupant's outcome as its own.
334 let slot_1_generation_1 = LayerId((1 << 32) | 1);
335 let slot_1_generation_2 = LayerId((1 << 32) | 2);
336 assert_ne!(slot_1_generation_1, slot_1_generation_2);
337
338 let report = CommitReport {
339 placements: vec![LayerPlacement {
340 layer: slot_1_generation_2,
341 placement: Placement::AssignedToPlane,
342 plane_id: Some(31),
343 plane_rotation_bits: 0,
344 }],
345 ..CommitReport::default()
346 };
347 assert!(report.placement_of(slot_1_generation_2).is_some());
348 assert!(report.placement_of(slot_1_generation_1).is_none());
349 }
350
351 #[test]
352 fn only_a_composited_layer_was_composited() {
353 let report = report();
354 assert!(!report.was_composited(LayerId(1)), "it got a plane");
355 assert!(report.was_composited(LayerId(2)));
356 // Dropped is not composited: it never reached the screen, and a
357 // caller treating the two alike would stop repainting a layer nobody
358 // can see.
359 assert!(!report.was_composited(LayerId(3)));
360 assert!(!report.was_composited(LayerId(9)), "absent");
361 }
362}