Skip to main content

drmkit_dumb/
buffer.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! The dumb-buffer handle.
5//!
6//! Port of `src/dumb/buffer.{hpp,cpp}`.
7
8use std::os::fd::{AsFd as _, BorrowedFd, OwnedFd};
9use std::ptr::NonNull;
10
11use drm::buffer::DrmFourcc;
12use drm::control::{Device as ControlDevice, FbCmd2Flags, framebuffer};
13use drmkit_core::Device;
14use drmkit_fmt::fourcc;
15
16use crate::error::{DumbError, Result};
17use crate::mapping::{MapAccess, Mapping};
18
19/// Past every shipping camera and display maximum. Rejecting beyond it keeps
20/// the row and byte-count computations from wrapping on hostile or quirky
21/// dimensions fed in from a driver-controlled V4L2 format echo.
22const MAX_IMAGE_DIM: u32 = 16384;
23
24/// What the kernel reported for a `CREATE_DUMB` allocation.
25///
26/// Kept rather than using the `drm` crate's `DumbBuffer`, which stores the
27/// kernel's `size` and `pitch` in private fields with no accessors — and whose
28/// mapping helper re-`mmap`s per call, which invariant 6 forbids. `drm-ffi` is
29/// still the Smithay binding (DR-2); this reaches one layer down, not around.
30#[derive(Debug, Clone, Copy)]
31struct Allocation {
32    handle: u32,
33    /// The kernel's own byte count, which may exceed `pitch * height` when the
34    /// driver pads. Mapping less than this would under-map the object.
35    size: u64,
36}
37
38/// A `mmap`'d region owned for a buffer's lifetime.
39///
40/// Held rather than re-established per frame — see [`Mapping`] and invariant 6.
41#[derive(Debug)]
42struct Mmap {
43    ptr: NonNull<u8>,
44    len: usize,
45}
46
47// SAFETY: the region is owned exclusively by the `Buffer` holding it, and the
48// kernel's dumb-buffer mapping is coherent across threads. Nothing here is
49// thread-affine.
50unsafe impl Send for Mmap {}
51// SAFETY: as above; `&Mmap` hands out no interior mutability of its own.
52unsafe impl Sync for Mmap {}
53
54impl Mmap {
55    fn as_slice(&self) -> &[u8] {
56        // SAFETY: `ptr` is a live mapping of `len` bytes established in
57        // `map_region` and unmapped only in `Drop`.
58        unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
59    }
60
61    fn as_mut_slice(&mut self) -> &mut [u8] {
62        // SAFETY: as above; `&mut self` guarantees exclusive access.
63        unsafe { std::slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) }
64    }
65}
66
67impl Drop for Mmap {
68    fn drop(&mut self) {
69        // SAFETY: unmapping exactly the region established in `map_region`.
70        // munmap is descriptor-independent, so this stays correct even after
71        // the originating DRM descriptor has been closed -- which is what
72        // makes `Buffer::forget` able to tear the mapping down.
73        unsafe {
74            let _ = rustix::mm::munmap(self.ptr.as_ptr().cast(), self.len);
75        }
76    }
77}
78
79/// How a dumb buffer should be allocated.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[allow(
82    clippy::exhaustive_structs,
83    reason = "built by callers with `..Default::default()`, which a new field does not break"
84)]
85pub struct Config {
86    /// Width in pixels.
87    pub width: u32,
88    /// Height in pixels.
89    pub height: u32,
90    /// DRM `FourCC`.
91    pub fourcc: u32,
92    /// Bits per pixel the kernel uses to size the allocation. Must match the
93    /// format.
94    pub bpp: u32,
95    /// Whether to register a KMS framebuffer after allocating.
96    ///
97    /// Set false for the legacy `drmModeSetCursor` path, which consumes the
98    /// raw GEM handle and needs no framebuffer id.
99    pub add_fb: bool,
100}
101
102impl Default for Config {
103    fn default() -> Self {
104        Self {
105            width: 0,
106            height: 0,
107            fourcc: 0,
108            bpp: 32,
109            add_fb: true,
110        }
111    }
112}
113
114/// Geometry of a recognized semi-planar layout.
115struct PlanarGeometry {
116    /// Bits per sample the kernel uses to size the pitch.
117    bpp: u32,
118    /// Extra rows for the chroma plane, as a fraction of the image height.
119    extra_rows_num: u64,
120    extra_rows_den: u64,
121}
122
123/// The semi-planar 4:2:0 formats [`Buffer::create_planar`] handles.
124const fn planar_geometry(fourcc_code: u32) -> Option<PlanarGeometry> {
125    match fourcc_code {
126        // 8-bit semi-planar 4:2:0. A Y row and a UV row are both `stride`
127        // bytes wide (Cb/Cr interleaved at half horizontal resolution cover
128        // the same byte count); the UV plane is height/2 rows.
129        fourcc::NV12 | fourcc::NV21 => Some(PlanarGeometry {
130            bpp: 8,
131            extra_rows_num: 1,
132            extra_rows_den: 2,
133        }),
134        // 16-bit semi-planar 4:2:0. Same row arrangement, u16 samples. The
135        // bpp difference is what sizes the pitch; the plane ratio is the same.
136        fourcc::P010 | fourcc::P012 | fourcc::P016 => Some(PlanarGeometry {
137            bpp: 16,
138            extra_rows_num: 1,
139            extra_rows_den: 2,
140        }),
141        _ => None,
142    }
143}
144
145/// An owned dumb buffer, its optional KMS framebuffer, and its CPU mapping.
146///
147/// Dumb buffers are KMS's GPU-free allocation path: any driver implementing
148/// `DRM_CAP_DUMB_BUFFER` — every modern KMS driver — can allocate a
149/// CPU-mappable linear pixel buffer with no accelerator involved. They are the
150/// right tool for cursors, client-side decorations, software-rendered UI, and
151/// test harnesses.
152///
153/// # Deviation: the mapping is established once, not per `map()`
154///
155/// The `drm` crate's `map_dumb_buffer` performs an `mmap` on every call and its
156/// guard `munmap`s on drop. That is the opposite of what **invariant 6**
157/// requires — one mapping held for the buffer's lifetime, and a free `map()`
158/// per frame. So the allocation and destruction ioctls come from the `drm`
159/// crate (DR-2), while the mapping is established once here at creation and
160/// held. See [`Mapping`] for why the cost contract matters.
161#[derive(Debug)]
162pub struct Buffer {
163    /// The device file the buffer was allocated on, held for teardown.
164    ///
165    /// A duplicate rather than the number: after a session loss the caller's
166    /// descriptor closes and the next open may take its number, and the kernel
167    /// hands out freed GEM handles and framebuffer ids again. A teardown
168    /// through the number would destroy the new file's objects. Through this,
169    /// it reaches the file these belong to. `None` once [`Buffer::forget`] has
170    /// run or the buffer is empty.
171    fd: Option<OwnedFd>,
172    alloc: Option<Allocation>,
173    fb: Option<framebuffer::Handle>,
174    mapping: Option<Mmap>,
175    width: u32,
176    height: u32,
177    stride: u32,
178}
179
180impl Default for Buffer {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl Buffer {
187    /// An empty buffer holding no resources.
188    #[must_use]
189    pub const fn new() -> Self {
190        Self {
191            fd: None,
192            alloc: None,
193            fb: None,
194            mapping: None,
195            width: 0,
196            height: 0,
197            stride: 0,
198        }
199    }
200
201    /// Allocate a single-plane dumb buffer.
202    ///
203    /// # Errors
204    ///
205    /// - [`DumbError::InvalidConfig`] for zero dimensions, a zero format, a
206    ///   zero bpp, dimensions past 16384, or a `FourCC` the `drm` crate does
207    ///   not recognize.
208    /// - [`DumbError::TooLarge`] if the kernel's allocation size does not fit
209    ///   a `usize`.
210    /// - [`DumbError::Io`] if an ioctl or the `mmap` fails.
211    pub fn create(device: &Device, config: &Config) -> Result<Self> {
212        if config.width == 0 || config.height == 0 {
213            return Err(DumbError::InvalidConfig {
214                reason: "width and height must be non-zero",
215            });
216        }
217        if config.fourcc == 0 {
218            return Err(DumbError::InvalidConfig {
219                reason: "format must be non-zero",
220            });
221        }
222        if config.bpp == 0 {
223            return Err(DumbError::InvalidConfig {
224                reason: "bits per pixel must be non-zero",
225            });
226        }
227        if config.width > MAX_IMAGE_DIM || config.height > MAX_IMAGE_DIM {
228            return Err(DumbError::InvalidConfig {
229                reason: "dimensions exceed 16384",
230            });
231        }
232
233        let fourcc_typed =
234            DrmFourcc::try_from(config.fourcc).map_err(|_| DumbError::InvalidConfig {
235                reason: "format is not a recognized DRM FourCC",
236            })?;
237
238        // Before the allocation, so a failure here leaks nothing.
239        let file = duplicate(device)?;
240        let created = drm_ffi::mode::dumbbuffer::create(
241            device.as_fd(),
242            config.width,
243            config.height,
244            config.bpp,
245            0,
246        )
247        .map_err(|e| io_to_errno(&e))?;
248
249        // Anything failing past this point must release the GEM allocation, or
250        // the buffer leaks until the descriptor closes.
251        let mut buffer = Self {
252            fd: Some(file),
253            width: config.width,
254            height: config.height,
255            stride: created.pitch,
256            alloc: Some(Allocation {
257                handle: created.handle,
258                size: created.size,
259            }),
260            fb: None,
261            mapping: None,
262        };
263
264        buffer.mapping = Some(buffer.map_region(device)?);
265
266        if config.add_fb {
267            let planes = PlaneLayout::single(&buffer, fourcc_typed);
268            let fb = device
269                .add_planar_framebuffer(&planes, FbCmd2Flags::empty())
270                .map_err(|e| io_to_errno(&e))?;
271            buffer.fb = Some(fb);
272        }
273
274        Ok(buffer)
275    }
276
277    /// Allocate a dumb buffer for a recognized semi-planar YUV format —
278    /// `NV12`, `NV21`, `P010`, `P012`, or `P016`.
279    ///
280    /// The kernel allocates one linear region; the framebuffer points the Y and
281    /// interleaved-UV planes at distinct byte offsets within it. Pass the
282    /// **image** dimensions: the over-allocation for the half-height chroma
283    /// plane and the 8- versus 16-bit sample width are derived internally.
284    ///
285    /// `height` should be even. The chroma half-height uses integer division,
286    /// so an odd height rounds down.
287    ///
288    /// # Errors
289    ///
290    /// - [`DumbError::UnsupportedFormat`] for a format outside the recognized
291    ///   set — fall back to [`Buffer::create`] for single-plane formats.
292    /// - [`DumbError::InvalidConfig`] for zero or out-of-range dimensions.
293    /// - [`DumbError::TooLarge`] if the row count or chroma offset would not
294    ///   fit the kernel's 32-bit fields, or the allocation size a `usize`.
295    /// - [`DumbError::Io`] if an ioctl or the `mmap` fails.
296    pub fn create_planar(
297        device: &Device,
298        fourcc_code: u32,
299        width: u32,
300        height: u32,
301    ) -> Result<Self> {
302        if width == 0 || height == 0 || width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM {
303            return Err(DumbError::InvalidConfig {
304                reason: "dimensions must be non-zero and at most 16384",
305            });
306        }
307        let geometry = planar_geometry(fourcc_code).ok_or(DumbError::UnsupportedFormat {
308            fourcc: fourcc_code,
309        })?;
310        let fourcc_typed =
311            DrmFourcc::try_from(fourcc_code).map_err(|_| DumbError::UnsupportedFormat {
312                fourcc: fourcc_code,
313            })?;
314
315        // Over-allocate so the chroma plane fits the same linear region. The
316        // kernel sees one tall buffer; the framebuffer points the chroma plane
317        // at the second part via a per-plane offset. All of this runs in 64-bit
318        // so a hostile geometry cannot wrap, and the kernel's height field is
319        // 32-bit, so a result that would truncate is rejected.
320        let extra_rows = u64::from(height)
321            .checked_mul(geometry.extra_rows_num)
322            .ok_or(DumbError::TooLarge {
323                what: "chroma row count",
324            })?
325            / geometry.extra_rows_den;
326        let total_rows = u64::from(height)
327            .checked_add(extra_rows)
328            .and_then(|rows| u32::try_from(rows).ok())
329            .ok_or(DumbError::TooLarge {
330                what: "total row count",
331            })?;
332
333        // Before the allocation, so a failure here leaks nothing.
334        let file = duplicate(device)?;
335        let created =
336            drm_ffi::mode::dumbbuffer::create(device.as_fd(), width, total_rows, geometry.bpp, 0)
337                .map_err(|e| io_to_errno(&e))?;
338
339        let stride = created.pitch;
340        let mut buffer = Self {
341            fd: Some(file),
342            // The image dimensions, not the over-allocated row count: a
343            // consumer reading `height()` sees the visible image, matching how
344            // the framebuffer is framed.
345            width,
346            height,
347            stride,
348            alloc: Some(Allocation {
349                handle: created.handle,
350                size: created.size,
351            }),
352            fb: None,
353            mapping: None,
354        };
355
356        buffer.mapping = Some(buffer.map_region(device)?);
357
358        // The chroma offset is the Y plane's footprint, computed against the
359        // kernel-reported stride so any alignment padding the driver added is
360        // honored -- amdgpu DC's 256-byte rule lands here, and using the
361        // kernel stride means not having to know about it.
362        let y_size = u64::from(stride) * u64::from(height);
363        let chroma_offset = u32::try_from(y_size).map_err(|_| DumbError::TooLarge {
364            what: "chroma plane offset",
365        })?;
366
367        let planes = PlaneLayout::semi_planar(&buffer, fourcc_typed, chroma_offset);
368        let fb = device
369            .add_planar_framebuffer(&planes, FbCmd2Flags::empty())
370            .map_err(|e| io_to_errno(&e))?;
371        buffer.fb = Some(fb);
372
373        Ok(buffer)
374    }
375
376    /// Establish the lifetime mapping for the freshly allocated GEM object.
377    fn map_region(&self, device: &Device) -> Result<Mmap> {
378        let alloc = self.alloc.ok_or(DumbError::InvalidConfig {
379            reason: "buffer has no allocation to map",
380        })?;
381        let len = usize::try_from(alloc.size).map_err(|_| DumbError::TooLarge {
382            what: "allocation size does not fit a usize",
383        })?;
384
385        let offset = drm_ffi::mode::dumbbuffer::map(device.as_fd(), alloc.handle, 0, 0)
386            .map_err(|e| io_to_errno(&e))?
387            .offset;
388
389        // SAFETY: `offset` is the mmap offset the kernel just reported for this
390        // GEM handle, `len` is the size the kernel reported at creation, and
391        // the descriptor is the one the handle belongs to. The returned region
392        // is owned solely by this `Mmap` and unmapped exactly once, in `Drop`.
393        let ptr = unsafe {
394            rustix::mm::mmap(
395                std::ptr::null_mut(),
396                len,
397                rustix::mm::ProtFlags::READ | rustix::mm::ProtFlags::WRITE,
398                rustix::mm::MapFlags::SHARED,
399                device.as_fd(),
400                offset,
401            )
402        }?;
403
404        let ptr = NonNull::new(ptr.cast::<u8>()).ok_or(DumbError::Io(rustix::io::Errno::NOMEM))?;
405        Ok(Mmap { ptr, len })
406    }
407
408    /// Whether the buffer holds no allocation.
409    #[must_use]
410    pub const fn is_empty(&self) -> bool {
411        self.alloc.is_none()
412    }
413
414    /// Width in pixels.
415    #[must_use]
416    pub const fn width(&self) -> u32 {
417        self.width
418    }
419
420    /// Height in pixels — the image height, not the over-allocated row count of
421    /// a planar buffer.
422    #[must_use]
423    pub const fn height(&self) -> u32 {
424        self.height
425    }
426
427    /// Bytes per row, as the kernel reported it.
428    #[must_use]
429    pub const fn stride(&self) -> u32 {
430        self.stride
431    }
432
433    /// Total mapped bytes.
434    #[must_use]
435    pub fn size_bytes(&self) -> usize {
436        self.mapping.as_ref().map_or(0, |m| m.len)
437    }
438
439    /// The GEM handle on the originating descriptor.
440    ///
441    /// Legacy cursor paths consume this directly; atomic paths use
442    /// [`fb_id`](Self::fb_id).
443    #[must_use]
444    pub fn gem_handle(&self) -> Option<u32> {
445        self.alloc.map(|a| a.handle)
446    }
447
448    /// The KMS framebuffer id, if one was registered.
449    #[must_use]
450    pub fn fb_id(&self) -> Option<u32> {
451        self.fb.map(Into::into)
452    }
453
454    /// The pixel storage, read only.
455    #[must_use]
456    pub fn data(&self) -> &[u8] {
457        self.mapping.as_ref().map_or(&[], Mmap::as_slice)
458    }
459
460    /// The pixel storage, mutable.
461    #[must_use]
462    pub fn data_mut(&mut self) -> &mut [u8] {
463        self.mapping.as_mut().map_or(&mut [], Mmap::as_mut_slice)
464    }
465
466    /// Acquire a scoped CPU view.
467    ///
468    /// Free in both directions — see [`Mapping`] and invariant 6. The access
469    /// mode is recorded on the view but ignored here; it exists so the same
470    /// scoped painting code works against dumb and GBM buffers.
471    ///
472    /// Yields an empty view when the buffer is empty.
473    pub fn map(&mut self, access: MapAccess) -> Mapping<'_> {
474        let (stride, width, height) = (self.stride, self.width, self.height);
475        let pixels = self.data_mut();
476        Mapping::new(pixels, stride, width, height, access)
477    }
478
479    /// Abandon the GEM handle and framebuffer id **without issuing ioctls**.
480    ///
481    /// For when the originating descriptor is known to be dead — libseat's
482    /// session-resume replaced the device. The CPU mapping is still torn down,
483    /// because `munmap` is descriptor-independent, so the caller is left with a
484    /// valid empty buffer. The kernel reclaims the orphaned handles when the
485    /// descriptor closes.
486    pub fn forget(&mut self) {
487        self.mapping = None; // munmap runs; it needs no descriptor
488        self.alloc = None;
489        self.fb = None;
490        self.fd = None;
491        self.width = 0;
492        self.height = 0;
493        self.stride = 0;
494    }
495}
496
497impl Drop for Buffer {
498    fn drop(&mut self) {
499        let (Some(file), Some(alloc)) = (self.fd.take(), self.alloc.take()) else {
500            // Already forgotten, or never allocated. The mapping still drops.
501            return;
502        };
503
504        // The file this buffer was allocated on, whatever became of the
505        // caller's descriptor number.
506        let borrowed = file.as_fd();
507        let device = RawDevice { fd: borrowed };
508
509        if let Some(fb) = self.fb.take() {
510            // Order matters: the framebuffer references the GEM object, so it
511            // goes first. Errors are unactionable in a destructor.
512            let _ = device.destroy_framebuffer(fb);
513        }
514
515        // Unmap before releasing the GEM object.
516        self.mapping = None;
517
518        let _ = drm_ffi::mode::dumbbuffer::destroy(borrowed, alloc.handle);
519    }
520}
521
522/// A descriptor of our own on `device`'s file, for teardown.
523fn duplicate(device: &Device) -> Result<OwnedFd> {
524    device
525        .as_fd()
526        .try_clone_to_owned()
527        .map_err(|e| io_to_errno(&e))
528}
529
530/// Minimal device adapter for the two teardown ioctls, so `Drop` need not hold
531/// a `&Device` it cannot borrow.
532struct RawDevice<'a> {
533    fd: BorrowedFd<'a>,
534}
535
536impl std::os::fd::AsFd for RawDevice<'_> {
537    fn as_fd(&self) -> BorrowedFd<'_> {
538        self.fd
539    }
540}
541
542impl drm::Device for RawDevice<'_> {}
543impl drm::control::Device for RawDevice<'_> {}
544
545/// Plane layout handed to `add_planar_framebuffer`.
546struct PlaneLayout {
547    width: u32,
548    height: u32,
549    fourcc: DrmFourcc,
550    handles: [Option<drm::buffer::Handle>; 4],
551    pitches: [u32; 4],
552    offsets: [u32; 4],
553}
554
555impl PlaneLayout {
556    fn single(buffer: &Buffer, fourcc: DrmFourcc) -> Self {
557        let handle = buffer.alloc.and_then(|a| drm::control::from_u32(a.handle));
558        Self {
559            width: buffer.width,
560            height: buffer.height,
561            // Already validated by the caller. Taking the typed value rather
562            // than re-converting removes the only place a bad FourCC could
563            // have silently become a different format on the framebuffer.
564            fourcc,
565            handles: [handle, None, None, None],
566            pitches: [buffer.stride, 0, 0, 0],
567            offsets: [0; 4],
568        }
569    }
570
571    /// Both planes name the same GEM object; the chroma plane is distinguished
572    /// only by its byte offset.
573    fn semi_planar(buffer: &Buffer, fourcc: DrmFourcc, chroma_offset: u32) -> Self {
574        let handle = buffer.alloc.and_then(|a| drm::control::from_u32(a.handle));
575        Self {
576            width: buffer.width,
577            height: buffer.height,
578            fourcc,
579            handles: [handle, handle, None, None],
580            // A Y row and a UV row are both `stride` bytes wide for semi-planar
581            // 4:2:0.
582            pitches: [buffer.stride, buffer.stride, 0, 0],
583            offsets: [0, chroma_offset, 0, 0],
584        }
585    }
586}
587
588impl drm::buffer::PlanarBuffer for PlaneLayout {
589    fn size(&self) -> (u32, u32) {
590        (self.width, self.height)
591    }
592
593    fn format(&self) -> DrmFourcc {
594        self.fourcc
595    }
596
597    fn modifier(&self) -> Option<drm::buffer::DrmModifier> {
598        // Dumb buffers are linear by construction, and passing no modifier is
599        // what keeps `add_planar_framebuffer` from setting the MODIFIERS flag.
600        None
601    }
602
603    fn pitches(&self) -> [u32; 4] {
604        self.pitches
605    }
606
607    fn handles(&self) -> [Option<drm::buffer::Handle>; 4] {
608        self.handles
609    }
610
611    fn offsets(&self) -> [u32; 4] {
612        self.offsets
613    }
614}
615
616/// The `drm` crate returns `io::Result`; drmkit classifies on `Errno`.
617fn io_to_errno(error: &std::io::Error) -> DumbError {
618    rustix::io::Errno::from_io_error(error)
619        .map_or(DumbError::Io(rustix::io::Errno::IO), DumbError::Io)
620}