drmkit_scene/canvas.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Software composition for layers the allocator could not place.
5//!
6//! When a scene has more layers than the CRTC has usable planes, the ones that
7//! did not get a plane still have to reach the screen. They are blended on the
8//! CPU into a single full-screen surface, in stacking order and in ARGB8888,
9//! and that surface goes on a plane of its own.
10//!
11//! This module is the blend arithmetic, and nothing else. It reads and writes
12//! plain byte slices, so every rule below — clipping, scaling, premultiplied
13//! `SRC_OVER`, per-plane alpha — is exercised by ordinary unit tests with no
14//! device anywhere. Upstream splits it the same way and for the same reason.
15//!
16//! # Premultiplied, not straight
17//!
18//! Both source and destination pixels are assumed **premultiplied**, matching
19//! the kernel's default `pixel blend mode` and the output convention of
20//! `Blend2D`, thorvg, Cairo and Skia:
21//!
22//! ```text
23//! out = src + dst * (1 - src_alpha)
24//! ```
25//!
26//! A straight-alpha source blended by this formula does not merely look
27//! slightly off — the color channels saturate instead of mixing. Convert
28//! before calling.
29//!
30//! # Byte order
31//!
32//! `ARGB8888` is `B, G, R, A` in memory, so on a little-endian host a pixel
33//! read as a native `u32` is `0xAARRGGBB`. The arithmetic here is written in
34//! those terms, as upstream's is.
35
36use drmkit_core::Device;
37use drmkit_dumb::{Buffer, DumbError};
38use drmkit_fmt::fourcc;
39use drmkit_planes::PlaneCapabilities;
40
41/// A rectangle in whole pixels, half-open in extent.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43#[allow(
44 clippy::exhaustive_structs,
45 reason = "a value defined by its fields; there is nothing to add to it"
46)]
47pub struct CompositeRect {
48 /// Left edge. May be negative; the region is clipped.
49 pub x: i32,
50 /// Top edge. May be negative; the region is clipped.
51 pub y: i32,
52 /// Width in pixels.
53 pub w: u32,
54 /// Height in pixels.
55 pub h: u32,
56}
57
58/// A source surface to blend from.
59#[derive(Debug, Clone, Copy)]
60#[allow(
61 clippy::exhaustive_structs,
62 reason = "built by callers, with no default to fill a new field from"
63)]
64pub struct CompositeSrc<'a> {
65 /// The pixels, rows `src_stride_bytes` apart.
66 pub pixels: &'a [u8],
67 /// Distance between row starts, which may exceed `src_width * 4`.
68 pub src_stride_bytes: u32,
69 /// Width in pixels.
70 pub src_width: u32,
71 /// Height in pixels.
72 pub src_height: u32,
73 /// The pixel format. Only `ARGB8888` and `XRGB8888` blend today.
74 pub drm_fourcc: u32,
75 /// The layer's plane alpha, on the KMS `u16` scale.
76 pub plane_alpha: u16,
77}
78
79/// Whether this module can blend from `fourcc`.
80///
81/// Deliberately narrow. A format that reaches the blend loop unrecognized
82/// would be reinterpreted as `ARGB8888` and produce color noise, so an
83/// unsupported source is refused rather than guessed at.
84#[must_use]
85pub const fn format_supported(drm_fourcc: u32) -> bool {
86 matches!(drm_fourcc, fourcc::ARGB8888 | fourcc::XRGB8888)
87}
88
89/// Whether a format carries no usable alpha channel.
90const fn source_is_opaque(drm_fourcc: u32) -> bool {
91 drm_fourcc == fourcc::XRGB8888
92}
93
94/// One premultiplied `SRC_OVER` step.
95///
96/// `out = src + dst * (1 - src_alpha)`, per channel, in `u32` lanes with
97/// round-to-nearest. The alpha channel accumulates coverage so a run of
98/// partially transparent sources composites to the right combined alpha, and
99/// saturates rather than wrapping.
100fn blend_pixel_over(src: u32, dst: u32) -> u32 {
101 let sa = (src >> 24) & 0xFF;
102 // Both shortcuts are exact, not approximations: a fully transparent source
103 // contributes nothing, and a fully opaque one replaces the destination.
104 if sa == 0 {
105 return dst;
106 }
107 if sa == 0xFF {
108 return src;
109 }
110
111 let inv = 255 - sa;
112 let scale = |c: u32| (c * inv + 127) / 255;
113
114 let out_r = ((src >> 16) & 0xFF) + scale((dst >> 16) & 0xFF);
115 let out_g = ((src >> 8) & 0xFF) + scale((dst >> 8) & 0xFF);
116 let out_b = (src & 0xFF) + scale(dst & 0xFF);
117 let out_a = sa + scale((dst >> 24) & 0xFF);
118
119 let clamp = |c: u32| if c > 0xFF { 0xFF } else { c };
120 (clamp(out_a) << 24) | (clamp(out_r) << 16) | (clamp(out_g) << 8) | clamp(out_b)
121}
122
123/// Scale every channel of a premultiplied pixel by `alpha8 / 255`.
124///
125/// Applied whole-surface, from the layer's plane alpha. Scaling the color
126/// channels as well as alpha is what keeps the pixel premultiplied — leaving
127/// `rgb > a` would break the assumption [`blend_pixel_over`] rests on and show
128/// up as haloing at the edges.
129fn modulate_pixel(pixel: u32, alpha8: u32) -> u32 {
130 let scale = |c: u32| (c * alpha8 + 127) / 255;
131 (scale((pixel >> 24) & 0xFF) << 24)
132 | (scale((pixel >> 16) & 0xFF) << 16)
133 | (scale((pixel >> 8) & 0xFF) << 8)
134 | scale(pixel & 0xFF)
135}
136
137/// KMS plane alpha is `u16`; the blend works in `0..=255`.
138///
139/// Round-to-nearest, which matters in the middle of the range rather than at
140/// the ends: `0xFFFF` and `0` land on `0xFF` and `0` whether rounded or
141/// truncated, but a half-transparent `0x8000` truncates to `0x7F` and rounds
142/// to `0x80`. One step darker than the reference, on every composited pixel of
143/// every partially transparent layer.
144const fn plane_alpha8(plane_alpha: u16) -> u32 {
145 (plane_alpha as u32 * 255 + 32767) / 65535
146}
147
148/// The visible span of one axis after clipping both rectangles.
149#[derive(Debug, Clone, Copy)]
150struct AxisRange {
151 dst_start: u32,
152 dst_end: u32,
153 src_start: u32,
154 src_span: u32,
155}
156
157/// Clip one axis of a blit against both buffers.
158///
159/// Returns `None` when nothing survives — off-screen either side, or a
160/// degenerate extent. Negative positions clip rather than wrap.
161fn clip_axis(
162 dst_pos: i32,
163 dst_size: u32,
164 dst_extent: u32,
165 src_pos: i32,
166 src_size: u32,
167 src_extent: u32,
168) -> Option<AxisRange> {
169 if dst_size == 0 || src_size == 0 {
170 return None;
171 }
172
173 // Each side is clipped against its own buffer independently; the mapping
174 // between them is re-derived afterwards from what survived.
175 let clip = |pos: i32, size: u32, extent: u32| -> Option<(u32, u32)> {
176 let clipped_off = if pos < 0 { pos.unsigned_abs() } else { 0 };
177 let start = pos.saturating_add(clipped_off.cast_signed());
178 if start >= extent.cast_signed() {
179 return None;
180 }
181 let start = start.cast_unsigned();
182 let remaining = size.checked_sub(clipped_off)?;
183 if remaining == 0 {
184 return None;
185 }
186 let visible = remaining.min(extent - start);
187 if visible == 0 {
188 None
189 } else {
190 Some((start, visible))
191 }
192 };
193
194 let (src_start, src_span) = clip(src_pos, src_size, src_extent)?;
195 let (dst_start, dst_visible) = clip(dst_pos, dst_size, dst_extent)?;
196
197 Some(AxisRange {
198 dst_start,
199 dst_end: dst_start + dst_visible,
200 src_start,
201 src_span,
202 })
203}
204
205/// Map a destination index back to a source index, nearest-neighbor.
206///
207/// The same expression covers magnification and minification, so a scaled
208/// blit needs no separate path.
209const fn map_src_index(dst_idx: u32, dst_visible: u32, src_span: u32) -> u32 {
210 if dst_visible <= 1 {
211 return 0;
212 }
213 (dst_idx * src_span) / dst_visible
214}
215
216/// Whether a buffer is large enough for `height` rows of `stride` bytes.
217///
218/// Computed in `u64` so a stride and height that overflow `u32` are rejected
219/// rather than wrapping into a small figure that passes the check.
220fn fits(len: usize, height: u32, stride: u32) -> bool {
221 let required = u64::from(height) * u64::from(stride);
222 required <= len as u64
223}
224
225/// Zero-fill a rectangle of `dst`, clipped to its extents.
226///
227/// Zero is transparent black in premultiplied ARGB, which is the identity for
228/// `SRC_OVER` — so this is what makes a frame's blending independent of the
229/// last frame's.
230pub fn clear_into(
231 dst: &mut [u8],
232 dst_stride_bytes: u32,
233 dst_width: u32,
234 dst_height: u32,
235 rect: CompositeRect,
236) {
237 if dst_width == 0 || dst_height == 0 {
238 return;
239 }
240 if u64::from(dst_stride_bytes) < u64::from(dst_width) * 4
241 || !fits(dst.len(), dst_height, dst_stride_bytes)
242 {
243 return;
244 }
245 let Some(xr) = clip_axis(rect.x, rect.w, dst_width, 0, rect.w, dst_width) else {
246 return;
247 };
248 let Some(yr) = clip_axis(rect.y, rect.h, dst_height, 0, rect.h, dst_height) else {
249 return;
250 };
251
252 for y in yr.dst_start..yr.dst_end {
253 let row = (y * dst_stride_bytes) as usize;
254 let from = row + (xr.dst_start * 4) as usize;
255 let to = row + (xr.dst_end * 4) as usize;
256 dst[from..to].fill(0);
257 }
258}
259
260/// Blend `src` over `dst`, premultiplied `SRC_OVER`.
261///
262/// Both rectangles are clipped against their own buffer, and mismatched sizes
263/// scale nearest-neighbor. Refuses rather than guessing when the source
264/// format is unsupported or either buffer is smaller than its stated geometry.
265///
266/// Unlike the C++, this needs no alignment guard. Upstream reinterpret-casts
267/// both buffers to `uint32_t*` and has to bail on an unaligned base to avoid
268/// undefined behavior; reading through `chunks_exact` has no such
269/// requirement, so a caller cannot hand in a pointer that makes this unsound.
270pub fn blend_into(
271 dst: &mut [u8],
272 dst_stride_bytes: u32,
273 dst_width: u32,
274 dst_height: u32,
275 src: &CompositeSrc<'_>,
276 src_rect: CompositeRect,
277 dst_rect: CompositeRect,
278) {
279 if dst_width == 0 || dst_height == 0 || !format_supported(src.drm_fourcc) {
280 return;
281 }
282 if u64::from(dst_stride_bytes) < u64::from(dst_width) * 4
283 || !fits(dst.len(), dst_height, dst_stride_bytes)
284 {
285 return;
286 }
287 if u64::from(src.src_stride_bytes) < u64::from(src.src_width) * 4
288 || !fits(src.pixels.len(), src.src_height, src.src_stride_bytes)
289 {
290 return;
291 }
292
293 let Some(xr) = clip_axis(
294 dst_rect.x,
295 dst_rect.w,
296 dst_width,
297 src_rect.x,
298 src_rect.w,
299 src.src_width,
300 ) else {
301 return;
302 };
303 let Some(yr) = clip_axis(
304 dst_rect.y,
305 dst_rect.h,
306 dst_height,
307 src_rect.y,
308 src_rect.h,
309 src.src_height,
310 ) else {
311 return;
312 };
313
314 let opaque = source_is_opaque(src.drm_fourcc);
315 let alpha8 = plane_alpha8(src.plane_alpha);
316 let modulate = alpha8 < 0xFF;
317 let visible_x = xr.dst_end - xr.dst_start;
318 let visible_y = yr.dst_end - yr.dst_start;
319
320 for dy in 0..visible_y {
321 let sy = yr.src_start + map_src_index(dy, visible_y, yr.src_span);
322 let src_row = (sy * src.src_stride_bytes) as usize;
323 let dst_row = ((yr.dst_start + dy) * dst_stride_bytes) as usize;
324
325 for dx in 0..visible_x {
326 let sx = xr.src_start + map_src_index(dx, visible_x, xr.src_span);
327 let s_at = src_row + (sx * 4) as usize;
328 let d_at = dst_row + ((xr.dst_start + dx) * 4) as usize;
329
330 let mut pixel = u32::from_ne_bytes([
331 src.pixels[s_at],
332 src.pixels[s_at + 1],
333 src.pixels[s_at + 2],
334 src.pixels[s_at + 3],
335 ]);
336 // An XRGB source's fourth byte is undefined padding, not alpha.
337 // Reading it as alpha would make an opaque layer randomly
338 // transparent wherever the producer left the byte clear.
339 if opaque {
340 pixel |= 0xFF00_0000;
341 }
342 if modulate {
343 pixel = modulate_pixel(pixel, alpha8);
344 }
345
346 let under =
347 u32::from_ne_bytes([dst[d_at], dst[d_at + 1], dst[d_at + 2], dst[d_at + 3]]);
348 let out = blend_pixel_over(pixel, under).to_ne_bytes();
349 dst[d_at..d_at + 4].copy_from_slice(&out);
350 }
351 }
352}
353
354/// Copy `height` tightly packed rows into a buffer that may be strided wider.
355///
356/// A dumb buffer's stride is whatever the kernel chose and may exceed
357/// `row_bytes`; the shadow is always tight. Copying the whole shadow in one
358/// block would walk that padding into the next row and shear the image
359/// progressively down the canvas.
360///
361/// Split out because vkms never pads — its stride is exactly `width * 4` — so
362/// no test against a real device can reach the case this exists for.
363fn copy_rows(dst: &mut [u8], dst_stride: usize, shadow: &[u8], row_bytes: usize, height: usize) {
364 if row_bytes > dst_stride {
365 return;
366 }
367 if dst.len() < dst_stride * height || shadow.len() < row_bytes * height {
368 return;
369 }
370 for y in 0..height {
371 let to = y * dst_stride;
372 let from = y * row_bytes;
373 dst[to..to + row_bytes].copy_from_slice(&shadow[from..from + row_bytes]);
374 }
375}
376
377// --- the surface ------------------------------------------------------------
378
379/// Bytes per pixel the canvas can write in `fourcc`, or zero.
380///
381/// Zero is the gate: a format the canvas cannot emit is not offered even if
382/// the plane advertises it. Keeping the check here rather than in the
383/// preference list is what stops the two drifting apart, which would show up
384/// as a canvas allocated in a format `flush` then writes garbage into.
385#[must_use]
386pub const fn canvas_output_bpp(fourcc: u32) -> u32 {
387 match fourcc {
388 fourcc::ARGB8888 | fourcc::XRGB8888 | fourcc::XBGR8888 | fourcc::ABGR8888 => 4,
389 fourcc::RGB565 | fourcc::BGR565 => 2,
390 _ => 0,
391 }
392}
393
394/// Formats the canvas will take, best first.
395///
396/// `ARGB8888` first because it is what the shadow already holds and needs no
397/// conversion; the alpha-carrying variants ahead of the opaque ones because a
398/// canvas that has blended transparency into it should be able to say so; and
399/// the 565s last, which lose two bits a channel but are all some panels take.
400const CANVAS_FORMATS: [u32; 6] = [
401 fourcc::ARGB8888,
402 fourcc::XRGB8888,
403 fourcc::XBGR8888,
404 fourcc::ABGR8888,
405 fourcc::RGB565,
406 fourcc::BGR565,
407];
408
409/// The best canvas format this plane can scan out, if any.
410///
411/// The device-free core of the single-plane composition fallback. On a
412/// controller with one plane and no `ARGB8888` -- tilcdc on a `BeagleBone` is
413/// the canonical one, and i.MX LCDIF and the small SPI panels have the same
414/// shape -- a canvas that insisted on `ARGB8888` could not be allocated at
415/// all, and every layer that overflowed the plane count would be dropped
416/// instead of composited.
417///
418/// `None` means the plane advertises nothing the canvas can write, which is
419/// the honest answer: composition is not available there.
420///
421/// The canvas is a dumb buffer, so linear: a format counts only where the
422/// plane scans it out linear. The RK3566's Cluster windows list `ARGB8888` in
423/// their bare format list and offer it in `IN_FORMATS` only as AFBC; a canvas
424/// put there is refused at check time, and every frame that composites fails
425/// with it (P-48).
426#[must_use]
427pub fn canvas_format_for_plane(plane: &PlaneCapabilities) -> Option<u32> {
428 CANVAS_FORMATS
429 .into_iter()
430 .find(|fourcc| canvas_output_bpp(*fourcc) != 0 && plane.scans_out_linear(*fourcc))
431}
432
433/// Convert one row of the `ARGB8888` shadow into `out_fourcc`.
434///
435/// Scalar. The reference hand-vectorizes this with NEON and leans on
436/// autovectorization elsewhere; here the loop is written so the compiler can
437/// do the same, and the conversion runs once per canvas row per frame rather
438/// than on the per-layer path.
439///
440/// Public because the decoration tier's framebuffer presenter converts into
441/// an fbdev mapping the same way, and a second copy of a channel-order
442/// conversion is a second place for red and blue to be swapped.
443///
444/// # Panics
445///
446/// If `src` is shorter than `pixels * 4` bytes, or `dst` shorter than `pixels`
447/// times the output format's bytes per pixel.
448pub fn convert_row(dst: &mut [u8], src: &[u8], pixels: usize, out_fourcc: u32) {
449 match out_fourcc {
450 // The shadow is already this. `XRGB8888` differs only in that the
451 // high byte is ignored rather than read as alpha, so the bytes are
452 // the same either way.
453 fourcc::ARGB8888 | fourcc::XRGB8888 => {
454 dst[..pixels * 4].copy_from_slice(&src[..pixels * 4]);
455 }
456 // Same layout with red and blue exchanged.
457 fourcc::XBGR8888 | fourcc::ABGR8888 => {
458 for i in 0..pixels {
459 let s = &src[i * 4..i * 4 + 4];
460 // Memory order is B, G, R, A on a little-endian host, so
461 // swapping red and blue is swapping bytes 0 and 2.
462 dst[i * 4] = s[2];
463 dst[i * 4 + 1] = s[1];
464 dst[i * 4 + 2] = s[0];
465 dst[i * 4 + 3] = s[3];
466 }
467 }
468 fourcc::RGB565 | fourcc::BGR565 => {
469 let swap = out_fourcc == fourcc::BGR565;
470 for i in 0..pixels {
471 let s = &src[i * 4..i * 4 + 4];
472 let (b, g, r) = (s[0], s[1], s[2]);
473 let (high, low) = if swap { (b, r) } else { (r, b) };
474 // Truncating, not rounding: the reference truncates, and a
475 // canvas that rounded would differ from it by one step on
476 // half the pixels of any gradient.
477 let packed =
478 (u16::from(high >> 3) << 11) | (u16::from(g >> 2) << 5) | u16::from(low >> 3);
479 dst[i * 2..i * 2 + 2].copy_from_slice(&packed.to_le_bytes());
480 }
481 }
482 // Unreachable through `canvas_format_for_plane`, which only offers
483 // formats `canvas_output_bpp` accepts. Leaving the row untouched is
484 // the safe answer for a caller that built a canvas by hand.
485 _ => {}
486 }
487}
488
489/// A double-buffered software composition surface.
490///
491/// Owns two dumb buffers in the canvas format -- `ARGB8888` unless made with
492/// [`create_in`](Self::create_in) -- and alternates between them: the CPU paints
493/// the back while the kernel scans out the front. A single buffer tears
494/// visibly — the scanout reads the same memory the CPU is writing, so a
495/// horizontal seam appears wherever it caught a half-painted frame. The cost
496/// is one extra canvas-sized allocation: 3 MB at 1024×768, 33 MB at 4K.
497///
498/// Painting goes to a **shadow** in ordinary memory, not to the buffer. The
499/// kernel typically maps a dumb buffer write-combined, where scattered
500/// per-pixel writes are slow but one long linear copy runs at streaming
501/// bandwidth. [`flush`](Self::flush) is that copy.
502pub struct CompositeCanvas {
503 buffers: [Buffer; 2],
504 /// Index of the buffer being painted into.
505 back: usize,
506 /// Cached-memory paint target, `width * height * 4`.
507 ///
508 /// Always `ARGB8888` whatever the buffers are: blending happens here, and
509 /// blending in 565 would quantize at every layer instead of once at the
510 /// end. [`flush`](Self::flush) is where the conversion happens.
511 shadow: Vec<u8>,
512 width: u32,
513 height: u32,
514 /// What the buffers are, and what `flush` converts the shadow into.
515 fourcc: u32,
516}
517
518impl CompositeCanvas {
519 /// Allocate the pair at `width` × `height`.
520 ///
521 /// # Errors
522 ///
523 /// [`DumbError`] if either allocation or its framebuffer fails.
524 pub fn create(device: &Device, width: u32, height: u32) -> Result<Self, DumbError> {
525 Self::create_in(device, width, height, fourcc::ARGB8888)
526 }
527
528 /// Allocate the pair in a format the target plane can scan out.
529 ///
530 /// For the single-plane fallback: pass what
531 /// [`canvas_format_for_plane`] returned, and the canvas emits that
532 /// instead of `ARGB8888`. Blending still happens in `ARGB8888`, in a
533 /// shadow buffer, so a 565 canvas quantizes once at flush rather than at
534 /// every layer.
535 ///
536 /// # Errors
537 ///
538 /// [`DumbError::InvalidConfig`] if `fourcc` is not one the canvas can
539 /// write, and otherwise as [`CompositeCanvas::create`].
540 pub fn create_in(
541 device: &Device,
542 width: u32,
543 height: u32,
544 fourcc: u32,
545 ) -> Result<Self, DumbError> {
546 let bpp = canvas_output_bpp(fourcc);
547 if bpp == 0 {
548 return Err(DumbError::InvalidConfig {
549 reason: "the canvas cannot write this format",
550 });
551 }
552 let config = drmkit_dumb::Config {
553 width,
554 height,
555 fourcc,
556 bpp: bpp * 8,
557 add_fb: true,
558 };
559 let buffers = [
560 Buffer::create(device, &config)?,
561 Buffer::create(device, &config)?,
562 ];
563 Ok(Self {
564 buffers,
565 back: 0,
566 shadow: vec![0; (width as usize) * (height as usize) * 4],
567 width,
568 height,
569 fourcc,
570 })
571 }
572
573 /// What the canvas scans out as.
574 #[must_use]
575 pub const fn fourcc(&self) -> u32 {
576 self.fourcc
577 }
578
579 /// Swap back and front before painting.
580 ///
581 /// Call once per frame, before the first paint. Calling it twice in one
582 /// frame would paint into the buffer just handed to the kernel.
583 pub const fn begin_frame(&mut self) {
584 self.back = 1 - self.back;
585 }
586
587 /// Zero the whole shadow: transparent black, the `SRC_OVER` identity.
588 pub fn clear(&mut self) {
589 self.shadow.fill(0);
590 }
591
592 /// Zero one rectangle of the shadow.
593 ///
594 /// Scrubbing only the union of last frame's and this frame's painted
595 /// region turns the per-frame cost from `width * height * 4` bytes into
596 /// something proportional to what actually moved.
597 pub fn clear_rect(&mut self, rect: CompositeRect) {
598 let (w, h) = (self.width, self.height);
599 clear_into(&mut self.shadow, w * 4, w, h, rect);
600 }
601
602 /// Blend one source over the shadow.
603 pub fn blend(
604 &mut self,
605 src: &CompositeSrc<'_>,
606 src_rect: CompositeRect,
607 dst_rect: CompositeRect,
608 ) {
609 let (w, h) = (self.width, self.height);
610 blend_into(&mut self.shadow, w * 4, w, h, src, src_rect, dst_rect);
611 }
612
613 /// Copy the painted shadow into the back buffer, converting it to the
614 /// canvas format.
615 ///
616 /// Row by row rather than one call, because the kernel is free to pad a
617 /// dumb buffer's stride above `width * 4`; copying straight through would
618 /// walk the padding into the next row and shear the image.
619 pub fn flush(&mut self) {
620 let stride = self.buffers[self.back].stride() as usize;
621 let pixels = self.width as usize;
622 let height = self.height as usize;
623 let fourcc = self.fourcc;
624 let shadow_row = pixels * 4;
625 let dst = self.buffers[self.back].data_mut();
626
627 if fourcc == fourcc::ARGB8888 || fourcc == fourcc::XRGB8888 {
628 // The common case, and the one worth keeping as one long linear
629 // copy per row: the kernel usually maps a dumb buffer
630 // write-combined, where a memcpy runs at streaming bandwidth and
631 // scattered writes do not.
632 copy_rows(dst, stride, &self.shadow, shadow_row, height);
633 return;
634 }
635
636 for row in 0..height {
637 let Some(out) = dst.get_mut(row * stride..) else {
638 break;
639 };
640 let src = &self.shadow[row * shadow_row..][..shadow_row];
641 convert_row(out, src, pixels, fourcc);
642 }
643 }
644
645 /// The framebuffer to arm on the canvas plane — the one just painted.
646 #[must_use]
647 pub fn fb_id(&self) -> Option<u32> {
648 self.buffers[self.back].fb_id()
649 }
650
651 /// Canvas width in pixels.
652 #[must_use]
653 pub const fn width(&self) -> u32 {
654 self.width
655 }
656
657 /// Canvas height in pixels.
658 #[must_use]
659 pub const fn height(&self) -> u32 {
660 self.height
661 }
662
663 /// Read one pixel back out of the buffer being painted.
664 ///
665 /// Reads the framebuffer's own memory rather than the shadow, which is the
666 /// only way to tell that [`flush`](Self::flush) actually reached the buffer
667 /// the kernel will scan out. Zero for a pixel outside the canvas, or once
668 /// the mapping has been dropped. Reads 32 bits at a 4-byte pitch, so it
669 /// is only meaningful on a 32-bit canvas.
670 #[must_use]
671 pub fn back_pixel(&self, x: u32, y: u32) -> u32 {
672 if x >= self.width || y >= self.height {
673 return 0;
674 }
675 let stride = self.buffers[self.back].stride() as usize;
676 let data = self.buffers[self.back].data();
677 let at = y as usize * stride + x as usize * 4;
678 data.get(at..at + 4).map_or(0, |bytes| {
679 u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
680 })
681 }
682
683 /// Whether the canvas can be armed.
684 ///
685 /// False once [`forget`](Self::forget) has run, so a scene that resumes
686 /// without re-allocating cannot commit a framebuffer id the kernel no
687 /// longer knows.
688 #[must_use]
689 pub fn armable(&self) -> bool {
690 !self.buffers[0].is_empty() && !self.buffers[1].is_empty() && self.fb_id().is_some()
691 }
692
693 /// Drop both buffers' kernel state without issuing ioctls.
694 ///
695 /// For session pause, where the descriptor has been revoked and cannot
696 /// service the ioctls a normal teardown would make. The shadow survives —
697 /// it is ordinary memory and owes the device nothing.
698 pub fn forget(&mut self) {
699 for buffer in &mut self.buffers {
700 buffer.forget();
701 }
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 /// A canvas of `w * h` pixels, tightly strided.
710 fn canvas(w: u32, h: u32) -> Vec<u8> {
711 vec![0; (w * h * 4) as usize]
712 }
713
714 fn px(buf: &[u8], stride: u32, x: u32, y: u32) -> u32 {
715 let at = (y * stride + x * 4) as usize;
716 u32::from_ne_bytes([buf[at], buf[at + 1], buf[at + 2], buf[at + 3]])
717 }
718
719 fn solid(w: u32, h: u32, argb: u32) -> Vec<u8> {
720 argb.to_ne_bytes().repeat((w * h) as usize)
721 }
722
723 fn src(pixels: &[u8], w: u32, h: u32, fourcc: u32) -> CompositeSrc<'_> {
724 CompositeSrc {
725 pixels,
726 src_stride_bytes: w * 4,
727 src_width: w,
728 src_height: h,
729 drm_fourcc: fourcc,
730 plane_alpha: 0xFFFF,
731 }
732 }
733
734 fn whole(w: u32, h: u32) -> CompositeRect {
735 CompositeRect { x: 0, y: 0, w, h }
736 }
737
738 #[test]
739 fn an_opaque_source_replaces_what_was_under_it() {
740 let mut dst = solid(4, 4, 0xFF00_00FF);
741 let pixels = solid(4, 4, 0xFFFF_0000);
742 blend_into(
743 &mut dst,
744 16,
745 4,
746 4,
747 &src(&pixels, 4, 4, fourcc::ARGB8888),
748 whole(4, 4),
749 whole(4, 4),
750 );
751 assert_eq!(px(&dst, 16, 2, 2), 0xFFFF_0000);
752 }
753
754 #[test]
755 fn a_fully_transparent_source_leaves_the_destination_alone() {
756 let mut dst = solid(4, 4, 0xFF00_00FF);
757 let pixels = solid(4, 4, 0x0000_0000);
758 blend_into(
759 &mut dst,
760 16,
761 4,
762 4,
763 &src(&pixels, 4, 4, fourcc::ARGB8888),
764 whole(4, 4),
765 whole(4, 4),
766 );
767 assert_eq!(px(&dst, 16, 1, 1), 0xFF00_00FF);
768 }
769
770 /// Half-alpha red, premultiplied, over opaque blue.
771 ///
772 /// Premultiplied means the source's own channels are *already* scaled by
773 /// its alpha, so they are added rather than interpolated:
774 /// `out = src + dst * (1 - src_a)`. Reading 0x80 red as "half of 0xFF red"
775 /// and lerping would give a different, wrong answer — which is exactly the
776 /// mistake a straight-alpha source causes.
777 #[test]
778 fn a_half_alpha_source_composites_premultiplied() {
779 let mut dst = solid(2, 2, 0xFF00_00FF);
780 let pixels = solid(2, 2, 0x8080_0000);
781 blend_into(
782 &mut dst,
783 8,
784 2,
785 2,
786 &src(&pixels, 2, 2, fourcc::ARGB8888),
787 whole(2, 2),
788 whole(2, 2),
789 );
790
791 // inv = 0x7F. blue: 0x00 + (0xFF * 0x7F + 127)/255 = 0x7F.
792 // red: 0x80 + (0x00 * ...) = 0x80.
793 // a: 0x80 + (0xFF * 0x7F + 127)/255 = 0xFF.
794 assert_eq!(px(&dst, 8, 0, 0), 0xFF80_007F);
795 }
796
797 /// An XRGB source is opaque even where its fourth byte is clear.
798 ///
799 /// That byte is undefined padding, not alpha. Trusting it would make an
800 /// opaque layer randomly transparent wherever the producer happened to
801 /// leave it zero — and a producer that memsets its buffer leaves all of it
802 /// zero, so the layer would vanish entirely.
803 #[test]
804 fn an_xrgb_source_ignores_its_padding_byte() {
805 let mut dst = solid(2, 2, 0xFF00_00FF);
806 let pixels = solid(2, 2, 0x00FF_0000);
807 blend_into(
808 &mut dst,
809 8,
810 2,
811 2,
812 &src(&pixels, 2, 2, fourcc::XRGB8888),
813 whole(2, 2),
814 whole(2, 2),
815 );
816 assert_eq!(px(&dst, 8, 0, 0), 0xFFFF_0000);
817 }
818
819 /// Plane alpha scales color as well as alpha.
820 ///
821 /// Leaving the color channels alone would produce `rgb > a`, which is not
822 /// a valid premultiplied pixel; `blend_pixel_over` assumes it never sees
823 /// one, and the visible result is a bright halo at the layer's edges.
824 #[test]
825 fn plane_alpha_keeps_the_pixel_premultiplied() {
826 let mut dst = canvas(2, 2);
827 let pixels = solid(2, 2, 0xFFFF_FFFF);
828 let mut source = src(&pixels, 2, 2, fourcc::ARGB8888);
829 source.plane_alpha = 0x8000;
830 blend_into(&mut dst, 8, 2, 2, &source, whole(2, 2), whole(2, 2));
831
832 let out = px(&dst, 8, 0, 0);
833 let a = (out >> 24) & 0xFF;
834 for shift in [16, 8, 0] {
835 assert!(
836 (out >> shift) & 0xFF <= a,
837 "channel at {shift} exceeds alpha {a:#x} in {out:#010x}"
838 );
839 }
840 }
841
842 /// Fully opaque plane alpha must not darken the source at all.
843 ///
844 /// `0xFFFF` has to map to exactly `0xFF`; a truncating conversion lands on
845 /// `0xFE` and every frame is composited a shade dark.
846 #[test]
847 fn opaque_plane_alpha_is_a_no_op() {
848 let mut dst = canvas(2, 2);
849 let pixels = solid(2, 2, 0xFFAB_CDEF);
850 blend_into(
851 &mut dst,
852 8,
853 2,
854 2,
855 &src(&pixels, 2, 2, fourcc::ARGB8888),
856 whole(2, 2),
857 whole(2, 2),
858 );
859 assert_eq!(px(&dst, 8, 1, 1), 0xFFAB_CDEF);
860 }
861
862 /// A layer partly off the left edge is clipped, not wrapped.
863 #[test]
864 fn a_negative_destination_clips() {
865 let mut dst = canvas(4, 1);
866 let pixels = solid(4, 1, 0xFFFF_0000);
867 blend_into(
868 &mut dst,
869 16,
870 4,
871 1,
872 &src(&pixels, 4, 1, fourcc::ARGB8888),
873 whole(4, 1),
874 CompositeRect {
875 x: -2,
876 y: 0,
877 w: 4,
878 h: 1,
879 },
880 );
881 assert_eq!(px(&dst, 16, 0, 0), 0xFFFF_0000);
882 assert_eq!(px(&dst, 16, 1, 0), 0xFFFF_0000);
883 // The two pixels that fell off the left must not reappear on the right.
884 assert_eq!(px(&dst, 16, 2, 0), 0);
885 assert_eq!(px(&dst, 16, 3, 0), 0);
886 }
887
888 /// A destination entirely off-screen writes nothing.
889 #[test]
890 fn a_fully_clipped_destination_writes_nothing() {
891 let mut dst = solid(4, 4, 0xFF00_00FF);
892 let before = dst.clone();
893 let pixels = solid(4, 4, 0xFFFF_0000);
894 blend_into(
895 &mut dst,
896 16,
897 4,
898 4,
899 &src(&pixels, 4, 4, fourcc::ARGB8888),
900 whole(4, 4),
901 CompositeRect {
902 x: 100,
903 y: 0,
904 w: 4,
905 h: 4,
906 },
907 );
908 assert_eq!(dst, before);
909 }
910
911 /// An unsupported format is refused rather than reinterpreted.
912 #[test]
913 fn an_unsupported_format_writes_nothing() {
914 let mut dst = solid(2, 2, 0xFF00_00FF);
915 let before = dst.clone();
916 let pixels = solid(2, 2, 0xFFFF_0000);
917 blend_into(
918 &mut dst,
919 8,
920 2,
921 2,
922 &src(&pixels, 2, 2, fourcc::NV12),
923 whole(2, 2),
924 whole(2, 2),
925 );
926 assert_eq!(dst, before, "an NV12 source must not be blended as ARGB");
927 }
928
929 /// A source buffer shorter than its declared geometry writes nothing.
930 ///
931 /// The guard is the only thing standing between a mis-declared stride and
932 /// an out-of-bounds read, so it is worth a case of its own.
933 #[test]
934 fn a_short_source_buffer_writes_nothing() {
935 let mut dst = solid(4, 4, 0xFF00_00FF);
936 let before = dst.clone();
937 let pixels = vec![0xFFu8; 8]; // claims 4x4, holds 2 pixels
938 blend_into(
939 &mut dst,
940 16,
941 4,
942 4,
943 &src(&pixels, 4, 4, fourcc::ARGB8888),
944 whole(4, 4),
945 whole(4, 4),
946 );
947 assert_eq!(dst, before);
948 }
949
950 /// Magnifying picks nearest-neighbor source pixels.
951 #[test]
952 fn a_smaller_source_scales_up() {
953 let mut dst = canvas(4, 1);
954 let mut pixels = Vec::new();
955 pixels.extend_from_slice(&0xFFFF_0000u32.to_ne_bytes());
956 pixels.extend_from_slice(&0xFF00_FF00u32.to_ne_bytes());
957 blend_into(
958 &mut dst,
959 16,
960 4,
961 1,
962 &src(&pixels, 2, 1, fourcc::ARGB8888),
963 whole(2, 1),
964 whole(4, 1),
965 );
966 assert_eq!(px(&dst, 16, 0, 0), 0xFFFF_0000);
967 assert_eq!(px(&dst, 16, 1, 0), 0xFFFF_0000);
968 assert_eq!(px(&dst, 16, 2, 0), 0xFF00_FF00);
969 assert_eq!(px(&dst, 16, 3, 0), 0xFF00_FF00);
970 }
971
972 /// Clearing a rectangle restores the `SRC_OVER` identity there.
973 #[test]
974 fn clearing_a_rect_leaves_the_rest_intact() {
975 let mut dst = solid(4, 2, 0xFFFF_FFFF);
976 clear_into(
977 &mut dst,
978 16,
979 4,
980 2,
981 CompositeRect {
982 x: 1,
983 y: 0,
984 w: 2,
985 h: 1,
986 },
987 );
988 assert_eq!(px(&dst, 16, 0, 0), 0xFFFF_FFFF);
989 assert_eq!(px(&dst, 16, 1, 0), 0);
990 assert_eq!(px(&dst, 16, 2, 0), 0);
991 assert_eq!(px(&dst, 16, 3, 0), 0xFFFF_FFFF);
992 assert_eq!(px(&dst, 16, 1, 1), 0xFFFF_FFFF, "row 1 is untouched");
993 }
994
995 /// Stacking order is the caller's, and it decides what covers what.
996 #[test]
997 fn later_blends_land_on_top() {
998 let mut dst = canvas(2, 2);
999 let under = solid(2, 2, 0xFFFF_0000);
1000 let over = solid(2, 2, 0xFF00_FF00);
1001 for pixels in [&under, &over] {
1002 blend_into(
1003 &mut dst,
1004 8,
1005 2,
1006 2,
1007 &src(pixels, 2, 2, fourcc::ARGB8888),
1008 whole(2, 2),
1009 whole(2, 2),
1010 );
1011 }
1012 assert_eq!(px(&dst, 8, 0, 0), 0xFF00_FF00);
1013 }
1014}
1015
1016#[cfg(test)]
1017mod stride_tests {
1018 use super::copy_rows;
1019
1020 /// Padding stays between rows instead of shifting them.
1021 ///
1022 /// The failure this guards against is not a crash — it is every row after
1023 /// the first landing a few pixels further left than the last, so the image
1024 /// shears diagonally. Unreachable on vkms, which never pads.
1025 #[test]
1026 fn a_padded_stride_does_not_shear_the_image() {
1027 let row_bytes = 8; // two pixels
1028 let stride = 12; // one pixel of padding per row
1029 let height = 3;
1030 let shadow: Vec<u8> = (0..u8::try_from(row_bytes * height).expect("fits")).collect();
1031 let mut dst = vec![0xEE; stride * height];
1032
1033 copy_rows(&mut dst, stride, &shadow, row_bytes, height);
1034
1035 for y in 0..height {
1036 let at = y * stride;
1037 assert_eq!(
1038 &dst[at..at + row_bytes],
1039 &shadow[y * row_bytes..(y + 1) * row_bytes],
1040 "row {y} landed at the wrong offset"
1041 );
1042 assert_eq!(
1043 &dst[at + row_bytes..at + stride],
1044 &[0xEE; 4],
1045 "row {y}'s padding must be left alone, not written through"
1046 );
1047 }
1048 }
1049
1050 /// A destination too small for its stated geometry is left untouched.
1051 #[test]
1052 fn a_short_destination_copies_nothing() {
1053 let mut dst = vec![0u8; 8];
1054 let before = dst.clone();
1055 copy_rows(&mut dst, 8, &[1, 2, 3, 4, 5, 6, 7, 8], 8, 4);
1056 assert_eq!(dst, before);
1057 }
1058
1059 /// A stride narrower than a row is refused rather than truncating.
1060 #[test]
1061 fn a_stride_narrower_than_a_row_copies_nothing() {
1062 let mut dst = vec![0u8; 16];
1063 let before = dst.clone();
1064 copy_rows(&mut dst, 4, &[9u8; 16], 8, 2);
1065 assert_eq!(dst, before);
1066 }
1067
1068 // ── Canvas format negotiation ───────────────────────────────────────
1069
1070 use super::{canvas_format_for_plane, canvas_output_bpp, convert_row};
1071 use drmkit_fmt::fourcc;
1072 use drmkit_planes::PlaneCapabilities;
1073
1074 fn plane_with(formats: &[u32]) -> PlaneCapabilities {
1075 PlaneCapabilities {
1076 formats: formats.to_vec(),
1077 ..PlaneCapabilities::default()
1078 }
1079 }
1080
1081 #[test]
1082 fn tilcdc_picks_xbgr_rather_than_being_stuck() {
1083 // The board the fallback exists for: no ARGB8888 or XRGB8888, but
1084 // XBGR8888 and RGB565 scan out. A canvas that insisted on ARGB8888
1085 // could not be allocated, and every overflow layer would be dropped
1086 // rather than composited.
1087 let plane = plane_with(&[fourcc::RGB565, fourcc::XBGR8888, fourcc::BGR888]);
1088 assert_eq!(canvas_format_for_plane(&plane), Some(fourcc::XBGR8888));
1089 }
1090
1091 #[test]
1092 fn argb_wins_where_it_is_offered() {
1093 // Byte-identical to the shadow, so flush is a copy with no per-row
1094 // conversion at all.
1095 let plane = plane_with(&[
1096 fourcc::NV12,
1097 fourcc::XRGB8888,
1098 fourcc::ARGB8888,
1099 fourcc::RGB565,
1100 ]);
1101 assert_eq!(canvas_format_for_plane(&plane), Some(fourcc::ARGB8888));
1102 }
1103
1104 #[test]
1105 fn the_plane_s_advertisement_order_does_not_decide() {
1106 // Preference is by what the canvas costs to convert, not by what the
1107 // driver happened to list first.
1108 let plane = plane_with(&[fourcc::XBGR8888, fourcc::ARGB8888]);
1109 assert_eq!(canvas_format_for_plane(&plane), Some(fourcc::ARGB8888));
1110 }
1111
1112 #[test]
1113 fn a_565_only_panel_still_hosts_a_canvas() {
1114 let plane = plane_with(&[fourcc::RGB565]);
1115 assert_eq!(canvas_format_for_plane(&plane), Some(fourcc::RGB565));
1116 }
1117
1118 #[test]
1119 fn every_format_the_negotiation_offers_is_one_flush_can_write() {
1120 // The two lists drifting apart would give a canvas allocated in a
1121 // format `flush` writes garbage into, which no error reports.
1122 for fourcc in [
1123 fourcc::ARGB8888,
1124 fourcc::XRGB8888,
1125 fourcc::XBGR8888,
1126 fourcc::ABGR8888,
1127 fourcc::RGB565,
1128 fourcc::BGR565,
1129 ] {
1130 assert_eq!(
1131 canvas_format_for_plane(&plane_with(&[fourcc])),
1132 Some(fourcc),
1133 "{fourcc:#010x} is not selectable"
1134 );
1135 assert_ne!(
1136 canvas_output_bpp(fourcc),
1137 0,
1138 "{fourcc:#010x} cannot be written"
1139 );
1140 }
1141 }
1142
1143 #[test]
1144 fn a_plane_that_takes_the_format_only_compressed_hosts_none() {
1145 // The RK3566's Cluster windows: ARGB8888 and the rest in the bare
1146 // list, AFBC alone in IN_FORMATS. The canvas is linear, and the
1147 // kernel refuses it there (P-48).
1148 let afbc = drmkit_fmt::Modifier(0x0800_0000_0000_0001);
1149 let formats = [fourcc::ARGB8888, fourcc::XRGB8888, fourcc::RGB565];
1150 let plane = PlaneCapabilities {
1151 format_table: drmkit_fmt::FormatTable::from_pairs(formats.map(|f| (f, afbc))),
1152 ..plane_with(&formats)
1153 };
1154 assert_eq!(canvas_format_for_plane(&plane), None);
1155 }
1156
1157 #[test]
1158 fn a_format_offered_linear_wins_over_one_offered_compressed() {
1159 let afbc = drmkit_fmt::Modifier(0x0800_0000_0000_0001);
1160 let plane = PlaneCapabilities {
1161 format_table: drmkit_fmt::FormatTable::from_pairs([
1162 (fourcc::ARGB8888, afbc),
1163 (fourcc::XRGB8888, drmkit_fmt::Modifier::LINEAR),
1164 ]),
1165 ..plane_with(&[fourcc::ARGB8888, fourcc::XRGB8888])
1166 };
1167 assert_eq!(canvas_format_for_plane(&plane), Some(fourcc::XRGB8888));
1168 }
1169
1170 #[test]
1171 fn a_plane_with_nothing_the_canvas_can_write_hosts_none() {
1172 // The scene keeps looking, or drops the overflow -- either beats
1173 // arming a buffer nothing can emit into.
1174 let plane = plane_with(&[fourcc::NV12, fourcc::YUYV]);
1175 assert_eq!(canvas_format_for_plane(&plane), None);
1176 assert_eq!(canvas_format_for_plane(&plane_with(&[])), None);
1177 }
1178
1179 #[test]
1180 fn the_conversion_leaves_argb_alone() {
1181 // 0xAARRGGBB is B, G, R, A in memory on a little-endian host.
1182 let src = [0x11_u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
1183 for fourcc in [fourcc::ARGB8888, fourcc::XRGB8888] {
1184 let mut dst = [0_u8; 8];
1185 convert_row(&mut dst, &src, 2, fourcc);
1186 assert_eq!(dst, src, "{fourcc:#010x}");
1187 }
1188 }
1189
1190 #[test]
1191 fn the_conversion_exchanges_red_and_blue() {
1192 // Bytes 0 and 2 swap; green and alpha stay where they are. Getting
1193 // this backwards is the classic blue-faces bug, and nothing rejects
1194 // it -- the picture is simply wrong.
1195 let src = [0x11_u8, 0x22, 0x33, 0x44];
1196 for fourcc in [fourcc::XBGR8888, fourcc::ABGR8888] {
1197 let mut dst = [0_u8; 4];
1198 convert_row(&mut dst, &src, 1, fourcc);
1199 assert_eq!(dst, [0x33, 0x22, 0x11, 0x44], "{fourcc:#010x}");
1200 }
1201 }
1202
1203 #[test]
1204 fn the_conversion_packs_565_and_truncates() {
1205 // White, black, and a value that exercises each field. Truncating
1206 // rather than rounding, which is what the reference does -- rounding
1207 // would differ by one step on half the pixels of any gradient.
1208 let cases = [
1209 // (B, G, R, A) in memory expected RGB565
1210 ([0xFF_u8, 0xFF, 0xFF, 0xFF], 0xFFFF_u16),
1211 ([0x00, 0x00, 0x00, 0xFF], 0x0000),
1212 ([0x00, 0x00, 0xFF, 0xFF], 0xF800), // pure red
1213 ([0x00, 0xFF, 0x00, 0xFF], 0x07E0), // pure green
1214 ([0xFF, 0x00, 0x00, 0xFF], 0x001F), // pure blue
1215 ];
1216 for (src, expected) in cases {
1217 let mut dst = [0_u8; 2];
1218 convert_row(&mut dst, &src, 1, fourcc::RGB565);
1219 assert_eq!(u16::from_le_bytes(dst), expected, "{src:02x?} packed wrong");
1220 }
1221 }
1222
1223 #[test]
1224 fn the_565_swap_moves_red_and_blue_between_the_fields() {
1225 // BGR565 puts blue in the top five bits where RGB565 puts red.
1226 let src = [0x00_u8, 0x00, 0xFF, 0xFF]; // pure red
1227 let mut dst = [0_u8; 2];
1228 convert_row(&mut dst, &src, 1, fourcc::BGR565);
1229 assert_eq!(
1230 u16::from_le_bytes(dst),
1231 0x001F,
1232 "red belongs in the low field"
1233 );
1234 }
1235
1236 #[test]
1237 fn a_format_the_canvas_cannot_write_has_no_depth() {
1238 assert_eq!(canvas_output_bpp(fourcc::NV12), 0);
1239 assert_eq!(canvas_output_bpp(fourcc::YUYV), 0);
1240 assert_eq!(canvas_output_bpp(fourcc::ARGB8888), 4);
1241 assert_eq!(canvas_output_bpp(fourcc::RGB565), 2);
1242 }
1243
1244 #[test]
1245 fn the_canvas_refuses_a_format_it_cannot_write() {
1246 // Device-free: `create_in` checks before it allocates, so this is the
1247 // whole of the guard. A canvas built in NV12 would flush nothing into
1248 // a buffer the plane happily scans out as garbage.
1249 assert_eq!(canvas_output_bpp(fourcc::NV12), 0);
1250 }
1251
1252 #[test]
1253 fn a_565_canvas_is_half_the_bytes_per_pixel() {
1254 // What `create_in` sizes the allocation from. Getting it wrong gives
1255 // a buffer half the size the stride implies, and flush walks off the
1256 // end of it.
1257 assert_eq!(canvas_output_bpp(fourcc::RGB565) * 8, 16);
1258 assert_eq!(canvas_output_bpp(fourcc::ARGB8888) * 8, 32);
1259 }
1260}