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 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}