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}