Skip to main content

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}