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<'_> {}