drmkit-scene 0.1.2

Scene composition for drmkit: buffer sources, layer lowering, and atomic commit orchestration.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
// SPDX-License-Identifier: MIT

//! Where a layer's content comes from.
//!
//! Port of `src/scene/buffer_source.hpp`.
//!
//! # Design invariants
//!
//! Carried from the C++, because the scene depends on all of them:
//!
//! - [`SourceFormat`] describes what the buffer **is** — format, modifier,
//!   intrinsic size. It belongs to the source; the scene never mutates it.
//!   How the buffer is *displayed* — source and destination rectangles,
//!   rotation, alpha, stacking — lives on the layer instead, because those are
//!   plane properties in KMS, not properties of a buffer. Keeping the two apart
//!   mirrors the KMS boundary exactly and makes it natural to show one buffer
//!   several ways.
//!
//! - [`release`](LayerBufferSource::release) is **infallible**. The source owns
//!   everything reachable through an [`AcquiredBuffer`], so releasing cannot
//!   fail by construction. A source whose release path genuinely can fail — a
//!   remote producer, a network-backed surface — must log and leak rather than
//!   propagate: the scene's cleanup paths cannot observe a release failure and
//!   still be simple enough to be correct.
//!
//! - [`bind_to_plane`](LayerBufferSource::bind_to_plane) and
//!   [`unbind_from_plane`](LayerBufferSource::unbind_from_plane) are the
//!   insertion points for [`BindingModel::DriverOwnsBinding`]. The no-op
//!   defaults are correct for every source that submits its own `FB_ID`.

use drmkit_sync::SyncFence;

/// How the scene and a source divide responsibility for `FB_ID`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[non_exhaustive]
pub enum BindingModel {
    /// The scene writes `FB_ID` into the atomic commit.
    ///
    /// Covers dumb buffers, GBM buffer objects, imported DMA-BUFs, V4L2
    /// capture buffers — everything that becomes a DRM framebuffer.
    #[default]
    SceneSubmitsFbId,

    /// The driver binds a producer directly to the plane, and the scene must
    /// **not** write `FB_ID` for this layer.
    ///
    /// EGL Streams and relatives. The hook exists as dormant surface (DR-4) so
    /// the scene does not need rewriting when the streams tier lands; no
    /// in-tree source reports it yet.
    DriverOwnsBinding,
}

/// What a source's buffer intrinsically is.
///
/// Width and height are the buffer's own dimensions, not the on-screen
/// rectangle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(
    clippy::exhaustive_structs,
    reason = "built by callers with `..Default::default()`, which a new field does not break"
)]
pub struct SourceFormat {
    /// DRM `FourCC`.
    pub fourcc: u32,
    /// Layout modifier.
    pub modifier: u64,
    /// Buffer width in pixels.
    pub width: u32,
    /// Buffer height in pixels.
    pub height: u32,
}

/// A region of a buffer that changed since the slot was last scanned out, in
/// buffer pixels with a top-left origin.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(
    clippy::exhaustive_structs,
    reason = "a value defined by its fields; there is nothing to add to it"
)]
pub struct DamageRect {
    /// Left edge.
    pub x: i32,
    /// Top edge.
    pub y: i32,
    /// Width.
    pub w: u32,
    /// Height.
    pub h: u32,
}

