Skip to main content

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}