Skip to main content

drmkit_scene/
commit.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Turning plane assignments into atomic commits.
5//!
6//! This is the seam between the device-free search in `drmkit-planes` and the
7//! kernel. The allocator decides *what* to try; this knows *how* to ask.
8
9use std::collections::HashMap;
10
11use drmkit_core::{
12    AtomicCommitFlags, CoreError, Device, Mode, ObjectType, PropertyBlob, PropertyStore,
13};
14use drmkit_planes::{
15    ColorEncoding, ColorEncodingValues, ColorRange, ColorRangeValues, LayerRef, PropTag,
16    TestCommitter, TestFailure,
17};
18
19use crate::frame::KernelResult;
20
21/// A plane's `COLOR_ENCODING` and `COLOR_RANGE`: each property's id, and the
22/// driver-assigned integer for every entry it lists.
23///
24/// The two are independent. A plane may expose one without the other, and
25/// each is written where it is writable, as upstream does.
26#[derive(Debug, Clone, Copy, Default)]
27struct ColorProps {
28    encoding: Option<(u32, ColorEncodingValues)>,
29    range: Option<(u32, ColorRangeValues)>,
30}
31
32impl ColorProps {
33    /// The values this plane takes for `want`, one per property it has.
34    fn resolve(&self, want: crate::Ycbcr) -> ResolvedColor {
35        ResolvedColor {
36            encoding: self
37                .encoding
38                .and_then(|(id, values)| Some((id, encoding_value(&values, want.encoding)?))),
39            range: self
40                .range
41                .and_then(|(id, values)| Some((id, range_value(&values, want.range)?))),
42        }
43    }
44}
45
46/// A plane's colorimetry as written: property id and value, for each of the
47/// pair the plane has a usable entry for.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49struct ResolvedColor {
50    encoding: Option<(u32, u64)>,
51    range: Option<(u32, u64)>,
52}
53
54/// The `COLOR_ENCODING` value for `want`, or the nearest the plane has.
55///
56/// A driver that lacks the entry asked for gets BT.709, then BT.601: the
57/// fallback upstream's `arm_layer_plane_color_props` takes. Writing nothing
58/// instead would leave the plane on whatever the previous client chose.
59pub(crate) fn encoding_value(values: &ColorEncodingValues, want: ColorEncoding) -> Option<u64> {
60    let exact = match want {
61        ColorEncoding::Bt601 => values.bt601,
62        ColorEncoding::Bt709 => values.bt709,
63        ColorEncoding::Bt2020 => values.bt2020,
64    };
65    exact.or(values.bt709).or(values.bt601)
66}
67
68/// The `COLOR_RANGE` value for `want`, or limited, or full.
69pub(crate) fn range_value(values: &ColorRangeValues, want: ColorRange) -> Option<u64> {
70    let exact = match want {
71        ColorRange::Limited => values.limited,
72        ColorRange::Full => values.full,
73    };
74    exact.or(values.limited).or(values.full)
75}
76
77/// Resolves a layer's [`PropTag`]s to the DRM property ids of a given plane.
78///
79/// Property ids are per-object and stable for a device's lifetime, so they are
80/// resolved once per plane and cached. Doing it per frame would be an ioctl per
81/// property per layer.
82#[derive(Debug, Default)]
83pub struct PlanePropertyMap {
84    /// `plane_id` to its resolved tag ids.
85    pub(crate) planes: HashMap<u32, HashMap<PropTag, u32>>,
86    /// Properties the kernel marks immutable, which a commit must never write.
87    ///
88    /// Writing one is rejected with `EINVAL` whatever the value, so a single
89    /// stray write poisons the whole commit — including every other layer in
90    /// it.
91    immutable: HashMap<u32, Vec<PropTag>>,
92    /// Colorimetry properties, for the planes that expose them.
93    color: HashMap<u32, ColorProps>,
94    /// `FB_DAMAGE_CLIPS`, for the planes that expose it.
95    ///
96    /// Kept apart from `planes` rather than given a `PropTag`, because its
97    /// value does not exist until emission: it is a blob id, created from the
98    /// rectangles a source reported for *this* frame. A tag would put it in
99    /// the layer's property bag, which is built before any of that is known.
100    damage: HashMap<u32, u32>,
101    /// The inclusive zpos range each plane advertises, where it has one.
102    ///
103    /// zpos is the one plane property drmkit *derives* rather than echoes: the
104    /// composition canvas asks to sit above every layer, and "above" is
105    /// computed from the layer count. Nothing in that arithmetic knows what
106    /// the plane will accept, and a plane whose range is `[1, 17]` rejects 19
107    /// with `EINVAL` -- taking the whole frame down with it, every correctly
108    /// programmed layer included.
109    pub(crate) zpos_range: HashMap<u32, (u64, u64)>,
110    /// The largest `alpha` each plane advertises, where it advertises a
111    /// range. Layers carry 16-bit alpha; see [`drmkit_planes::rescale_alpha`].
112    pub(crate) alpha_max: HashMap<u32, u64>,
113    /// The colorimetry each plane's last real commit carried.
114    ///
115    /// These properties are sticky across clients, so they have to be written
116    /// to displace whatever the previous compositor left -- and then only when
117    /// a layer asks for something else, because restating an unchanged value
118    /// every frame is exactly the per-frame traffic the rest of the emit path
119    /// works to avoid. A frame that [restates](crate::FrameBuild::restates_sticky)
120    /// writes them regardless.
121    color_committed: HashMap<u32, ResolvedColor>,
122    /// Driver-private properties each plane exposes that drmkit does not
123    /// drive and zeroes whenever it writes the plane in full. See
124    /// [`RESET_ON_ARM`].
125    pub(crate) reset_on_arm: HashMap<u32, Vec<u32>>,
126}
127
128/// Plane properties whose 0 means "no configuration", which drmkit drives
129/// none of: the downstream SDE driver's scaler and color-conversion setups,
130/// its exclusion rectangle and its LUT blobs.
131///
132/// Another client that used a plane leaves them set, and they persist. On the
133/// SA8155P a `scaler_v2` sized for weston's 1280x720 buffer made every
134/// `TEST_ONLY` that put a small layer on that plane fail, and a stale
135/// `excl_rect_v1` alters what a plane fetches. So they are written 0 whenever
136/// a plane is written in full, as the reference does (drm-cxx `f72c5ad`).
137/// `blend_op` is left alone, as there.
138const RESET_ON_ARM: [&str; 8] = [
139    "scaler_v2",
140    "csc_v1",
141    "excl_rect_v1",
142    "lut_ed",
143    "lut_cir",
144    "lut_sep",
145    "SDE_VIG_3D_LUT_GAMUT_V5",
146    "SDE_VIG_1D_LUT_IGC_V5",
147];
148
149impl PlanePropertyMap {
150    /// An empty map.
151    #[must_use]
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Resolve and cache every tag this crate writes for `plane_id`.
157    ///
158    /// Tags the plane does not expose are simply absent: a plane without
159    /// `rotation` or `alpha` is normal, and asking for one is not an error.
160    ///
161    /// # Errors
162    ///
163    /// [`CoreError`] if the plane's properties cannot be read at all.
164    pub fn learn_plane(&mut self, device: &Device, plane_id: u32) -> Result<(), CoreError> {
165        let mut store = PropertyStore::new();
166        store.cache_properties(device, plane_id, ObjectType::Plane)?;
167
168        // Colorimetry is optional -- plenty of planes expose neither property.
169        // An immutable one is left out, since writing it fails the commit
170        // whatever the value, and an entry the driver does not list is `None`
171        // and falls back at emission.
172        let writable = |name: &str| {
173            store
174                .property_id(plane_id, name)
175                .ok()
176                .filter(|_| !store.is_immutable(plane_id, name).unwrap_or(false))
177        };
178        let entry =
179            |name: &str, variant: &str| store.enum_value(device, plane_id, name, variant).ok();
180        let color = ColorProps {
181            encoding: writable("COLOR_ENCODING").map(|id| {
182                (
183                    id,
184                    ColorEncodingValues {
185                        bt601: entry("COLOR_ENCODING", "ITU-R BT.601 YCbCr"),
186                        bt709: entry("COLOR_ENCODING", "ITU-R BT.709 YCbCr"),
187                        bt2020: entry("COLOR_ENCODING", "ITU-R BT.2020 YCbCr"),
188                    },
189                )
190            }),
191            range: writable("COLOR_RANGE").map(|id| {
192                (
193                    id,
194                    ColorRangeValues {
195                        limited: entry("COLOR_RANGE", "YCbCr limited range"),
196                        full: entry("COLOR_RANGE", "YCbCr full range"),
197                    },
198                )
199            }),
200        };
201        if color.encoding.is_some() || color.range.is_some() {
202            self.color.insert(plane_id, color);
203        }
204
205        if let Ok(Some(range)) = store.range(plane_id, PropTag::Zpos.name()) {
206            self.zpos_range.insert(plane_id, range);
207        }
208        if let Ok(Some((_, max))) = store.range(plane_id, PropTag::Alpha.name()) {
209            self.alpha_max.insert(plane_id, max);
210        }
211
212        if let Ok(id) = store.property_id(plane_id, "FB_DAMAGE_CLIPS") {
213            self.damage.insert(plane_id, id);
214        }
215
216        let reset: Vec<u32> = RESET_ON_ARM
217            .iter()
218            .filter_map(|name| writable(name))
219            .collect();
220        if !reset.is_empty() {
221            self.reset_on_arm.insert(plane_id, reset);
222        }
223
224        let mut ids = HashMap::new();
225        let mut immutable = Vec::new();
226        for tag in PropTag::ALL {
227            // `pixel_format` and `FB_MODIFIER` are drmkit's own hints on the
228            // property bag, not KMS plane properties -- the allocator reads
229            // them, the kernel never sees them.
230            if matches!(tag, PropTag::PixelFormat | PropTag::FbModifier) {
231                continue;
232            }
233            if let Ok(id) = store.property_id(plane_id, tag.name()) {
234                if store.is_immutable(plane_id, tag.name()).unwrap_or(false) {
235                    immutable.push(tag);
236                    continue;
237                }
238                ids.insert(tag, id);
239            }
240        }
241
242        self.planes.insert(plane_id, ids);
243        self.immutable.insert(plane_id, immutable);
244        Ok(())
245    }
246
247    /// Learn every plane the registry knows about.
248    ///
249    /// # Errors
250    ///
251    /// [`CoreError`] if a plane's properties cannot be read.
252    pub fn learn_all(
253        &mut self,
254        device: &Device,
255        registry: &drmkit_planes::PlaneRegistry,
256    ) -> Result<(), CoreError> {
257        for plane in registry.all() {
258            self.learn_plane(device, plane.id)?;
259        }
260        Ok(())
261    }
262
263    /// Record that a real commit carried a plan entry's colorimetry.
264    ///
265    /// Call for every entry of a frame the kernel accepted. A `TEST_ONLY`
266    /// applies nothing, so noting one would suppress the write on the commit
267    /// that actually matters and leave the plane on the previous client's
268    /// settings. An entry that asks for nothing -- the composition canvas --
269    /// leaves the record as it was.
270    pub fn note_color_committed(&mut self, plane_id: u32, color: Option<crate::Ycbcr>) {
271        if let (Some(want), Some(props)) = (color, self.color.get(&plane_id)) {
272            self.color_committed.insert(plane_id, props.resolve(want));
273        }
274    }
275
276    /// The DRM property id for a tag on a plane, if it is writable there.
277    #[must_use]
278    pub fn property_id(&self, plane_id: u32, tag: PropTag) -> Option<u32> {
279        self.planes.get(&plane_id)?.get(&tag).copied()
280    }
281
282    /// Whether the plane marks this property immutable.
283    #[must_use]
284    pub fn is_immutable(&self, plane_id: u32, tag: PropTag) -> bool {
285        self.immutable
286            .get(&plane_id)
287            .is_some_and(|tags| tags.contains(&tag))
288    }
289
290    /// How many planes have been learned.
291    #[must_use]
292    pub fn len(&self) -> usize {
293        self.planes.len()
294    }
295
296    /// Whether nothing has been learned yet.
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.planes.is_empty()
300    }
301}
302
303/// Emit one layer's properties onto a plane.
304///
305/// Skips tags the plane does not expose or marks immutable, the two hint tags
306/// the kernel never sees, `FB_ID` on an externally bound layer, and values
307/// unchanged from `baseline`. Returns what was written, as [`LayerWrites`].
308///
309/// With no `baseline` -- a plane written in full, which is every `TEST_ONLY`,
310/// a plane's first use or a new layer on it, and the composition canvas -- the
311/// driver-private state another client may have left on the plane is zeroed
312/// first: the downstream SDE driver's scaler, color-conversion,
313/// exclusion-rectangle and LUT properties, which drmkit does not drive.
314///
315/// # Errors
316///
317/// [`CoreError`] if a property write is rejected before the commit — which
318/// only happens for a malformed id.
319pub fn emit_layer(
320    request: &mut drmkit_core::AtomicRequest,
321    map: &PlanePropertyMap,
322    plane_id: u32,
323    layer: &drmkit_planes::Layer,
324    baseline: Option<&drmkit_planes::PropertySnapshot>,
325) -> Result<LayerWrites, CoreError> {
326    let mut written = LayerWrites::default();
327    if baseline.is_none() {
328        for property_id in map.reset_on_arm.get(&plane_id).into_iter().flatten() {
329            request.add_property(plane_id, *property_id, 0)?;
330            written.properties += 1;
331        }
332    }
333    for (tag, value) in layer.properties() {
334        let Some(property_id) = map.property_id(plane_id, tag) else {
335            continue;
336        };
337        // An externally bound layer's FB_ID is set up by the producer's
338        // extension stack. Writing it from here fights that, so suppress it
339        // even if something has stuffed one into the property bag.
340        if layer.is_externally_bound() && tag == PropTag::FbId {
341            continue;
342        }
343        // The diff compares what the caller asked for, because that is what
344        // the baseline records; the plane's own range is applied to what goes
345        // out. The mapping is fixed per plane, so equal requests always mean
346        // equal writes -- and comparing a rescaled value against a raw
347        // baseline would rewrite alpha on every frame of an 8-bit plane.
348        if !needs_write(tag, value, baseline) {
349            continue;
350        }
351        let value = match tag {
352            PropTag::Zpos => map.clamp_zpos(plane_id, value),
353            PropTag::Alpha => map.rescale_alpha(plane_id, value),
354            _ => value,
355        };
356        request.add_property(plane_id, property_id, value)?;
357        written.properties += 1;
358        if tag == PropTag::FbId {
359            written.framebuffers += 1;
360        }
361    }
362    Ok(written)
363}
364
365/// What emitting one layer put on the wire.
366///
367/// `framebuffers` is a subset of `properties`, kept apart because it answers a
368/// different question: `FB_ID` is written every frame by contract, so it is
369/// the floor a minimal frame is measured against rather than part of what the
370/// diff decided.
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372#[non_exhaustive]
373pub struct LayerWrites {
374    /// Properties written for this layer.
375    pub properties: usize,
376    /// Of those, `FB_ID` attachments.
377    pub framebuffers: usize,
378}
379
380impl PlanePropertyMap {
381    /// A layer's 16-bit alpha, on the scale this plane advertises.
382    ///
383    /// Every write of a layer's properties goes through
384    /// [`emit_layer`], so this one call covers the allocator's tests, the real
385    /// commit and a pinned layer alike -- upstream needed two fixes to reach
386    /// all three.
387    #[must_use]
388    pub fn rescale_alpha(&self, plane_id: u32, value: u64) -> u64 {
389        self.alpha_max
390            .get(&plane_id)
391            .map_or(value, |&max| drmkit_planes::rescale_alpha(value, max))
392    }
393
394    /// Bring a derived zpos inside what the plane will actually take.
395    ///
396    /// Only zpos is clamped, and only against an unsigned range. Every other
397    /// plane property carries a value the caller measured or was handed -- a
398    /// rectangle, a framebuffer id -- where a value outside the range is a
399    /// real error that silently moving would hide. zpos alone is computed, so
400    /// it alone is worth bending to fit; a canvas one slot lower than asked is
401    /// still above every layer, whereas the alternative is no frame at all.
402    #[must_use]
403    pub fn clamp_zpos(&self, plane_id: u32, value: u64) -> u64 {
404        match self.zpos_range.get(&plane_id) {
405            Some((lo, hi)) => value.clamp(*lo, *hi),
406            None => value,
407        }
408    }
409}
410
411/// Whether a property has to be written, given what the kernel last took.
412///
413/// `baseline` of `None` means write everything -- see
414/// [`committed_baseline`](drmkit_planes::Allocator::committed_baseline).
415fn needs_write(
416    tag: PropTag,
417    value: u64,
418    baseline: Option<&drmkit_planes::PropertySnapshot>,
419) -> bool {
420    // Two properties are written every commit no matter what the diff says.
421    //
422    // FB_ID is how KMS is told this is a new frame: the kernel schedules the
423    // page-flip event off the re-attach. A single-buffered source whose pixels
424    // mutate in place presents the same id every frame, so diffing it away
425    // leaves an otherwise-clean scene with an empty request -- which the kernel
426    // accepts and then never queues an event for, wedging the caller's flip
427    // forever.
428    //
429    // IN_FENCE_FD is a one-shot the kernel consumes each commit. An fd number
430    // the diff happens to see unchanged, because the value was recycled, still
431    // has to be re-armed or the plane scans out before its buffer is ready.
432    if matches!(tag, PropTag::FbId | PropTag::InFenceFd) {
433        return true;
434    }
435    baseline.is_none_or(|snapshot| snapshot.get(tag) != Some(value))
436}
437
438/// Emit the writes that detach a plane.
439///
440/// A plane left out of an assignment must be explicitly cleared, or it inherits
441/// whatever the previous commit left on it — an old framebuffer, an old CRTC
442/// binding, an old stacking position. Under a `TEST_ONLY` that shows up as a
443/// spurious rejection when a layer migrates between planes on one CRTC: two
444/// active planes, same stacking slot, same CRTC.
445///
446/// # Errors
447///
448/// [`CoreError`] if a property write is rejected.
449pub fn emit_disable(
450    request: &mut drmkit_core::AtomicRequest,
451    map: &PlanePropertyMap,
452    plane_id: u32,
453) -> Result<usize, CoreError> {
454    let mut written = 0;
455    for tag in [PropTag::FbId, PropTag::CrtcId] {
456        if let Some(property_id) = map.property_id(plane_id, tag) {
457            request.add_property(plane_id, property_id, 0)?;
458            written += 1;
459        }
460    }
461    Ok(written)
462}
463
464/// Turn off what a [`rebind`](crate::LayerScene::rebind) left on the old CRTC.
465///
466/// One commit of its own, before the first frame on the new CRTC: every plane
467/// the scene had lit there is detached, and the old CRTC is switched off
468/// (`ACTIVE = 0`). It has to be separate because the kernel refuses to move a
469/// plane between CRTCs in one commit, and a plane both pipes can use is one
470/// the new frame may want. The CRTC goes off with its planes because some
471/// drivers reject an active CRTC whose primary is disarmed; `ACTIVE = 0` keeps
472/// its mode and connector, so a caller that wants the old output back sets it
473/// active again. Upstream's fix for the same defect (`a335bef`, drm-cxx#340)
474/// leaves the old CRTC active, and retries without the primary instead.
475///
476/// Returns whether there was anything to commit. On success the scene can
477/// build frames again; on failure the detach stays pending and
478/// [`build_frame`](crate::LayerScene::build_frame) goes on refusing.
479///
480/// # Errors
481///
482/// [`CoreError`] if a property cannot be found or the kernel rejects the
483/// commit.
484pub fn commit_detach(device: &Device, scene: &mut crate::LayerScene) -> Result<bool, CoreError> {
485    let Some(detach) = scene.pending_detach() else {
486        return Ok(false);
487    };
488    // Looked up here rather than through the caller's property map: a plane
489    // the map never learned would be skipped silently and stay lit, which is
490    // the defect this exists to fix.
491    let mut store = PropertyStore::new();
492    let mut request = drmkit_core::AtomicRequest::with_capacity(2 * detach.planes.len() + 1);
493    for &plane_id in &detach.planes {
494        store.cache_properties(device, plane_id, ObjectType::Plane)?;
495        request.add_property(plane_id, store.property_id(plane_id, "FB_ID")?, 0)?;
496        request.add_property(plane_id, store.property_id(plane_id, "CRTC_ID")?, 0)?;
497    }
498    store.cache_properties(device, detach.crtc_id, ObjectType::Crtc)?;
499    request.add_property(
500        detach.crtc_id,
501        store.property_id(detach.crtc_id, "ACTIVE")?,
502        0,
503    )?;
504    request.commit(device, AtomicCommitFlags::ALLOW_MODESET)?;
505    scene.detach_committed();
506    Ok(true)
507}
508
509/// `layer` with the stacked zpos for `plane_id`, if the stack has one.
510fn with_stacked_zpos(
511    layer: &drmkit_planes::Layer,
512    plane_id: u32,
513    stacked: &[(u32, u64)],
514) -> drmkit_planes::Layer {
515    let mut layer = layer.clone();
516    if let Some((_, zpos)) = stacked.iter().find(|(id, _)| *id == plane_id) {
517        layer.set_property(PropTag::Zpos, *zpos);
518    }
519    layer
520}
521
522/// A [`TestCommitter`] that issues real `TEST_ONLY` commits.
523///
524/// Builds a fresh request per test that (a) disables every candidate plane not
525/// in the assignment, then (b) applies each assigned layer's properties — the
526/// order matters for the reason [`emit_disable`] describes.
527pub struct DeviceCommitter<'a> {
528    device: &'a Device,
529    map: &'a PlanePropertyMap,
530    /// Planes on this CRTC that a test may need to disable.
531    candidates: Vec<u32>,
532    /// Modeset writes to prepend to every test request, if the CRTC still
533    /// needs bringing up.
534    modeset: Option<&'a Modeset<'a>>,
535    flags: AtomicCommitFlags,
536    /// How many test commits have been issued, for diagnostics.
537    pub commits: usize,
538    /// A plane armed in every test alongside the assignment -- the
539    /// composition canvas. See [`TestCommitter::set_extra_plane`].
540    extra: Option<(u32, drmkit_planes::Layer)>,
541    /// For the zpos each test writes: the same stack the frame will.
542    registry: &'a drmkit_planes::PlaneRegistry,
543}
544
545impl<'a> DeviceCommitter<'a> {
546    /// A committer over the planes this CRTC could use.
547    ///
548    /// The candidate list comes from
549    /// [`force_disable_candidates`](drmkit_planes::PlaneRegistry::force_disable_candidates)
550    /// rather than being supplied, so a test commit and the apply commit that
551    /// follows it clear exactly the same set. Handing them different lists
552    /// would let a configuration pass `TEST_ONLY` and then be applied with a
553    /// plane the test had cleared still live.
554    #[must_use]
555    pub fn new(
556        device: &'a Device,
557        map: &'a PlanePropertyMap,
558        registry: &'a drmkit_planes::PlaneRegistry,
559        crtc_index: u32,
560        flags: AtomicCommitFlags,
561        modeset: Option<&'a Modeset<'a>>,
562    ) -> Self {
563        Self {
564            device,
565            map,
566            candidates: registry
567                .force_disable_candidates(crtc_index)
568                .map(|plane| plane.id)
569                .collect(),
570            flags,
571            modeset,
572            commits: 0,
573            extra: None,
574            registry,
575        }
576    }
577}
578
579impl TestCommitter for DeviceCommitter<'_> {
580    fn lit_planes(&mut self, crtc_id: u32) -> Option<Vec<u32>> {
581        let mut store = drmkit_core::PropertyStore::new();
582        let mut lit = Vec::new();
583        for &plane_id in &self.candidates {
584            store
585                .cache_properties(self.device, plane_id, drmkit_core::ObjectType::Plane)
586                .ok()?;
587            let value = |name| store.property_value(plane_id, name).ok();
588            if value("FB_ID").unwrap_or(0) != 0 && value("CRTC_ID") == Some(u64::from(crtc_id)) {
589                lit.push(plane_id);
590            }
591        }
592        Some(lit)
593    }
594
595    fn test_assignment(&mut self, assignment: &[(u32, LayerRef<'_>)]) -> Result<(), TestFailure> {
596        self.commits += 1;
597        let mut request = drmkit_core::AtomicRequest::new();
598
599        // A test request is plane-only otherwise, and while the CRTC is still
600        // inactive the kernel rejects every plane bound to it with EINVAL. The
601        // search would then find nothing placeable and composite the whole
602        // scene -- on the first frame, on a machine that boots without a DRM
603        // fbdev client to leave the CRTC lit for it.
604        if let Some(modeset) = self.modeset
605            && modeset.emit(&mut request).is_err()
606        {
607            return Err(TestFailure::Rejected);
608        }
609
610        // Disable first: a plane inheriting stale state from the previous
611        // commit is what makes an otherwise-valid migration look invalid.
612        let extra_plane = self.extra.as_ref().map(|(plane_id, _)| *plane_id);
613        for plane_id in &self.candidates {
614            if assignment.iter().all(|(assigned, _)| assigned != plane_id)
615                && extra_plane != Some(*plane_id)
616                && emit_disable(&mut request, self.map, *plane_id).is_err()
617            {
618                return Err(TestFailure::Rejected);
619            }
620        }
621        // Full writes, deliberately. A search commit proposes a configuration
622        // the kernel has not seen; diffing it against what the kernel last
623        // took would test a request that is only valid as a delta from the
624        // current state, while the assignment being tried is not that state.
625        // Upstream splits the same way -- the search path and the apply path
626        // are different functions there for this reason.
627        //
628        // zpos goes out as the frame will write it: stacked over exactly the
629        // planes this test arms, canvas included, so the kernel is asked
630        // about the stack it will be handed.
631        let mut armed: Vec<(u32, Option<u64>)> = assignment
632            .iter()
633            .map(|(plane_id, layer)| (*plane_id, layer.layer.property(PropTag::Zpos)))
634            .collect();
635        if let Some((plane_id, layer)) = &self.extra {
636            armed.push((*plane_id, layer.property(PropTag::Zpos)));
637        }
638        let stacked = drmkit_planes::stacked_zpos(self.registry, &armed);
639        for (plane_id, layer) in assignment {
640            let layer = with_stacked_zpos(layer.layer, *plane_id, &stacked);
641            if emit_layer(&mut request, self.map, *plane_id, &layer, None).is_err() {
642                return Err(TestFailure::Rejected);
643            }
644        }
645        // The canvas, when the caller has told us there will be one. It is
646        // part of the frame the kernel will be asked to take, so it is part of
647        // the frame the kernel is asked about.
648        if let Some((plane_id, layer)) = &self.extra {
649            let layer = with_stacked_zpos(layer, *plane_id, &stacked);
650            if emit_layer(&mut request, self.map, *plane_id, &layer, None).is_err() {
651                return Err(TestFailure::Rejected);
652            }
653        }
654
655        // A request that carries the modeset brings the CRTC up, which the
656        // kernel refuses to validate without ALLOW_MODESET ("requires full
657        // modeset") wherever the CRTC was off or in another mode. Every test
658        // came back EINVAL there and nothing was ever placed (P-41, found on
659        // the SA8155P's DP-4). Upstream tests the first frame under the same
660        // allowance.
661        let flags = if self.modeset.is_some() {
662            self.flags | AtomicCommitFlags::ALLOW_MODESET
663        } else {
664            self.flags
665        };
666        match request.test(self.device, flags) {
667            Ok(()) => Ok(()),
668            Err(CoreError::NotMaster) => Err(TestFailure::NotMaster),
669            Err(_) => Err(TestFailure::Rejected),
670        }
671    }
672
673    fn set_extra_plane(&mut self, plane_id: u32, layer: drmkit_planes::Layer) {
674        self.extra = Some((plane_id, layer));
675    }
676
677    fn clear_extra_plane(&mut self) {
678        self.extra = None;
679    }
680}
681
682/// Classify a real commit's outcome for [`FrameLifecycle`](crate::FrameLifecycle).
683#[must_use]
684pub fn classify(result: &Result<(), CoreError>) -> KernelResult {
685    match result {
686        Ok(()) => KernelResult::Ok,
687        Err(CoreError::NotMaster) => KernelResult::NotMaster,
688        Err(_) => KernelResult::Rejected,
689    }
690}
691
692/// The CRTC and connector writes that bring a display up.
693///
694/// Plane properties alone do not light a screen. Until the CRTC has a mode and
695/// is `ACTIVE`, and some connector routes to it, a commit that binds planes to
696/// that CRTC is rejected -- and the planes have nowhere to scan out to even if
697/// it were not. Upstream folds these writes into the first commit after
698/// `create()` or `rebind()` and ORs in `ALLOW_MODESET` to let them through;
699/// this is the same set.
700///
701/// Easy to leave out and not notice: most desktops run a DRM fbdev client that
702/// re-enables the CRTC as soon as the previous master drops it, so a scene that
703/// never writes these still commits successfully on a developer's machine. It
704/// fails on a system that boots without one.
705pub struct Modeset<'a> {
706    crtc_id: u32,
707    connector_id: u32,
708    crtc_active: u32,
709    crtc_mode_id: u32,
710    connector_crtc_id: u32,
711    mode_blob: PropertyBlob<'a>,
712}
713
714impl<'a> Modeset<'a> {
715    /// Resolve the three property ids and stage `mode` as a blob.
716    ///
717    /// The blob is created once and reused for every commit: it describes the
718    /// mode, which does not change while the scene is bound to this CRTC.
719    ///
720    /// # Errors
721    ///
722    /// [`CoreError`] if a property is missing -- every atomic driver exposes
723    /// all three, so an absent one means this is not an atomic-capable device
724    /// -- or if the kernel refuses the blob.
725    pub fn learn(
726        device: &'a Device,
727        crtc_id: u32,
728        connector_id: u32,
729        mode: &Mode,
730    ) -> Result<Self, CoreError> {
731        let mut store = PropertyStore::new();
732        store.cache_properties(device, crtc_id, ObjectType::Crtc)?;
733        store.cache_properties(device, connector_id, ObjectType::Connector)?;
734
735        let crtc_active = store.property_id(crtc_id, "ACTIVE")?;
736        let crtc_mode_id = store.property_id(crtc_id, "MODE_ID")?;
737        let connector_crtc_id = store.property_id(connector_id, "CRTC_ID")?;
738
739        let mode_blob = device.create_property_blob(mode)?;
740
741        Ok(Self {
742            crtc_id,
743            connector_id,
744            crtc_active,
745            crtc_mode_id,
746            connector_crtc_id,
747            mode_blob,
748        })
749    }
750
751    /// Emit the modeset writes. Returns how many properties were written.
752    ///
753    /// # Errors
754    ///
755    /// [`CoreError`] if a property write is rejected before the commit.
756    pub fn emit(&self, request: &mut drmkit_core::AtomicRequest) -> Result<usize, CoreError> {
757        request.add_property(self.crtc_id, self.crtc_mode_id, self.mode_blob.id())?;
758        request.add_property(self.crtc_id, self.crtc_active, 1)?;
759        request.add_property(
760            self.connector_id,
761            self.connector_crtc_id,
762            u64::from(self.crtc_id),
763        )?;
764        Ok(3)
765    }
766
767    /// The blob id backing `MODE_ID`.
768    #[must_use]
769    pub const fn mode_blob(&self) -> u64 {
770        self.mode_blob.id()
771    }
772}
773
774/// Emit a built frame's apply commit.
775///
776/// Writes, in order: the modeset properties if `modeset` is given, then
777/// a disable for every candidate plane the frame did not use, then each
778/// assigned layer. The disables come before the applies for the reason
779/// [`emit_disable`] gives.
780///
781/// Returns how many properties were written.
782///
783/// # Errors
784///
785/// [`CoreError`] if a property write is rejected before the commit.
786/// # The modeset must outlive the commit, not this call
787///
788/// A [`Modeset`] holds a [`PropertyBlob`] that is
789/// destroyed when it drops, and the request carries only the blob's **id**.
790/// Taking `&Modeset` means it is alive *here*; it says nothing about whether
791/// it is alive at `commit`. A modeset built inside a loop that emits several
792/// scenes into one request, and dropped at the end of its iteration, leaves
793/// the request naming a blob the kernel has already freed — and the commit is
794/// refused with `EINVAL`, naming nothing useful.
795///
796/// Build them all first, into something that outlives the commit:
797///
798/// ```ignore
799/// let modesets: Vec<Modeset<'_>> = slots.iter().map(|s| Modeset::learn(..)).collect::<Result<_, _>>()?;
800/// for (slot, modeset) in slots.iter().zip(&modesets) {
801///     emit_frame(&mut request, &map, &mut build, Some(modeset))?;
802/// }
803/// request.commit(device, flags)?;  // every blob still alive
804/// ```
805pub fn emit_frame(
806    request: &mut drmkit_core::AtomicRequest,
807    map: &PlanePropertyMap,
808    build: &mut crate::FrameBuild,
809    modeset: Option<&Modeset<'_>>,
810) -> Result<usize, CoreError> {
811    emit_frame_damaged(request, map, build, modeset, None).map(|(written, _)| written)
812}
813
814/// [`emit_frame`], plus the `FB_DAMAGE_CLIPS` blobs partial repaint needs.
815///
816/// `device` is what creates the blobs, and is separate from everything else
817/// because it is the only part of emission that allocates kernel objects.
818/// Pass `None` to emit no damage at all — every frame then repaints whole,
819/// which is correct, just more bandwidth than the sources asked for.
820///
821/// # The returned blobs must outlive the commit
822///
823/// Each is a kernel object destroyed when it drops, and the request holds only
824/// its id. Dropping them before `commit` leaves the request pointing at blobs
825/// the kernel has already freed. Bind them to a local that lives across the
826/// commit:
827///
828/// ```ignore
829/// let (_, _blobs) = emit_frame_damaged(&mut request, &map, &mut build, None, Some(device))?;
830/// request.commit(device, flags)?;
831/// ```
832///
833/// # Errors
834///
835/// [`CoreError`] if a property write is rejected or a blob cannot be created.
836pub fn emit_frame_damaged<'a>(
837    request: &mut drmkit_core::AtomicRequest,
838    map: &PlanePropertyMap,
839    build: &mut crate::FrameBuild,
840    modeset: Option<&Modeset<'_>>,
841    device: Option<&'a Device>,
842) -> Result<(usize, Vec<drmkit_core::PropertyBlob<'a>>), CoreError> {
843    let mut written = 0;
844    let mut fbs = 0;
845    let mut damaged = 0;
846    let mut blobs = Vec::new();
847    if let Some(modeset) = modeset {
848        written += modeset.emit(request)?;
849    }
850    for plane_id in build.disables() {
851        written += emit_disable(request, map, *plane_id)?;
852    }
853    for entry in build.plan() {
854        let bag = match entry.zpos {
855            Some(zpos) if entry.layer.property(PropTag::Zpos) != Some(zpos) => {
856                let mut bag = entry.layer.clone();
857                bag.set_property(PropTag::Zpos, zpos);
858                std::borrow::Cow::Owned(bag)
859            }
860            _ => std::borrow::Cow::Borrowed(&entry.layer),
861        };
862        let layer = emit_layer(request, map, entry.plane_id, &bag, entry.baseline.as_ref())?;
863        written += layer.properties;
864        fbs += layer.framebuffers;
865        written += emit_color_props(
866            request,
867            map,
868            entry.plane_id,
869            entry.color,
870            build.restates_sticky(),
871        )?;
872
873        if let Some(device) = device
874            && let Some(blob) = emit_damage(request, map, device, entry.plane_id, &entry.damage)?
875        {
876            blobs.push(blob);
877            written += 1;
878            damaged += 1;
879        }
880    }
881    // Recorded rather than only returned: the caller has no way to put these
882    // back into the report, and a discarded return is how `properties_written`
883    // and `fbs_attached` came to be documented counters that were always zero.
884    build.record_emission(written, fbs, damaged);
885    Ok((written, blobs))
886}
887
888/// The most damage rectangles one layer may report before its frame is
889/// treated as whole. Upstream's `k_max_damage_rects`: past this, a damage blob
890/// buys the driver little over a full repaint, and the per-commit union stays
891/// small.
892pub(crate) const MAX_DAMAGE_RECTS: usize = 16;
893
894/// Bound a source's damage to what the kernel will take, as upstream's
895/// `arm_layer_damage_clips` does before it builds a blob.
896///
897/// More than [`MAX_DAMAGE_RECTS`] rectangles is whole-frame. Each rectangle is
898/// clipped to the `width` by `height` buffer, and one with nothing left is
899/// dropped. A source is not trusted to stay inside its own buffer, and a
900/// clip that strays outside it can make the driver refuse the whole commit
901/// rather than the one rectangle. An empty result is whole-frame too, which
902/// is what an empty list already means.
903pub(crate) fn clip_damage(
904    damage: &[crate::DamageRect],
905    width: u32,
906    height: u32,
907) -> Vec<crate::DamageRect> {
908    if damage.len() > MAX_DAMAGE_RECTS {
909        return Vec::new();
910    }
911    damage
912        .iter()
913        .filter_map(|rect| {
914            let x1 = i64::from(rect.x).max(0);
915            let y1 = i64::from(rect.y).max(0);
916            let x2 = (i64::from(rect.x) + i64::from(rect.w)).min(i64::from(width));
917            let y2 = (i64::from(rect.y) + i64::from(rect.h)).min(i64::from(height));
918            if x2 <= x1 || y2 <= y1 {
919                return None;
920            }
921            Some(crate::DamageRect {
922                x: i32::try_from(x1).ok()?,
923                y: i32::try_from(y1).ok()?,
924                w: u32::try_from(x2 - x1).ok()?,
925                h: u32::try_from(y2 - y1).ok()?,
926            })
927        })
928        .collect()
929}
930
931/// Convert damage rectangles into the `struct drm_mode_rect` array the kernel
932/// reads out of an `FB_DAMAGE_CLIPS` blob.
933///
934/// `drm_mode_rect` is four `__s32`: `x1, y1, x2, y2` — **corners, not
935/// origin-and-size**, and the second corner is exclusive. Writing a width
936/// where `x2` belongs describes a rectangle in the wrong place *and* the wrong
937/// size, and the driver repaints that instead — leaving the region that
938/// actually changed stale, which looks exactly like a producer that failed to
939/// draw.
940///
941/// The addition saturates rather than wrapping. A rectangle whose far corner
942/// does not fit an `i32` is nothing a real framebuffer contains, and clamping
943/// over-reports where wrapping would describe a rectangle with its corners the
944/// wrong way round.
945pub(crate) fn damage_rects(damage: &[crate::DamageRect]) -> Vec<[i32; 4]> {
946    damage
947        .iter()
948        .map(|rect| {
949            [
950                rect.x,
951                rect.y,
952                rect.x.saturating_add(rect.w.cast_signed()),
953                rect.y.saturating_add(rect.h.cast_signed()),
954            ]
955        })
956        .collect()
957}
958
959/// Stage one layer's damage as a `FB_DAMAGE_CLIPS` blob and point the plane at
960/// it.
961///
962/// `None` when there is nothing to say: the plane has no such property, or the
963/// layer reported no damage. **An empty list is not an empty blob.** Empty
964/// damage means *the whole frame changed*, and the way to say that is to write
965/// nothing -- a zero-rectangle blob tells the driver nothing changed, and the
966/// frame is never repainted.
967fn emit_damage<'a>(
968    request: &mut drmkit_core::AtomicRequest,
969    map: &PlanePropertyMap,
970    device: &'a Device,
971    plane_id: u32,
972    damage: &[crate::DamageRect],
973) -> Result<Option<drmkit_core::PropertyBlob<'a>>, CoreError> {
974    let Some(property_id) = map.damage.get(&plane_id).copied() else {
975        return Ok(None);
976    };
977    if damage.is_empty() {
978        return Ok(None);
979    }
980
981    let rects = damage_rects(damage);
982    let blob = device.create_property_blob(rects.as_slice())?;
983    request.add_property(plane_id, property_id, blob.id())?;
984    Ok(Some(blob))
985}
986
987/// Emit the colorimetry a plan entry asks of its plane, where the plane has it.
988///
989/// Exempt from the diff that governs everything else in [`emit_layer`]. These
990/// properties are sticky across clients: whatever the last compositor left is
991/// what this one inherits, and inheriting the wrong YCbCr matrix or range
992/// tints every YUV layer -- cyan or magenta, depending which is wrong. So each
993/// is written whenever it differs from what this plane's last real commit
994/// carried, and always when `restate` says the scene does not know what the
995/// kernel holds. `want` is `None` for the composition canvas, which writes
996/// neither (see [`PlanePlan::color`](crate::PlanePlan::color)).
997fn emit_color_props(
998    request: &mut drmkit_core::AtomicRequest,
999    map: &PlanePropertyMap,
1000    plane_id: u32,
1001    want: Option<crate::Ycbcr>,
1002    restate: bool,
1003) -> Result<usize, CoreError> {
1004    let (Some(want), Some(props)) = (want, map.color.get(&plane_id)) else {
1005        return Ok(0);
1006    };
1007    let resolved = props.resolve(want);
1008    let committed = map
1009        .color_committed
1010        .get(&plane_id)
1011        .filter(|_| !restate)
1012        .copied()
1013        .unwrap_or_default();
1014    let mut written = 0;
1015    for (now, before) in [
1016        (resolved.encoding, committed.encoding),
1017        (resolved.range, committed.range),
1018    ] {
1019        if let Some((property_id, value)) = now
1020            && now != before
1021        {
1022            request.add_property(plane_id, property_id, value)?;
1023            written += 1;
1024        }
1025    }
1026    Ok(written)
1027}
1028
1029/// What to do with a layer's acquire fence, given what its plane can take.
1030#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1031#[non_exhaustive]
1032pub enum FenceAction {
1033    /// Hand the descriptor to KMS as `IN_FENCE_FD`.
1034    Arm {
1035        /// The plane's `IN_FENCE_FD` property.
1036        property_id: u32,
1037    },
1038    /// The plane cannot take one, so the caller has to wait before committing.
1039    CpuWait,
1040}
1041
1042/// Decide between the two, from whether the plane exposes a writable
1043/// `IN_FENCE_FD`.
1044///
1045/// Split out so the decision is testable without a device: the fallback is the
1046/// branch that matters and the one a card that advertises `IN_FENCE_FD` on
1047/// every plane -- vkms among them -- can never reach.
1048#[must_use]
1049pub const fn fence_action(property_id: Option<u32>) -> FenceAction {
1050    match property_id {
1051        Some(property_id) => FenceAction::Arm { property_id },
1052        None => FenceAction::CpuWait,
1053    }
1054}
1055
1056/// Give each fenced layer's fence to its plane, or wait on it here.
1057///
1058/// A buffer's `acquire_fence` says its pixels are not valid yet. Where the
1059/// assigned plane exposes `IN_FENCE_FD` the descriptor goes to KMS and the
1060/// kernel holds scanout until it signals. Where it does not, there is nowhere
1061/// to put it, so the wait happens here instead -- on the CPU, before the
1062/// commit, which is slower but is the difference between a stalled frame and a
1063/// half-rendered one reaching the screen.
1064///
1065/// This runs after allocation on purpose. Until a layer has a plane there is
1066/// nothing to ask about `IN_FENCE_FD`, which is why the fence cannot simply be
1067/// lowered into the property bag with everything else.
1068///
1069/// `real` gates the CPU wait only: a `TEST_ONLY` commit never scans out, so
1070/// blocking on a fence for one would stall the search for no reason. The
1071/// property write is not gated -- a test should carry what the apply will.
1072///
1073/// # Errors
1074///
1075/// [`CoreError`] if a property write is rejected before the commit.
1076pub fn arm_acquire_fences(
1077    build: &mut crate::FrameBuild,
1078    request: &mut drmkit_core::AtomicRequest,
1079    map: &PlanePropertyMap,
1080    real: bool,
1081) -> Result<(), CoreError> {
1082    let mut armed = 0;
1083    let mut waits = 0;
1084
1085    // Driven from the acquisitions rather than the plan, because the fence
1086    // itself lives there. Carrying a bare descriptor on the plan instead would
1087    // mean reconstructing a borrow from an integer, and the whole reason this
1088    // is safe is that the buffer -- and so the fence -- is owned by the frame
1089    // being built.
1090    for acquisition in &build.acquisitions {
1091        let Some(fence) = acquisition.buffer.acquire_fence.as_ref() else {
1092            continue;
1093        };
1094        let Some(fd) = drmkit_sync::SyncFence::as_fd(fence) else {
1095            continue;
1096        };
1097        // A layer with no plane was composited or dropped. There is nothing to
1098        // arm, and the buffer's own lifecycle still owns the fence.
1099        let Some(entry) = build
1100            .plan
1101            .iter()
1102            .find(|entry| entry.layer_id == acquisition.layer)
1103        else {
1104            continue;
1105        };
1106
1107        match fence_action(map.property_id(entry.plane_id, PropTag::InFenceFd)) {
1108            FenceAction::Arm { property_id } => {
1109                let raw = std::os::fd::AsRawFd::as_raw_fd(&fd);
1110                request.add_property(
1111                    entry.plane_id,
1112                    property_id,
1113                    u64::from(raw.cast_unsigned()),
1114                )?;
1115                armed += 1;
1116            }
1117            FenceAction::CpuWait => {
1118                if !real {
1119                    continue;
1120                }
1121                waits += 1;
1122                // Reported, not fatal. The producer may never signal this
1123                // fence, and refusing to commit would wedge the display on one
1124                // bad frame rather than showing it.
1125                if let Err(error) = fence.wait(std::time::Duration::from_secs(1)) {
1126                    drmkit_log::log_warn!("acquire-fence CPU wait failed: {error}");
1127                }
1128            }
1129        }
1130    }
1131
1132    build.note_fences(armed, waits);
1133    Ok(())
1134}