/// A buffer handed over by [`LayerBufferSource::acquire`], valid until the
/// matching [`release`](LayerBufferSource::release).
///
/// Move-only, because it carries an owned fence. That is load-bearing: it means
/// the two-deep release queue **cannot** hand a buffer back early by accident —
/// doing so is a move, and using it afterwards does not compile. Invariant 1's
/// ownership half is enforced by the type; only its *timing* half needs a test.
///
/// Releasing a buffer and then using it is a compile error, which is the whole
/// point:
///
/// ```compile_fail
/// # use drmkit_scene::AcquiredBuffer;
/// fn release(_buffer: AcquiredBuffer) {}
///
/// let buffer = AcquiredBuffer { fb_id: 1, ..AcquiredBuffer::default() };
/// release(buffer);
/// // The buffer is back with its source now; the display engine may already
/// // have stopped scanning it out. Reading it here would be exactly the
/// // use-after-release invariant 1 exists to prevent.
/// assert_eq!(buffer.fb_id, 1);
/// ```
///
/// Ordinary moves are of course fine:
///
/// ```
/// # use drmkit_scene::AcquiredBuffer;
/// let buffer = AcquiredBuffer { fb_id: 1, ..AcquiredBuffer::default() };
/// let moved = buffer;
/// assert_eq!(moved.fb_id, 1);
/// ```
#[derive(Debug, Default)]
#[allow(
    clippy::exhaustive_structs,
    reason = "built by callers with `..Default::default()`, which a new field does not break"
)]
pub struct AcquiredBuffer {
    /// The DRM framebuffer the scene will write to the plane's `FB_ID`.
    ///
    /// Non-zero for a [`BindingModel::SceneSubmitsFbId`] source. **Zero** for
    /// [`BindingModel::DriverOwnsBinding`], where the scene skips the write.
    pub fb_id: u32,

    /// A source-private token echoed back verbatim to `release` — typically a
    /// slot index in a ring.
    ///
    /// The C++ carries a `void*` here. A `u64` covers every in-tree use (all of
    /// them store an index) without inviting a raw pointer into a type that
    /// crosses the source boundary.
    pub token: u64,

    /// An optional render-done fence: the pixels are valid only once it
    /// signals.
    ///
    /// Set it when the producer renders asynchronously rather than CPU-blocking
    /// before returning. The scene takes ownership: where the assigned plane
    /// advertises `IN_FENCE_FD` it hands the descriptor to KMS so the kernel
    /// waits before scanout; otherwise it waits on the CPU before committing.
    /// Either way the fence rides this buffer's lifecycle and closes on release
    /// or drop — the kernel does not take ownership of `IN_FENCE_FD`.
    ///
    /// Sources that produce ready buffers synchronously leave this `None`.
    pub acquire_fence: Option<SyncFence>,

    /// Per-frame dirty regions.
    ///
    /// **Empty means full-frame** — the scene emits no `FB_DAMAGE_CLIPS` and
    /// assumes everything changed, which is correct but not power-optimal. A
    /// non-empty list names the changed regions; the scene falls back to
    /// full-frame above 16 rectangles, clips each to the buffer and drops any
    /// left empty, and omits the blob entirely on planes or drivers without the
    /// property.
    pub damage: Vec<DamageRect>,
}

/// Why an [`acquire`](LayerBufferSource::acquire) did not produce a buffer.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SourceError {
    /// **Flow control, not a failure.** The source has no frame to contribute
    /// this vblank.
    ///
    /// The layer keeps its plane and goes on showing its previous framebuffer
    /// -- or is left out, if it has never produced one -- and `acquire` is
    /// called again on the next commit; the layer is counted in
    /// [`layers_skipped_no_frame`](crate::CommitReport::layers_skipped_no_frame),
    /// never in the failure paths. A live source returns this before its first
    /// sample lands, and any time the producer falls behind with no cached
    /// frame to re-issue.
    ///
    /// This is DR-6: a distinct variant, never
    /// [`io::ErrorKind::WouldBlock`](std::io::ErrorKind::WouldBlock). Squinting
    /// at an I/O error to recover flow control is exactly how the EAGAIN
    /// contract gets lost in a refactor — and losing it turns a stalled
    /// producer into an aborted commit.
    #[error("no frame available this vblank")]
    WouldBlock,

    /// The operation is not supported by this source.
    ///
    /// Returned by the default [`map`](LayerBufferSource::map) and
    /// [`export_dma_buf`](LayerBufferSource::export_dma_buf). A source that
    /// does not support `map` is **uncompositable**: when the allocator cannot place
    /// it, the scene has no way to rescue it and the layer stays dropped for
    /// the frame.
    #[error("not supported by this source")]
    Unsupported,

    /// A real failure. Aborts the commit.
    #[error("source failed: {0}")]
    Failed(#[from] rustix::io::Errno),
}

