Skip to main content

drmkit_present/
backend.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! The whole scanout path behind one object.
5//!
6//! Port of `src/present/scanout_backend.{hpp,cpp}`.
7
8use std::os::fd::OwnedFd;
9
10use drm::control::Device as ControlDevice;
11use drmkit_core::{
12    AtomicCommitFlags, AtomicRequest, Device, Mode, ObjectType, PropertyBlob, PropertyStore,
13    commit_with_out_fence,
14};
15use drmkit_display::{DriverProfile, ScanoutTarget};
16use drmkit_modeset::ModeInfo as _;
17use drmkit_planes::PlaneRegistry;
18use drmkit_scene::{
19    CommitKind, CommitReport, DeviceCommitter, DisplayParams, KernelResult, LayerHandle,
20    LayerScene, Modeset, PlanePropertyMap, arm_acquire_fences, emit_frame_damaged,
21};
22use drmkit_sync::SyncFence;
23
24use crate::{FrameAction, FrameEconomy, PresentError, ScanoutProducer, negotiate};
25
26/// When to ask for variable refresh.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28#[non_exhaustive]
29pub enum VrrPolicy {
30    /// Never arm `VRR_ENABLED`.
31    #[default]
32    Off,
33    /// Arm it when the driver profile reports the CRTC capable.
34    Auto,
35    /// Arm it regardless -- a no-op on a CRTC that does not expose it.
36    On,
37}
38
39/// What to leave on screen when the backend goes away.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41#[non_exhaustive]
42pub enum RestorePolicy {
43    /// Leave the last committed frame up.
44    #[default]
45    None,
46    /// Put back the CRTC configuration captured at
47    /// [`create`](ScanoutBackend::create).
48    ///
49    /// What a display board wants on teardown: the console comes back rather
50    /// than the screen freezing on whatever was committed last.
51    SavedCrtc,
52}
53
54/// How to build a scanout backend.
55#[derive(Debug, Clone, Copy)]
56#[allow(
57    clippy::exhaustive_structs,
58    reason = "built by callers with `..Default::default()`, which a new field does not break"
59)]
60pub struct Config {
61    /// The `FourCC` to allocate and scan out.
62    pub fourcc: u32,
63    /// The rotation the layer will be presented with, which the modifier
64    /// negotiation filters against -- a tiled layout a plane cannot rotate is
65    /// not a candidate for a rotated layer.
66    pub rotation: drmkit_fmt::Rotation,
67    /// Whether to ask for variable refresh.
68    pub vrr: VrrPolicy,
69    /// What to leave on screen at teardown.
70    pub restore: RestorePolicy,
71    /// The connector to drive; `0` for the first connected one. See
72    /// [`ScanoutTarget::discover_connector`].
73    pub connector_id: u32,
74}
75
76impl Default for Config {
77    fn default() -> Self {
78        Self {
79            fourcc: drmkit_fmt::fourcc::XRGB8888,
80            rotation: drmkit_fmt::Rotation::Rotate0,
81            vrr: VrrPolicy::Off,
82            restore: RestorePolicy::None,
83            connector_id: 0,
84        }
85    }
86}
87
88/// The CRTC configuration as it stood before the backend touched it.
89#[derive(Debug, Clone, Copy)]
90struct SavedCrtc {
91    handle: drm::control::crtc::Handle,
92    framebuffer: Option<drm::control::framebuffer::Handle>,
93    position: (u32, u32),
94    mode: Option<Mode>,
95}
96
97/// One output, one layer, and everything between a producer and the screen.
98///
99/// Discovers the output, reads the driver's profile, negotiates a modifier
100/// against what the CRTC's planes can scan out, asks the producer for a buffer
101/// in one of them, and drives a single-layer scene over it. What is left for
102/// the caller is rendering and pacing.
103///
104/// The backend does **not** dispatch the page-flip event, for the reason
105/// `DumbScanoutSink` does not: the scene deliberately has no `commit()` of its
106/// own, so the caller drives its own `PageFlip` and calls
107/// [`flip_landed`](Self::flip_landed).
108///
109/// # Teardown
110///
111/// Dropping the backend while a flip it armed is still outstanding waits, up to
112/// 100 ms, for the CRTC's vblank sequence to move past that commit before the
113/// scene lets go of its buffers. It never reads the event queue, so the event
114/// stays queued for the caller. Dispatching it and calling
115/// [`flip_landed`](Self::flip_landed) first means there is nothing to wait for.
116/// The device must outlive the backend: the wait reads the sequence on it.
117pub struct ScanoutBackend {
118    target: ScanoutTarget,
119    profile: DriverProfile,
120    modifiers: Vec<u64>,
121    scene: LayerScene,
122    layer: LayerHandle,
123    registry: PlaneRegistry,
124    map: PlanePropertyMap,
125    economy: FrameEconomy,
126    crtc_index: u32,
127    /// Resolved once at create; `None` on a CRTC that exposes no `VRR_ENABLED`.
128    vrr_property: Option<u32>,
129    /// What the caller last asked for, and what was last written -- a change
130    /// between them is what needs `ALLOW_MODESET`.
131    vrr_wanted: bool,
132    vrr_armed: bool,
133    /// Resolved once at create; `None` on a CRTC with no `OUT_FENCE_PTR`.
134    out_fence_property: Option<u32>,
135    needs_modeset: bool,
136    restore: RestorePolicy,
137    saved: Option<SavedCrtc>,
138    /// The descriptor the restore and the teardown flip wait are issued on.
139    /// Held raw because `Drop` has no device argument, and the restore is a
140    /// legacy ioctl either way.
141    device_fd: std::os::fd::RawFd,
142    /// What teardown waits on for the last commit's flip: the CRTC's vblank
143    /// sequence right after it, or only its event where the CRTC has no
144    /// counter. `None` when that commit armed no flip.
145    armed_flip_seq: Option<crate::armed_flip::ArmedFlip>,
146}
147
148impl std::fmt::Debug for ScanoutBackend {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("ScanoutBackend")
151            .field("driver", &self.profile.name)
152            .field("crtc_id", &self.target.crtc.id)
153            .field("modifiers", &self.modifiers.len())
154            .field("vrr", &self.vrr_armed)
155            .finish_non_exhaustive()
156    }
157}
158
159impl ScanoutBackend {
160    /// Discover an output and build everything needed to present on it.
161    ///
162    /// # Errors
163    ///
164    /// [`PresentError::Bind`] if no output can be found, the planes cannot be
165    /// probed, or the producer cannot allocate.
166    pub fn create(
167        device: &Device,
168        producer: &mut dyn ScanoutProducer,
169        config: &Config,
170    ) -> Result<Self, PresentError> {
171        let target = ScanoutTarget::discover_connector(device, config.connector_id)
172            .map_err(|error| PresentError::Bind(format!("discovering an output: {error}")))?
173            .map_err(|error| PresentError::Bind(format!("discovering an output: {error}")))?;
174        let profile = DriverProfile::probe(device)
175            .map_err(|error| PresentError::Bind(format!("probing the driver: {error}")))?;
176
177        let crtc_id = target.crtc.id;
178        let crtc_index = target.crtc.index;
179        let (width, height) = (target.mode.width(), target.mode.height());
180
181        let registry = PlaneRegistry::probe(device)
182            .map_err(|error| PresentError::Bind(format!("probing planes: {error}")))?;
183        let mut map = PlanePropertyMap::new();
184        for plane in registry.for_crtc(crtc_index) {
185            map.learn_plane(device, plane.id)
186                .map_err(|error| PresentError::Bind(format!("plane {}: {error}", plane.id)))?;
187        }
188
189        // Negotiate against the union across every non-cursor plane on this
190        // CRTC, not the primary's IN_FORMATS alone -- see
191        // `PlaneRegistry::candidate_modifiers` for the split-SoC case that
192        // makes the difference.
193        let plane_modifiers = registry.candidate_modifiers(crtc_index, config.fourcc);
194        let producer_modifiers = producer.exportable_modifiers(config.fourcc);
195        let modifiers = if plane_modifiers.is_empty() {
196            // No plane advertises IN_FORMATS. That is "this driver does not
197            // say", not "nothing works", so the producer is left unconstrained
198            // and the commit is what finds out.
199            Vec::new()
200        } else {
201            let producer: Vec<drmkit_fmt::Modifier> = producer_modifiers
202                .iter()
203                .copied()
204                .map(drmkit_fmt::Modifier)
205                .collect();
206            let planes: Vec<drmkit_fmt::Modifier> = plane_modifiers
207                .iter()
208                .copied()
209                .map(drmkit_fmt::Modifier)
210                .collect();
211            negotiate(&producer, &planes, config.rotation)
212                .into_iter()
213                .map(|modifier| modifier.0)
214                .collect()
215        };
216
217        let source = producer
218            .create_buffer(width, height, config.fourcc, &modifiers)
219            .map_err(|error| PresentError::Bind(format!("allocating: {error}")))?;
220
221        let mut scene = LayerScene::new(crtc_id);
222        let layer = scene.add_layer(source);
223        scene
224            .layer_mut(layer)
225            .ok_or_else(|| PresentError::Bind("the layer vanished".to_owned()))?
226            .set_display(DisplayParams {
227                src_rect: drmkit_planes::Rect {
228                    x: 0,
229                    y: 0,
230                    w: width,
231                    h: height,
232                },
233                dst_rect: drmkit_planes::Rect {
234                    x: 0,
235                    y: 0,
236                    w: width,
237                    h: height,
238                },
239                ..DisplayParams::default()
240            });
241
242        let mut store = PropertyStore::new();
243        let (vrr_property, out_fence_property) = if store
244            .cache_properties(device, crtc_id, ObjectType::Crtc)
245            .is_ok()
246        {
247            (
248                store.property_id(crtc_id, "VRR_ENABLED").ok(),
249                store.property_id(crtc_id, "OUT_FENCE_PTR").ok(),
250            )
251        } else {
252            (None, None)
253        };
254
255        let vrr_wanted = match config.vrr {
256            VrrPolicy::Off => false,
257            VrrPolicy::On => true,
258            VrrPolicy::Auto => profile.vrr_capable,
259        };
260
261        let saved = (config.restore == RestorePolicy::SavedCrtc)
262            .then(|| Self::snapshot_crtc(device, crtc_id))
263            .flatten();
264
265        Ok(Self {
266            target,
267            profile,
268            modifiers,
269            scene,
270            layer,
271            registry,
272            map,
273            economy: FrameEconomy::new(),
274            crtc_index,
275            vrr_property,
276            vrr_wanted,
277            vrr_armed: false,
278            out_fence_property,
279            needs_modeset: true,
280            restore: config.restore,
281            saved,
282            device_fd: device.raw_fd(),
283            armed_flip_seq: None,
284        })
285    }
286
287    /// Capture the CRTC as it stands, for [`RestorePolicy::SavedCrtc`].
288    ///
289    /// Read through the legacy CRTC ioctl rather than atomic properties
290    /// because that is what the restore writes back through: an atomic commit
291    /// does not update the legacy framebuffer field, so a snapshot taken any
292    /// other way would not describe what `set_crtc` can put back.
293    fn snapshot_crtc(device: &Device, crtc_id: u32) -> Option<SavedCrtc> {
294        let handle = drm::control::crtc::Handle::from(std::num::NonZeroU32::new(crtc_id)?);
295        let info = device.get_crtc(handle).ok()?;
296        Some(SavedCrtc {
297            handle,
298            framebuffer: info.framebuffer(),
299            position: info.position(),
300            mode: info.mode(),
301        })
302    }
303
304    /// Present one frame.
305    ///
306    /// `out_fence` receives this commit's `OUT_FENCE` where the CRTC exposes
307    /// `OUT_FENCE_PTR` — it signals once the frame is on screen, which is what
308    /// a producer waits on rather than blocking the CPU until the flip event.
309    ///
310    /// # Errors
311    ///
312    /// [`PresentError::Build`] if the scene cannot build the frame,
313    /// [`PresentError::Commit`] if the kernel refuses it.
314    pub fn present(
315        &mut self,
316        device: &Device,
317        flags: AtomicCommitFlags,
318        out_fence: Option<&mut Option<OwnedFd>>,
319    ) -> Result<CommitReport, PresentError> {
320        // An unconditional present is a full commit by definition, and it
321        // still has to be counted -- `frames_committed` reports every frame
322        // that reached the kernel, not only the gated ones. Running it through
323        // the economy is what keeps one counter authoritative for both paths.
324        self.economy.force_full();
325        let _ = self.economy.decide(true, false);
326        self.commit(device, flags, out_fence)
327    }
328
329    /// Present only if the content changed, suppressing an idle frame.
330    ///
331    /// The **first** frame always commits, whatever `content_changed` says:
332    /// nothing is on screen yet, so "unchanged" describes nothing, and a
333    /// suppressed first frame leaves the scanout contents undefined.
334    ///
335    /// A suppressed frame returns a report with
336    /// [`skipped_idle`](CommitReport::skipped_idle) set and every other field
337    /// zero — there was no commit to count.
338    ///
339    /// # Errors
340    ///
341    /// As [`present`](Self::present).
342    pub fn present_if_changed(
343        &mut self,
344        device: &Device,
345        content_changed: bool,
346        flags: AtomicCommitFlags,
347        out_fence: Option<&mut Option<OwnedFd>>,
348    ) -> Result<CommitReport, PresentError> {
349        // `decide` keeps the counters itself, so this is the only place they
350        // move on the gated path.
351        match self.economy.decide(content_changed, false) {
352            FrameAction::Skip => Ok(CommitReport::suppressed()),
353            FrameAction::CommitFull | FrameAction::CommitDamaged => {
354                self.commit(device, flags, out_fence)
355            }
356        }
357    }
358
359    /// Build, emit and commit one frame.
360    fn commit(
361        &mut self,
362        device: &Device,
363        flags: AtomicCommitFlags,
364        out_fence: Option<&mut Option<OwnedFd>>,
365    ) -> Result<CommitReport, PresentError> {
366        // A local, not a field: it holds a `PropertyBlob` borrowed from the
367        // device, and storing one would put that lifetime on this type and
368        // everything holding it.
369        let modeset = if self.needs_modeset {
370            Some(
371                Modeset::learn(
372                    device,
373                    self.target.crtc.id,
374                    self.target.connector_id,
375                    &self.target.mode,
376                )
377                .map_err(|error| PresentError::Bind(format!("learning the mode: {error}")))?,
378            )
379        } else {
380            None
381        };
382
383        // Enabling variable refresh is a mode change on most drivers, so the
384        // transition needs ALLOW_MODESET even when the mode itself is already
385        // set. Disabling it is not, but the same flag is harmless there and
386        // the alternative is a branch that gets the direction wrong.
387        let vrr_changing = self.vrr_property.is_some() && self.vrr_wanted != self.vrr_armed;
388
389        let mut flags = flags;
390        if modeset.is_some() || vrr_changing {
391            flags |= AtomicCommitFlags::ALLOW_MODESET;
392        }
393
394        let mut committer = DeviceCommitter::new(
395            device,
396            &self.map,
397            &self.registry,
398            self.crtc_index,
399            AtomicCommitFlags::empty(),
400            modeset.as_ref(),
401        );
402
403        // A flip is armed only if this commit actually asked for the event.
404        // Saying otherwise leaves the scene waiting for a completion that will
405        // never arrive: it holds every acquisition open, and teardown then
406        // trips invariant 5 over a flip that was never in flight.
407        let arms_flip = flags.contains(AtomicCommitFlags::PAGE_FLIP_EVENT);
408        let mut build = self.scene.build_frame(
409            &self.registry,
410            self.crtc_index,
411            CommitKind::Real { arms_flip },
412            &mut committer,
413        )?;
414
415        let crtc_id = self.target.crtc.id;
416        let vrr = (self.vrr_property, self.vrr_wanted);
417        // The fence is armed if the caller asked for it, or if a source did:
418        // a producer that opted into release fences needs one whether or not
419        // this particular caller wants to see it.
420        let wanted_internally = self.scene.wants_release_fence();
421        let out_fence_property = (out_fence.is_some() || wanted_internally)
422            .then_some(self.out_fence_property)
423            .flatten();
424
425        // The plane ids are taken before the closure: the commit reports them
426        // back afterwards, and `build` is borrowed mutably inside.
427        let programmed: Vec<_> = build
428            .plan()
429            .iter()
430            .map(|entry| (entry.plane_id, entry.color))
431            .collect();
432
433        let emit = |request: &mut AtomicRequest,
434                    slot: u64|
435         -> Result<Vec<PropertyBlob<'_>>, drmkit_core::CoreError> {
436            // The blobs are handed back for `commit_with_out_fence` to hold
437            // across the commit: the request holds only their ids, and the
438            // end of this closure comes before the ioctl, not after it.
439            let (_, damage_blobs) = emit_frame_damaged(
440                request,
441                &self.map,
442                &mut build,
443                modeset.as_ref(),
444                Some(device),
445            )?;
446            arm_acquire_fences(&mut build, request, &self.map, true)?;
447            if let (Some(property), wanted) = vrr {
448                request.add_property(crtc_id, property, u64::from(wanted))?;
449            }
450            if let Some(property) = out_fence_property {
451                request.add_property(crtc_id, property, slot)?;
452            }
453            Ok(damage_blobs)
454        };
455
456        let outcome = commit_with_out_fence(device, flags, emit);
457
458        let fence = match outcome {
459            Ok(fence) => fence,
460            Err(error) => {
461                // The scene still has to be told, or its acquisitions leak and
462                // the next frame builds on a lie.
463                self.scene.finalize_frame(build, KernelResult::Rejected);
464                return Err(PresentError::Commit(error));
465            }
466        };
467        // Imported before it is handed over, because both halves may want it:
468        // the sources get a duplicate each, and the caller keeps the original.
469        let release_fence = fence
470            .as_ref()
471            .and_then(|fd| SyncFence::import(std::os::fd::AsFd::as_fd(fd)).ok());
472        if let Some(slot) = out_fence {
473            *slot = fence;
474        }
475
476        for (plane_id, color) in programmed {
477            self.map.note_color_committed(plane_id, color);
478        }
479        self.needs_modeset = false;
480        self.vrr_armed = self.vrr_wanted;
481        self.armed_flip_seq = crate::armed_flip::armed_sequence(device, crtc_id, arms_flip);
482        Ok(self
483            .scene
484            .finalize_frame_with_fence(build, KernelResult::Ok, release_fence.as_ref()))
485    }
486
487    /// Ask for variable refresh, or stop asking, from the next frame.
488    ///
489    /// A no-op on a CRTC that exposes no `VRR_ENABLED`.
490    pub const fn set_vrr(&mut self, enable: bool) {
491        self.vrr_wanted = enable;
492    }
493
494    /// Whether the discovered output can do variable refresh.
495    #[must_use]
496    pub const fn vrr_capable(&self) -> bool {
497        self.profile.vrr_capable
498    }
499
500    /// Make the next frame a full commit, whatever the economy would decide.
501    pub const fn force_full_present(&mut self) {
502        self.economy.force_full();
503    }
504
505    /// How many frames reached the kernel.
506    #[must_use]
507    pub const fn frames_committed(&self) -> u64 {
508        self.economy.committed()
509    }
510
511    /// How many frames were suppressed as idle.
512    #[must_use]
513    pub const fn frames_skipped(&self) -> u64 {
514        self.economy.skipped()
515    }
516
517    /// The output this backend found.
518    #[must_use]
519    pub const fn target(&self) -> &ScanoutTarget {
520        &self.target
521    }
522
523    /// The driver's capabilities, as probed.
524    #[must_use]
525    pub const fn profile(&self) -> &DriverProfile {
526        &self.profile
527    }
528
529    /// The negotiated modifiers the producer was allowed to allocate in.
530    ///
531    /// Empty when no plane advertised `IN_FORMATS`, which is *unconstrained*
532    /// rather than *nothing was acceptable*.
533    #[must_use]
534    pub fn modifiers(&self) -> &[u64] {
535        &self.modifiers
536    }
537
538    /// The scene, for a caller adding layers of its own.
539    #[must_use]
540    pub const fn scene(&self) -> &LayerScene {
541        &self.scene
542    }
543
544    /// The scene, mutably.
545    pub const fn scene_mut(&mut self) -> &mut LayerScene {
546        &mut self.scene
547    }
548
549    /// The layer the producer's buffer is presented on.
550    #[must_use]
551    pub const fn layer(&self) -> LayerHandle {
552        self.layer
553    }
554
555    /// The discovered connector, as a handle, for the teardown restore.
556    fn restore_connector(&self) -> Vec<drm::control::connector::Handle> {
557        std::num::NonZeroU32::new(self.target.connector_id)
558            .map(drm::control::connector::Handle::from)
559            .into_iter()
560            .collect()
561    }
562
563    /// Tell the scene the flip it armed has landed.
564    ///
565    /// Until this is called the scene believes a flip is outstanding, which is
566    /// what keeps teardown from releasing a buffer the display engine is still
567    /// reading — invariant 5.
568    pub fn flip_landed(&mut self) {
569        self.scene.flip_landed();
570    }
571}
572
573impl Drop for ScanoutBackend {
574    fn drop(&mut self) {
575        // SAFETY: the descriptor belongs to the device this backend was built
576        // against, which outlives it -- the borrow is only for this body.
577        let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(self.device_fd) };
578        // A flip the last commit armed may still be reading the buffers the
579        // scene releases below. Let it land first, bounded, without touching
580        // the event queue.
581        crate::armed_flip::settle(
582            &mut self.scene,
583            &borrowed,
584            self.target.crtc.id,
585            self.armed_flip_seq,
586        );
587
588        if self.restore != RestorePolicy::SavedCrtc {
589            return;
590        }
591        let Some(saved) = self.saved else { return };
592
593        // Tear the scene down first, so the restore below reprograms a
594        // pipeline that no longer references this backend's framebuffers.
595        // Rust drops fields after this body runs, which is the wrong order, so
596        // the scene is swapped out and dropped here explicitly.
597        drop(std::mem::replace(
598            &mut self.scene,
599            LayerScene::new(self.target.crtc.id),
600        ));
601
602        // A legacy modeset, because that is what the snapshot describes: the
603        // atomic commits this backend issues never touched the legacy
604        // framebuffer field, so putting back what was read from it is the only
605        // thing that restores what was on screen.
606        let device = RawDevice { fd: borrowed };
607        // No saved framebuffer means the CRTC was dark before this backend took
608        // it over, so put it back dark -- no connector, no mode -- rather than
609        // lighting up a framebuffer that never existed. With one, the connector
610        // has to come along: a legacy modeset naming a framebuffer and no
611        // connector is refused, which is a restore that silently does nothing.
612        let (connectors, mode) = if saved.framebuffer.is_some() {
613            (self.restore_connector(), saved.mode)
614        } else {
615            (Vec::new(), None)
616        };
617        // Errors are unactionable in a destructor, and a failed restore leaves
618        // the last frame up -- which is what RestorePolicy::None would have
619        // done anyway.
620        let _ = device.set_crtc(
621            saved.handle,
622            saved.framebuffer,
623            saved.position,
624            &connectors,
625            mode,
626        );
627    }
628}
629
630/// A bare descriptor wrapper, so the destructor can issue one ioctl without
631/// owning a `Device`.
632struct RawDevice<'a> {
633    fd: std::os::fd::BorrowedFd<'a>,
634}
635
636impl std::os::fd::AsFd for RawDevice<'_> {
637    fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
638        self.fd
639    }
640}
641
642impl drm::Device for RawDevice<'_> {}
643impl ControlDevice for RawDevice<'_> {}