/// A borrowed view of a buffer's DMA-BUF planes, for a consumer that imports
/// the buffer directly rather than reading CPU pixels.
///
/// The descriptors are **borrowed**: they stay owned by the source and are
/// valid only until the matching release. The consumer must not close them — an
/// image import references them internally, so no duplication is needed.
#[derive(Debug, Clone)]
#[allow(
    clippy::exhaustive_structs,
    reason = "built by callers, with no default to fill a new field from"
)]
pub struct DmaBufDesc<'a> {
    /// One descriptor per plane. A single-descriptor multi-plane format such as
    /// `NV12` repeats the same descriptor with distinct offsets.
    pub fds: Vec<std::os::fd::BorrowedFd<'a>>,
    /// Byte offset of each plane.
    pub offsets: Vec<u32>,
    /// Row stride of each plane.
    pub pitches: Vec<u32>,
    /// Shape, matching [`LayerBufferSource::format`].
    pub format: SourceFormat,
}

/// Where a layer's content comes from.
///
/// Owning a source is owning the DRM handles it holds: dropping a layer drops
/// its source and the buffers with it.
pub trait LayerBufferSource {
    /// Produce this frame's buffer.
    ///
    /// On success the source has arranged that `fb_id` names a ready-to-scan-out
    /// framebuffer. The scene pairs the result with a later
    /// [`release`](Self::release) once the commit completes.
    ///
    /// # Errors
    ///
    /// [`SourceError::WouldBlock`] is **flow control** and expected — see that
    /// variant. Any other error aborts the commit.
    fn acquire(&mut self) -> Result<AcquiredBuffer, SourceError>;

    /// Return a buffer to the free pool.
    ///
    /// Infallible by contract. The scene calls this after page-flip completion,
    /// on commit failure, and during shutdown; there is deliberately no error
    /// channel, so those cleanup paths stay simple enough to be correct.
    fn release(&mut self, acquired: AcquiredBuffer);

    /// Release carrying the `OUT_FENCE` of the commit that displaced this
    /// buffer.
    ///
    /// Once it signals, the buffer is off-screen and safe to render into again,
    /// so a GPU producer can wait on it GPU-side instead of blocking the CPU
    /// until the later release edge. `None` when the CRTC has no
    /// `OUT_FENCE_PTR` or the source did not opt in.
    ///
    /// The default forwards to [`release`](Self::release), so only a source
    /// that wants the fence overrides it — see
    /// [`wants_release_fence`](Self::wants_release_fence).
    fn release_with_fence(&mut self, acquired: AcquiredBuffer, release_fence: Option<SyncFence>) {
        let _ = release_fence;
        self.release(acquired);
    }

    /// Whether this source wants the per-buffer release fence.
    ///
    /// The scene only requests an internal `OUT_FENCE` when some live source
    /// says yes, so this costs nothing otherwise.
    fn wants_release_fence(&self) -> bool {
        false
    }

    /// Whether a producer submitted something new since the last commit.
    ///
    /// Drives the scene's whole-commit skip: when every live source says no and
    /// no layer is dirty, no atomic commit is issued at all — a power win, and
    /// what lets a self-refresh panel stay in self-refresh.
    ///
    /// **The default is `true`, deliberately.** A source that cannot tell
    /// whether its buffer changed — a CPU producer painting into a dumb buffer
    /// the scene cannot introspect — must say "changed", or a real update is
    /// silently skipped. Only a source that *knows* it is idle returns false.
    fn has_fresh_content(&self) -> bool {
        true
    }

    /// The scene acquires from this source no more: its layer was removed.
    ///
    /// Its in-flight buffers still come back through [`release`](Self::release)
    /// or [`release_with_fence`](Self::release_with_fence), after the commits
    /// that displace them. A source that holds its on-screen buffer back from
    /// release until a newer frame supersedes it -- `ExternalDmaBufRing`,
    /// `ExternalDmaBufPool` -- must let that one go with them: no newer frame
    /// is coming, and the producer would never get the buffer back. A client
    /// that makes a new pool per geometry would lose one buffer to every
    /// resize until it ran out and stalled.
    ///
    /// The default does nothing.
    fn on_retired(&mut self) {}

    /// Which binding contract this source participates in.
    fn binding_model(&self) -> BindingModel {
        BindingModel::SceneSubmitsFbId
    }

    /// The buffer's shape, read during layer lowering to populate the
    /// properties the allocator consults.
    fn format(&self) -> SourceFormat;

    /// Acquire a CPU view of the pixel storage.
    ///
    /// Composition fallback reads through this, by way of
    /// [`with_mapping`](Self::with_mapping), to rescue layers the allocator
    /// could not place. A source whose pixels live only in GPU memory returns
    /// [`SourceError::Unsupported`] — and is then uncompositable: the scene's
    /// CPU composition reads only through these.
    ///
    /// # Errors
    ///
    /// [`SourceError::Unsupported`] by default.
    fn map(
        &mut self,
        access: drmkit_dumb::MapAccess,
    ) -> Result<drmkit_dumb::Mapping<'_>, SourceError> {
        let _ = access;
        Err(SourceError::Unsupported)
    }

    /// Run `f` over a CPU view of the pixel storage, writable when `access`
    /// allows it.
    ///
    /// What the scene's composition reads through. The default maps with
    /// [`map`](Self::map) and hands that view to `f`, so a source that
    /// implements `map` needs nothing here. A source that can lend its pixels
    /// only for a bounded time -- one shared behind a `RefCell`, whose borrow
    /// cannot outlive a call -- overrides this instead, and holds whatever it
    /// must for exactly as long as `f` runs.
    ///
    /// # Errors
    ///
    /// Whatever [`map`](Self::map) fails with; `f` is not called then.
    fn with_mapping(
        &mut self,
        access: drmkit_dumb::MapAccess,
        f: &mut dyn FnMut(&mut drmkit_dumb::Mapping<'_>),
    ) -> Result<(), SourceError> {
        let mut mapping = self.map(access)?;
        f(&mut mapping);
        Ok(())
    }

    /// Export the currently-acquired buffer's DMA-BUF planes.
    ///
    /// For a consumer that imports DMA-BUFs: a source whose pixels never reach
    /// CPU memory — a V4L2 capture buffer — can be handed on this way. Upstream
    /// composites such a layer on the GPU; that compositor is not ported yet,
    /// and the scene's CPU composition reads only through
    /// [`with_mapping`](Self::with_mapping).
    ///
    /// # Errors
    ///
    /// [`SourceError::Unsupported`] by default.
    fn export_dma_buf(&mut self) -> Result<DmaBufDesc<'_>, SourceError> {
        Err(SourceError::Unsupported)
    }

    /// Bind a driver-owned producer to a plane.
    ///
    /// For a [`BindingModel::DriverOwnsBinding`] source, after the allocator
    /// settles on a plane and before the first acquire for it. Dormant like
    /// that variant: nothing in the scene calls it yet. The no-op default is correct for every
    /// [`BindingModel::SceneSubmitsFbId`] source.
    ///
    /// # Errors
    ///
    /// Never, by default.
    fn bind_to_plane(&mut self, plane_id: u32) -> Result<(), SourceError> {
        let _ = plane_id;
        Ok(())
    }

    /// Unbind from a plane, when the layer is reassigned or retired. Dormant,
    /// as [`bind_to_plane`](Self::bind_to_plane) is.
    fn unbind_from_plane(&mut self, plane_id: u32) {
        let _ = plane_id;
    }

    /// The session is losing DRM master; issue no ioctls until resumed.
    fn on_session_paused(&mut self) {}

    /// The session is back with a fresh device.
    ///
    /// A source holding descriptor-bound state must drop every handle tied to
    /// the dead descriptor — ioctls against it will fail — and re-allocate
    /// equivalent buffers. Dimensions and formats are preserved: callers rely
    /// on [`format`](Self::format) returning the same value afterwards.
    ///
    /// # Errors
    ///
    /// Whatever re-allocation failed with.
    fn on_session_resumed(&mut self, device: &drmkit_core::Device) -> Result<(), SourceError> {
        let _ = device;
        Ok(())
    }
}