Skip to main content

hub75_framebuffer/bitplane/plain/
row.rs

1//! Row-major bitplane framebuffer.
2//!
3//! Unlike the plane-major [`super::frame::DmaFrameBuffer`] which stores all
4//! rows for one bit-plane together, this framebuffer groups all bit-planes
5//! for a single row contiguously. This layout is optimized for row-by-row
6//! BCM rendering where the DMA driver replays each plane's pixel data
7//! multiple times (for brightness weighting) before moving to the next row.
8//!
9//! # Memory Layout
10//!
11//! ```text
12//! DmaFrameBuffer<NROWS, COLS, PLANES>
13//! ├── rows: [RowData<COLS, PLANES>; NROWS]
14//! │   └── RowData
15//! │       ├── plane0: PixelPlane<COLS>             -- plane 0 (head, duplicate)
16//! │       │   └── data: [Entry; COLS]
17//! │       ├── gap: [Entry; INTER_ROW_BLANK]        -- inter-row blanking gap
18//! │       ├── planes: [PixelPlane<COLS>; PLANES]   -- planes 0..PLANES-1
19//! │       │   └── PixelPlane
20//! │       │       └── data: [Entry; COLS]
21//! │       ├── [padding: Entry]                     -- if esp32-ordering + tail-closes-latch
22//! │       ├── [tail: Entry]                        -- if tail-closes-latch
23//! │       └── [padding: Entry]                     -- if not esp32-ordering + tail-closes-latch
24//! ```
25//!
26//! `plane0` duplicates `planes[0]`: it sits before the gap so plane 0's gap
27//! segment can stream `plane0 + gap` (the gap's LATCH=0 entries close plane 0's
28//! latch before the DMA stops). Without a gap, plane 0's segment streams
29//! `planes[0..] + tail` from the array instead. Each later plane suffix ends on
30//! the tail word stored after the last plane.
31//!
32//! Planes are stored **LSB-first**: plane 0 carries the least-significant
33//! bit and is displayed once per row; plane `PLANES-1` carries the MSB and
34//! is displayed `2^(PLANES-1)` times per row. This ordering means the first
35//! plane after an address change displays stale data for only 1 rep (minimal
36//! visual weight), eliminating the need for a separate primer segment.
37//!
38//! Each plane's pixel row contains COLS entries with a latch at the very
39//! last pixel; the latch entry is always blanked. In steady state the row
40//! address changes at exactly one place — the plane0→plane1 boundary within
41//! a scan row — so blanking (OE HIGH) is applied only around that boundary;
42//! anywhere else would be dead time that only dims the panel:
43//!
44//! - **Plane 0** (LSB, 1 rep): uses `prev_addr`, lead blank at end
45//!   (the address changes to the current row right after this plane and
46//!   the inter-row gap).
47//!   No trail blank is needed at its start: the previous row's planes 1+
48//!   (and the tail word) all carry the same `prev_addr`, so no address
49//!   change occurs there.
50//! - **Inter-row gap** (with an `inter-row-blank-*` feature): dead entries
51//!   holding `prev_addr` with OE blank, streamed as the tail of plane 0's
52//!   segment (right after plane 0's latch, before the address change). They
53//!   give slow row drivers extra blanked time before the address lines move
54//!   and they close plane 0's latch.
55//! - **Plane 1** (first current-addr plane): trail blank at start
56//!   (the address changes from plane 0's `prev_addr` at its first pixel).
57//! - **Planes 2..PLANES-2** (middle): OE active on all entries except
58//!   latch — no address change, so no blanking needed.
59//! - **Plane PLANES-1** (MSB, most reps): no lead blank — the tail
60//!   and next row's plane 0 all keep this row's address, so no address
61//!   change follows.
62//! - **PLANES == 1**: the single plane uses `prev_addr` with both trail
63//!   and lead blank, because in that configuration the address does change
64//!   at every row boundary. The gap is streamed after the single plane
65//!   and the address changes at the next row's plane 0 first pixel.
66//!
67//! When `tail-closes-latch` is enabled, a tail word (LATCH=0, OE=BLANK) is
68//! appended to the end of each plane suffix segment so every DMA transfer ends
69//! with the latch de-asserted (plane 0's latch is closed by the gap instead,
70//! when one is present). This prevents peripherals that continue clocking after
71//! DMA completion (or between per-segment transfers) from re-latching stale
72//! data.
73//!
74//! The inter-row gap appears only once per scan row — as the tail of
75//! plane 0's segment, before the address change — rather than once per
76//! plane as in the plane-major layout.
77//!
78//! # DMA Descriptor Pattern
79//!
80//! The plane rows of a row are contiguous (`repr(C)`), so each BCM segment
81//! streams a whole *suffix* of planes: the segment for plane `k` starts at
82//! plane `k` and covers planes `k..PLANES`, repeated just enough times to
83//! bring plane `k`'s total coverage to `2^k` displays per row. This halves
84//! the number of DMA transfers per row — `2^(PLANES-1)` instead of
85//! `2^PLANES - 1` — while the streamed bytes, and therefore the brightness,
86//! are unchanged.
87//!
88//! In [`FrameBuffer`] terms, one row's segments form
89//! one *sequence* — which is also one *group* (a single DMA transfer per
90//! row); one refresh is that sequence repeated `NROWS` times.
91//!
92//! For each row the driver builds descriptors like (no inter-row gap):
93//!
94//! ```text
95//! plane 0..PLANES-1 + tail  × 1 rep        → rows[r].planes[0..] + tail   (plane 0: prev_addr)
96//! plane 1..PLANES-1 + tail  × 1 rep        → rows[r].planes[1..] + tail   (addr)
97//! plane 2..PLANES-1 + tail  × 2 reps       → rows[r].planes[2..] + tail
98//! …
99//! plane PLANES-1 + tail     × 2^(PLANES-2) → rows[r].planes[PLANES-1] + tail (addr)
100//! ```
101//!
102//! With an `inter-row-blank-*` feature enabled, plane 0 streams the gap right
103//! after its latch (the gap closes the latch) and plane 1's segment gets a
104//! second rep to preserve its weight:
105//!
106//! ```text
107//! plane 0 + gap          × 1 rep        → rows[r].plane0 + gap  (prev_addr)
108//! planes 1..PLANES + tail × 2 reps      → rows[r].planes[1..] + tail (addr)
109//! planes 2..PLANES + tail × 2 reps      → rows[r].planes[2..] + tail
110//! …
111//! plane  PLANES-1 + tail × 2^(PLANES-2) → rows[r].planes[PLANES-1] + tail
112//! ```
113
114use core::convert::Infallible;
115
116use embedded_graphics::pixelcolor::RgbColor;
117use embedded_graphics::prelude::{DrawTarget, OriginDimensions, Point, Size};
118
119use crate::Color;
120use crate::{map_row_index, slot_addresses};
121use crate::{BcmLenReps, FrameBuffer, BCM_SEQUENCE_CAPACITY};
122use crate::{FrameBufferOperations, MutableFrameBuffer};
123
124use super::{
125    map_index, Entry, INTER_ROW_BLANK, LEAD_BLANK_DELAY, OE_ACTIVE, OE_BLANK, TRAIL_BLANK_DELAY,
126};
127
128/// One bit-plane's pixel data for a single row.
129#[derive(Clone, Copy, PartialEq, Debug)]
130#[repr(C)]
131pub struct PixelPlane<const COLS: usize> {
132    pub(crate) data: [Entry; COLS],
133}
134
135impl<const COLS: usize> PixelPlane<COLS> {
136    const fn new() -> Self {
137        Self {
138            data: [Entry::new(); COLS],
139        }
140    }
141}
142
143/// Byte size of the inter-row blanking gap (streamed as the tail of
144/// plane 0's segment, closing its latch before the address change).
145const GAP_BYTES: usize = INTER_ROW_BLANK * core::mem::size_of::<Entry>();
146
147/// Byte size of the tail word appended to each plane suffix segment
148/// (optional padding + optional tail).
149const TRAILER_BYTES: usize = {
150    #[cfg(feature = "tail-closes-latch")]
151    let tail = core::mem::size_of::<Entry>() * 2;
152    #[cfg(not(feature = "tail-closes-latch"))]
153    let tail = 0;
154
155    tail
156};
157
158/// Coalesced BCM suffix for plane `plane_idx`: returns
159/// `(covered_planes, reps)` — how many planes the segment spans (starting at
160/// `plane_idx`) and how many times that suffix is streamed. See the
161/// module-level "DMA Descriptor Pattern" section for the full scheme.
162///
163/// With an inter-row gap, plane 0 stands alone (its segment streams just
164/// plane 0 plus the inter-row gap), so plane 1's segment needs 2 reps to
165/// preserve its weight.
166const fn plane_suffix(plane_idx: usize, planes: usize) -> (usize, usize) {
167    let covered = if cfg!(feature = "inter-row-blank") && plane_idx == 0 {
168        1
169    } else {
170        planes - plane_idx
171    };
172    let reps = if plane_idx == 0 {
173        1
174    } else if cfg!(feature = "inter-row-blank") && plane_idx == 1 {
175        2
176    } else {
177        1 << (plane_idx - 1)
178    };
179    (covered, reps)
180}
181
182/// BCM sequence for one row: one entry per plane. Plane 0 streams its own
183/// plane (plus the inter-row gap when enabled — the gap closes its latch);
184/// each later plane streams the contiguous suffix `plane..PLANES` followed by
185/// the tail word when enabled. Entries past the sequence are zero padding.
186#[allow(clippy::absurd_extreme_comparisons)]
187const fn bcm_sequence<const COLS: usize, const PLANES: usize>(
188) -> [BcmLenReps; BCM_SEQUENCE_CAPACITY] {
189    let seq_len = PLANES;
190    assert!(seq_len <= BCM_SEQUENCE_CAPACITY);
191    let plane_bytes = COLS * core::mem::size_of::<Entry>();
192    let mut seq = [BcmLenReps::ZERO; BCM_SEQUENCE_CAPACITY];
193    let mut within = 0usize;
194    while within < seq_len {
195        let entry = if within == 0 {
196            // Plane 0 (LSB, 1 rep): with an inter-row gap it streams the gap
197            // right after its latch (the gap closes the latch); without a gap
198            // it covers every plane and ends on the tail word.
199            let len = if cfg!(feature = "inter-row-blank") {
200                plane_bytes + GAP_BYTES
201            } else {
202                plane_bytes * PLANES + TRAILER_BYTES
203            };
204            BcmLenReps { len, reps: 1 }
205        } else {
206            let (covered, reps) = plane_suffix(within, PLANES);
207            BcmLenReps {
208                len: plane_bytes * covered + TRAILER_BYTES,
209                reps,
210            }
211        };
212        seq[within] = entry;
213        within += 1;
214    }
215    seq
216}
217
218/// Builds a per-plane pixel template for the row-major layout.
219///
220/// - `trail_blank`: if true, first `TRAIL_BLANK_DELAY` entries have OE blanked
221///   (address just changed from the previous row).
222/// - `lead_blank`: if true, last `LEAD_BLANK_DELAY + 1` entries have OE blanked
223///   (address change to next row follows).
224/// - The latch entry (last pixel) always has OE blanked — many HUB75 driver ICs
225///   require OE to be inactive during latch for reliable data transfer.
226#[allow(clippy::absurd_extreme_comparisons)]
227#[inline]
228const fn make_row_plane_template<const COLS: usize>(
229    addr: u8,
230    trail_blank: bool,
231    lead_blank: bool,
232) -> [Entry; COLS] {
233    let mut data = [Entry::new(); COLS];
234    let mut i = 0;
235
236    while i < COLS {
237        let is_last = i == COLS - 1;
238
239        let blanked = is_last
240            || (trail_blank && i < TRAIL_BLANK_DELAY)
241            || (lead_blank && i >= COLS.saturating_sub(LEAD_BLANK_DELAY + 1));
242
243        let oe = if blanked { OE_BLANK } else { OE_ACTIVE };
244        let mut val = addr as u16 | oe;
245        if is_last {
246            val |= 0b0010_0000; // latch
247        }
248
249        data[map_index(i)] = Entry::from_raw(val);
250        i += 1;
251    }
252
253    data
254}
255
256/// One scan row's complete data: pixel entries for all bit-planes (LSB-first),
257/// the inter-row blanking gap, and an optional tail word.
258///
259/// The gap is stored between plane 0 and planes 1.. and is *streamed* as the
260/// tail of plane 0's segment (see the module-level DMA descriptor pattern).
261///
262/// Blanking is applied only around the single address change (the
263/// plane0→plane1 boundary); see the module-level documentation:
264/// - Plane 0 (LSB): `prev_addr`, lead blank (+ trail blank iff `PLANES == 1`)
265/// - Gap: `prev_addr`, OE blank — defers the address change to plane 1's
266///   first pixel
267/// - Plane 1: current addr, trail blank (address just changed from plane 0)
268/// - Middle and last planes: current addr, OE active everywhere except latch
269#[derive(Clone, Copy, PartialEq, Debug)]
270#[repr(C)]
271pub struct RowData<const COLS: usize, const PLANES: usize> {
272    #[cfg(feature = "inter-row-blank")]
273    pub(crate) plane0: PixelPlane<COLS>,
274    #[cfg(feature = "inter-row-blank")]
275    pub(crate) gap: [Entry; INTER_ROW_BLANK],
276    pub(crate) planes: [PixelPlane<COLS>; PLANES],
277    #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
278    pub(crate) padding: Entry,
279    #[cfg(feature = "tail-closes-latch")]
280    pub(crate) tail: Entry,
281    #[cfg(all(not(feature = "esp32-ordering"), feature = "tail-closes-latch"))]
282    pub(crate) padding: Entry,
283}
284
285impl<const COLS: usize, const PLANES: usize> RowData<COLS, PLANES> {
286    const fn new() -> Self {
287        Self {
288            #[cfg(feature = "inter-row-blank")]
289            plane0: PixelPlane::new(),
290            #[cfg(feature = "inter-row-blank")]
291            gap: [Entry::new(); INTER_ROW_BLANK],
292            planes: [PixelPlane::new(); PLANES],
293            #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
294            padding: Entry::new(),
295            #[cfg(feature = "tail-closes-latch")]
296            tail: Entry::new(),
297            #[cfg(all(not(feature = "esp32-ordering"), feature = "tail-closes-latch"))]
298            padding: Entry::new(),
299        }
300    }
301
302    /// Indexes the logical plane array (`0` → plane 0, `1..` → planes 1..).
303    #[cfg(test)]
304    #[inline]
305    const fn plane(&self, p: usize) -> &PixelPlane<COLS> {
306        &self.planes[p]
307    }
308
309    /// Mutable counterpart of the `plane` accessor.
310    #[inline]
311    const fn plane_mut(&mut self, p: usize) -> &mut PixelPlane<COLS> {
312        &mut self.planes[p]
313    }
314
315    /// Copies plane 0 into the dedicated head field that prefixes the
316    /// inter-row gap (the two must always agree).
317    #[cfg(feature = "inter-row-blank")]
318    #[inline]
319    const fn sync_plane0(&mut self) {
320        self.plane0 = self.planes[0];
321    }
322
323    /// Format control signals for this row.
324    ///
325    /// - `prev_addr`: previous row address (used by plane 0 / LSB and the gap).
326    /// - `addr`: current row address (used by planes 1+ and the tail).
327    const fn format(&mut self, prev_addr: u8, addr: u8) {
328        let mut p = 0;
329        while p < PLANES {
330            let is_first = p == 0;
331
332            if is_first {
333                // Plane 0 (LSB, 1 rep): uses prev_addr, lead blank.
334                // With a single plane the address changes at every row
335                // boundary (entries alternate prev_addr), so a trail blank
336                // is needed as well to let the address settle while blanked.
337                let template = make_row_plane_template::<COLS>(prev_addr, PLANES == 1, true);
338                let mut c = 0;
339                while c < COLS {
340                    self.plane_mut(p).data[c] = template[c];
341                    c += 1;
342                }
343            } else {
344                // Planes 1+ use current addr.
345                // Plane 1: trail blank (address just changed from prev_addr).
346                let trail = p == 1;
347                let template = make_row_plane_template::<COLS>(addr, trail, false);
348                let mut c = 0;
349                while c < COLS {
350                    self.plane_mut(p).data[c] = template[c];
351                    c += 1;
352                }
353            }
354
355            p += 1;
356        }
357
358        // Plane 0 is duplicated into the gap-prefix head, so the gap segment
359        // streams `plane0 + gap`.
360        #[cfg(feature = "inter-row-blank")]
361        {
362            self.sync_plane0();
363        }
364
365        // The gap is streamed between plane 0 (shifted out with `prev_addr`)
366        // and plane 1 (whose first pixel changes the address): it keeps the
367        // previous row address with OE blank, deferring the address change
368        // until after the gap. With `PLANES == 1` the gap is streamed after
369        // the single plane and the address changes at the next row's plane 0
370        // first pixel instead.
371        #[cfg(feature = "inter-row-blank")]
372        {
373            let gap_entry = Entry::from_raw(prev_addr as u16 | OE_BLANK);
374            let mut i = 0;
375            while i < INTER_ROW_BLANK {
376                self.gap[i] = gap_entry;
377                i += 1;
378            }
379        }
380
381        #[cfg(feature = "tail-closes-latch")]
382        {
383            self.tail = Entry::from_raw(addr as u16 | OE_BLANK);
384            self.padding = Entry::from_raw(addr as u16 | OE_BLANK);
385        }
386    }
387}
388
389/// Row-major BCM framebuffer for row-by-row Binary Code Modulation.
390///
391/// See the [module-level documentation](self) for layout details and DMA
392/// descriptor patterns.
393///
394/// # Type Parameters
395/// - `NROWS`: number of multiplexed row pairs (panel height / 2)
396/// - `COLS`: number of columns (panel width)
397/// - `PLANES`: number of bit-planes (typically 8 for full 8-bit colour)
398#[derive(Copy, Clone)]
399#[repr(C, align(4))]
400pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
401    pub(crate) rows: [RowData<COLS, PLANES>; NROWS],
402}
403
404impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
405    DmaFrameBuffer<NROWS, COLS, PLANES>
406{
407    /// Creates a new framebuffer, pre-formatted and ready for use.
408    ///
409    /// # Panics
410    /// Panics if `NROWS` is not within `1..=32` (5-bit row address) or
411    /// `PLANES` is not within `1..=8` (8-bit color depth). With the
412    /// `esp32-ordering` feature, also panics if `COLS` is not even (the
413    /// ESP32's byte-order swap requires an even column count). In const
414    /// contexts (e.g. `static` framebuffers) this is a compile-time error.
415    #[must_use]
416    pub const fn new() -> Self {
417        assert!(NROWS >= 1 && NROWS <= 32, "NROWS must be within 1..=32");
418        assert!(PLANES >= 1 && PLANES <= 8, "PLANES must be within 1..=8");
419        #[cfg(feature = "esp32-ordering")]
420        assert!(
421            COLS % 2 == 0,
422            "esp32-ordering feature requires COLS to be even"
423        );
424        let mut instance = Self {
425            rows: [RowData::new(); NROWS],
426        };
427        instance.format();
428        instance
429    }
430
431    /// Number of bit-planes.
432    #[must_use]
433    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
434    pub const fn bcm_chunk_count() -> usize {
435        PLANES
436    }
437
438    /// Byte size of one plane's pixel row.
439    #[must_use]
440    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
441    pub const fn bcm_chunk_bytes() -> usize {
442        COLS * core::mem::size_of::<Entry>()
443    }
444
445    /// Number of multiplexed scan rows.
446    #[must_use]
447    pub const fn bcm_row_count() -> usize {
448        NROWS
449    }
450
451    /// Byte size of the per-row overhead (inter-row gap + optional tail).
452    #[must_use]
453    pub const fn bcm_row_bytes() -> usize {
454        GAP_BYTES + TRAILER_BYTES
455    }
456
457    /// Formats the framebuffer with row addresses and control bits.
458    ///
459    /// Plane 0 (LSB, 1 rep) uses `prev_addr` so the brief display of
460    /// stale data from the previous row has minimal visual weight.
461    /// Planes 1+ use the current row address. Blanking is applied only
462    /// around the plane0→plane1 address change (plus plane 0's trail
463    /// blank when `PLANES == 1`); see the module-level documentation for
464    /// the exact distribution.
465    #[inline]
466    pub const fn format(&mut self) {
467        let mut slot = 0;
468        while slot < NROWS {
469            let (prev_addr, addr) = slot_addresses::<NROWS>(slot);
470            self.rows[slot].format(prev_addr, addr);
471            slot += 1;
472        }
473    }
474
475    /// Erase pixel colours while preserving row control data.
476    #[inline]
477    pub fn erase(&mut self) {
478        const MASK: u16 = !0b0111_1110_0000_0000; // clear bits 9-14
479        for row in &mut self.rows {
480            for p in 0..PLANES {
481                for entry in &mut row.plane_mut(p).data {
482                    entry.mask_raw(MASK);
483                }
484            }
485            #[cfg(feature = "inter-row-blank")]
486            {
487                row.sync_plane0();
488            }
489        }
490    }
491
492    /// Set a pixel in the framebuffer.
493    #[inline]
494    pub fn set_pixel(&mut self, p: Point, color: Color) {
495        if p.x < 0 || p.y < 0 {
496            return;
497        }
498        self.set_pixel_internal(p.x as usize, p.y as usize, color);
499    }
500
501    #[inline]
502    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
503        if x >= COLS || y >= NROWS * 2 {
504            return;
505        }
506
507        // Early exit for black pixels - common in UI backgrounds
508        // Only enabled when skip-black-pixels feature is active
509        #[cfg(feature = "skip-black-pixels")]
510        if color == Color::BLACK {
511            return;
512        }
513
514        let row_idx = map_row_index::<NROWS>(if y < NROWS { y } else { y - NROWS });
515        let is_top = y < NROWS;
516        let red = color.r();
517        let green = color.g();
518        let blue = color.b();
519
520        let col_idx = map_index(x);
521        for plane_idx in 0..PLANES {
522            let bit = (8 - PLANES + plane_idx) as u32;
523            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
524                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
525                | u8::from(((red >> bit) & 1) != 0);
526            let entry = &mut self.rows[row_idx].plane_mut(plane_idx).data[col_idx];
527            if is_top {
528                entry.set_color0_bits(bits);
529            } else {
530                entry.set_color1_bits(bits);
531            }
532        }
533        // Mirror just the touched column into the gap-prefix head (format and
534        // erase keep the two copies fully in sync, so only this one entry can
535        // have changed here).
536        #[cfg(feature = "inter-row-blank")]
537        {
538            self.rows[row_idx].plane0.data[col_idx] = self.rows[row_idx].planes[0].data[col_idx];
539        }
540    }
541
542    /// Returns a pointer and byte length for a plane's pixel row.
543    ///
544    /// # Panics
545    /// Panics if `row_idx >= NROWS` or `plane_idx >= PLANES`.
546    #[must_use]
547    pub fn pixel_row_ptr_len(&self, row_idx: usize, plane_idx: usize) -> (*const u8, usize) {
548        assert!(
549            row_idx < NROWS,
550            "row_idx {row_idx} out of range for {NROWS} rows"
551        );
552        assert!(
553            plane_idx < PLANES,
554            "plane_idx {plane_idx} out of range for {PLANES} planes"
555        );
556        let ptr = (&raw const self.rows[row_idx].planes[plane_idx]).cast::<u8>();
557        let len = COLS * core::mem::size_of::<Entry>();
558        (ptr, len)
559    }
560
561    /// Returns a pointer and byte length for a row's inter-row blanking gap.
562    ///
563    /// The gap is streamed as the tail of plane 0's segment (LSB) and holds
564    /// the previous row address with OE blank, deferring the address change
565    /// until after the gap. Returns length 0 when no `inter-row-blank-*`
566    /// feature is enabled.
567    ///
568    /// # Panics
569    /// Panics if `row_idx >= NROWS`.
570    #[must_use]
571    pub fn gap_ptr_len(&self, row_idx: usize) -> (*const u8, usize) {
572        assert!(
573            row_idx < NROWS,
574            "row_idx {row_idx} out of range for {NROWS} rows"
575        );
576        #[cfg(feature = "inter-row-blank")]
577        let ptr = (&raw const self.rows[row_idx].gap).cast::<u8>();
578        #[cfg(not(feature = "inter-row-blank"))]
579        let ptr = (&raw const self.rows[row_idx].planes[0]).cast::<u8>();
580        (ptr, GAP_BYTES)
581    }
582
583    /// Returns a pointer and byte length for a row's end-of-row trailer (the
584    /// optional padding and `tail-closes-latch` word).
585    ///
586    /// Returns length 0 when the `tail-closes-latch` feature is disabled.
587    ///
588    /// # Panics
589    /// Panics if `row_idx >= NROWS`.
590    #[must_use]
591    pub fn trailer_ptr_len(&self, row_idx: usize) -> (*const u8, usize) {
592        assert!(
593            row_idx < NROWS,
594            "row_idx {row_idx} out of range for {NROWS} rows"
595        );
596        #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
597        let ptr = (&raw const self.rows[row_idx].padding).cast::<u8>();
598        #[cfg(all(feature = "tail-closes-latch", not(feature = "esp32-ordering")))]
599        let ptr = (&raw const self.rows[row_idx].tail).cast::<u8>();
600        #[cfg(not(feature = "tail-closes-latch"))]
601        let ptr = (&raw const self.rows[row_idx].planes[0]).cast::<u8>();
602        (ptr, TRAILER_BYTES)
603    }
604}
605
606impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
607    for DmaFrameBuffer<NROWS, COLS, PLANES>
608{
609    fn default() -> Self {
610        Self::new()
611    }
612}
613
614impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
615    for DmaFrameBuffer<NROWS, COLS, PLANES>
616{
617    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
618        f.debug_struct("DmaFrameBuffer")
619            .field("size", &core::mem::size_of_val(self))
620            .field("row_count", &NROWS)
621            .field("planes", &PLANES)
622            .field("cols", &COLS)
623            .finish()
624    }
625}
626
627#[cfg(feature = "defmt")]
628impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
629    for DmaFrameBuffer<NROWS, COLS, PLANES>
630{
631    fn format(&self, f: defmt::Formatter) {
632        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
633        defmt::write!(f, " size: {}", core::mem::size_of_val(self));
634    }
635}
636
637impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
638    for DmaFrameBuffer<NROWS, COLS, PLANES>
639{
640    type Word = u16;
641
642    const BCM_SEQUENCE: [BcmLenReps; BCM_SEQUENCE_CAPACITY] = bcm_sequence::<COLS, PLANES>();
643
644    const BCM_SEQUENCE_LEN: usize = PLANES;
645
646    const BCM_SEQUENCE_COUNT: usize = NROWS;
647
648    const BCM_SEGMENTS_PER_GROUP: usize = PLANES;
649
650    fn bcm_segment_ptr(&self, index: usize) -> *const u8 {
651        let segments_per_row = PLANES;
652        assert!(
653            index < NROWS * segments_per_row,
654            "segment index {index} out of range"
655        );
656        let row_idx = index / segments_per_row;
657        let plane_idx = index % segments_per_row;
658        #[cfg(feature = "inter-row-blank")]
659        {
660            if plane_idx == 0 {
661                // Plane 0's gap segment streams the dedicated head (`plane0`)
662                // followed by the gap.
663                return (&raw const self.rows[row_idx].plane0).cast::<u8>();
664            }
665        }
666        let (ptr, _) = self.pixel_row_ptr_len(row_idx, plane_idx);
667        ptr
668    }
669}
670
671impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
672    for DmaFrameBuffer<NROWS, COLS, PLANES>
673{
674    #[inline]
675    fn erase(&mut self) {
676        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
677    }
678
679    #[inline]
680    fn set_pixel(&mut self, p: Point, color: Color) {
681        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
682    }
683}
684
685impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
686    for DmaFrameBuffer<NROWS, COLS, PLANES>
687{
688}
689
690impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
691    for DmaFrameBuffer<NROWS, COLS, PLANES>
692{
693    fn size(&self) -> Size {
694        Size::new(COLS as u32, (NROWS * 2) as u32)
695    }
696}
697
698impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
699    for DmaFrameBuffer<NROWS, COLS, PLANES>
700{
701    type Color = Color;
702    type Error = Infallible;
703
704    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
705    where
706        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
707    {
708        for pixel in pixels {
709            if pixel.0.x < 0 || pixel.0.y < 0 {
710                continue;
711            }
712            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
713        }
714        Ok(())
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    extern crate std;
721
722    use super::super::{LEAD_BLANK_DELAY, OE_ACTIVE, TRAIL_BLANK_DELAY};
723    use super::*;
724    use embedded_graphics::prelude::*;
725    use std::format;
726
727    const TEST_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
728        128
729    } else {
730        64
731    };
732    const TEST_PLANES: usize = 8;
733    type TestBuffer = DmaFrameBuffer<16, TEST_COLS, TEST_PLANES>;
734
735    #[test]
736    fn format_sets_addresses_per_plane() {
737        let fb = TestBuffer::new();
738
739        for slot in 0..16 {
740            let (prev_addr, addr) = slot_addresses::<16>(slot);
741            let prev_addr = u16::from(prev_addr);
742
743            // Plane 0 (LSB): prev_addr
744            for col in 0..TEST_COLS {
745                let entry = fb.rows[slot].plane(0).data[col];
746                assert_eq!(
747                    entry.addr(),
748                    prev_addr,
749                    "slot {slot} plane 0 col {col} should use prev_addr"
750                );
751            }
752
753            // Planes 1+: current addr
754            for plane_idx in 1..TEST_PLANES {
755                for col in 0..TEST_COLS {
756                    let entry = fb.rows[slot].plane(plane_idx).data[col];
757                    assert_eq!(
758                        entry.addr(),
759                        u16::from(addr),
760                        "slot {slot} plane {plane_idx} col {col} should use current addr"
761                    );
762                }
763            }
764        }
765    }
766
767    #[test]
768    fn planes_match_row_plane_template() {
769        let fb = TestBuffer::new();
770
771        for slot in 0..16 {
772            let (prev_addr, addr) = slot_addresses::<16>(slot);
773
774            // Plane 0 (LSB): prev_addr, lead blank only (no address change
775            // at its start, so no trail blank)
776            let expected = make_row_plane_template::<TEST_COLS>(prev_addr, false, true);
777            assert_eq!(
778                fb.rows[slot].plane(0).data,
779                expected,
780                "slot {slot} plane 0 doesn't match template"
781            );
782
783            // Planes 1+: current addr, trail blank on plane 1 only, no lead blank
784            for p in 1..TEST_PLANES {
785                let expected = make_row_plane_template::<TEST_COLS>(addr, p == 1, false);
786                assert_eq!(
787                    fb.rows[slot].plane(p).data,
788                    expected,
789                    "slot {slot} plane {p} doesn't match template"
790                );
791            }
792        }
793    }
794
795    #[test]
796    fn pixel_rows_have_latch_at_last_entry() {
797        let fb = TestBuffer::new();
798
799        for row in &fb.rows {
800            for p in 0..TEST_PLANES {
801                let plane = row.plane(p);
802                let last_idx = map_index(TEST_COLS - 1);
803                assert!(
804                    plane.data[last_idx].latch(),
805                    "last pixel must have latch set"
806                );
807            }
808        }
809    }
810
811    #[test]
812    fn first_plane_has_lead_blank_only() {
813        let fb = TestBuffer::new();
814        let oe_active = cfg!(feature = "invert-oe");
815
816        for row in &fb.rows {
817            let plane = row.plane(0);
818
819            // No trail blank: the previous row's planes and trailer all carry
820            // the same prev_addr, so no address change occurs at plane 0's start.
821            let first_idx = map_index(0);
822            assert_eq!(
823                plane.data[first_idx].output_enable(),
824                oe_active,
825                "plane 0: first pixel should have OE active (no trail blank)"
826            );
827
828            let active_idx = map_index(TRAIL_BLANK_DELAY);
829            assert_eq!(
830                plane.data[active_idx].output_enable(),
831                oe_active,
832                "plane 0: pixel after trail blank position should have OE active"
833            );
834
835            let lead_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
836            assert_eq!(
837                plane.data[lead_idx].output_enable(),
838                !oe_active,
839                "plane 0: lead blank should have OE blank"
840            );
841
842            let latch_idx = map_index(TEST_COLS - 1);
843            assert_eq!(
844                plane.data[latch_idx].output_enable(),
845                !oe_active,
846                "plane 0: latch should have OE blank"
847            );
848        }
849    }
850
851    #[test]
852    fn last_plane_blanks_only_at_latch() {
853        let fb = TestBuffer::new();
854        let oe_active = cfg!(feature = "invert-oe");
855        let last = TEST_PLANES - 1;
856
857        for row in &fb.rows {
858            let plane = row.plane(last);
859
860            let first_idx = map_index(0);
861            assert_eq!(
862                plane.data[first_idx].output_enable(),
863                oe_active,
864                "last plane: first pixel should have OE active (no trail blank)"
865            );
866
867            // No lead blank: the trailer and the next row's plane 0 keep this
868            // row's address, so no address change follows the MSB plane.
869            if LEAD_BLANK_DELAY > 0 {
870                let lead_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
871                assert_eq!(
872                    plane.data[lead_idx].output_enable(),
873                    oe_active,
874                    "last plane: near-end pixel should have OE active (no lead blank)"
875                );
876            }
877
878            let latch_idx = map_index(TEST_COLS - 1);
879            assert_eq!(
880                plane.data[latch_idx].output_enable(),
881                !oe_active,
882                "last plane: latch should have OE blank"
883            );
884        }
885    }
886
887    #[test]
888    fn middle_planes_have_oe_active_except_latch() {
889        let fb = TestBuffer::new();
890        let oe_active = cfg!(feature = "invert-oe");
891
892        // Middle planes are 2..PLANES-2 (plane 1 has trail blank, last has lead blank)
893        for row in &fb.rows {
894            for plane_idx in 2..TEST_PLANES - 1 {
895                for col in 0..TEST_COLS {
896                    let entry = row.plane(plane_idx).data[col];
897                    if col == map_index(TEST_COLS - 1) {
898                        assert_eq!(
899                            entry.output_enable(),
900                            !oe_active,
901                            "middle plane {plane_idx}: latch should have OE blank"
902                        );
903                    } else {
904                        assert_eq!(
905                            entry.output_enable(),
906                            oe_active,
907                            "middle plane {plane_idx} col {col} should have OE active"
908                        );
909                    }
910                }
911            }
912        }
913    }
914
915    #[test]
916    fn plane_1_has_trail_blank_only() {
917        let fb = TestBuffer::new();
918        let oe_active = cfg!(feature = "invert-oe");
919
920        for row in &fb.rows {
921            let plane = row.plane(1);
922
923            if TRAIL_BLANK_DELAY > 0 {
924                let trail_idx = map_index(0);
925                assert_eq!(
926                    plane.data[trail_idx].output_enable(),
927                    !oe_active,
928                    "plane 1: trail blank should have OE blank"
929                );
930            }
931
932            let active_idx = map_index(TRAIL_BLANK_DELAY);
933            assert_eq!(
934                plane.data[active_idx].output_enable(),
935                oe_active,
936                "plane 1: pixel after trail blank should have OE active"
937            );
938
939            let near_end = map_index(TEST_COLS - 2);
940            assert_eq!(
941                plane.data[near_end].output_enable(),
942                oe_active,
943                "plane 1: near-end pixel should have OE active (no lead blank)"
944            );
945
946            let latch_idx = map_index(TEST_COLS - 1);
947            assert_eq!(
948                plane.data[latch_idx].output_enable(),
949                !oe_active,
950                "plane 1: latch should have OE blank"
951            );
952        }
953    }
954
955    #[test]
956    fn single_plane_buffer_has_both_blanks_with_prev_addr() {
957        type OnePlane = DmaFrameBuffer<16, TEST_COLS, 1>;
958        let fb = OnePlane::new();
959        let oe_active = cfg!(feature = "invert-oe");
960
961        for slot in 0..16 {
962            let (prev_addr, _) = slot_addresses::<16>(slot);
963            let prev_addr = u16::from(prev_addr);
964            let plane = fb.rows[slot].plane(0);
965
966            // Single plane uses prev_addr
967            assert_eq!(
968                plane.data[map_index(0)].addr(),
969                prev_addr,
970                "single plane slot {slot}: should use prev_addr"
971            );
972
973            if TRAIL_BLANK_DELAY > 0 {
974                let trail_idx = map_index(0);
975                assert_eq!(
976                    plane.data[trail_idx].output_enable(),
977                    !oe_active,
978                    "single plane: trail blank should have OE blank"
979                );
980            }
981
982            if LEAD_BLANK_DELAY > 0 {
983                let lead_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
984                assert_eq!(
985                    plane.data[lead_idx].output_enable(),
986                    !oe_active,
987                    "single plane: lead blank should have OE blank"
988                );
989            }
990
991            let active_idx = map_index(TRAIL_BLANK_DELAY);
992            assert_eq!(
993                plane.data[active_idx].output_enable(),
994                oe_active,
995                "single plane: active pixel should have OE active"
996            );
997        }
998    }
999
1000    #[test]
1001    fn set_pixel_maps_top_half_bits_per_plane() {
1002        let mut fb = TestBuffer::new();
1003        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
1004        fb.set_pixel(Point::new(2, 3), color);
1005
1006        for plane_idx in 0..TEST_PLANES {
1007            let bit = 8 - TEST_PLANES + plane_idx;
1008            let entry = fb.rows[map_row_index::<16>(3)].plane(plane_idx).data[map_index(2)];
1009            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
1010            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
1011            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
1012        }
1013    }
1014
1015    #[test]
1016    fn set_pixel_maps_bottom_half_bits_per_plane() {
1017        let mut fb = TestBuffer::new();
1018        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
1019        fb.set_pixel(Point::new(4, 20), color);
1020
1021        for plane_idx in 0..TEST_PLANES {
1022            let bit = 8 - TEST_PLANES + plane_idx;
1023            let entry = fb.rows[map_row_index::<16>(4)].plane(plane_idx).data[map_index(4)];
1024            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
1025            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
1026            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
1027        }
1028    }
1029
1030    #[test]
1031    #[cfg(feature = "reverse-row-order")]
1032    fn reverse_row_order_stores_rows_back_to_front() {
1033        let mut fb = TestBuffer::new();
1034
1035        // Slot 0 is streamed first and renders the last panel row: plane 0
1036        // carries prev_addr 0 (row 0 is displayed while it is shifted) and
1037        // planes 1+ carry the slot's own address 15.
1038        assert_eq!(fb.rows[0].plane(0).data[map_index(0)].addr(), 0);
1039        assert_eq!(fb.rows[0].plane(1).data[map_index(0)].addr(), 15);
1040        // The final slot renders panel row 0: prev_addr 1, own address 0.
1041        assert_eq!(fb.rows[15].plane(0).data[map_index(0)].addr(), 1);
1042        assert_eq!(fb.rows[15].plane(1).data[map_index(0)].addr(), 0);
1043
1044        // Logical row 0 maps to the last memory slot.
1045        fb.set_pixel(Point::new(2, 0), Color::RED);
1046        let col2 = map_index(2);
1047        assert!(fb.rows[15].plane(TEST_PLANES - 1).data[col2].red1());
1048        assert!(!fb.rows[0].plane(TEST_PLANES - 1).data[col2].red1());
1049    }
1050
1051    #[test]
1052    fn erase_clears_only_color_bits() {
1053        let mut fb = TestBuffer::new();
1054        let oe_before = fb.rows[map_row_index::<16>(0)].plane(0).data[map_index(1)].output_enable();
1055        fb.set_pixel(Point::new(0, 0), Color::WHITE);
1056        fb.erase();
1057
1058        for row in &fb.rows {
1059            for p in 0..TEST_PLANES {
1060                let plane = row.plane(p);
1061                for entry in &plane.data {
1062                    assert!(!entry.red1());
1063                    assert!(!entry.grn1());
1064                    assert!(!entry.blu1());
1065                    assert!(!entry.red2());
1066                    assert!(!entry.grn2());
1067                    assert!(!entry.blu2());
1068                }
1069            }
1070        }
1071
1072        assert_eq!(
1073            fb.rows[map_row_index::<16>(0)].plane(0).data[map_index(1)].output_enable(),
1074            oe_before
1075        );
1076    }
1077
1078    #[test]
1079    fn erase_preserves_latch_and_oe_pattern() {
1080        let fb_fresh = TestBuffer::new();
1081        let mut fb = TestBuffer::new();
1082        fb.set_pixel(Point::new(0, 0), Color::WHITE);
1083        fb.erase();
1084
1085        for row_idx in 0..16 {
1086            for plane_idx in 0..TEST_PLANES {
1087                for col in 0..TEST_COLS {
1088                    let entry = fb.rows[row_idx].plane(plane_idx).data[col];
1089                    let fresh = fb_fresh.rows[row_idx].plane(plane_idx).data[col];
1090                    assert_eq!(
1091                        entry.output_enable(),
1092                        fresh.output_enable(),
1093                        "OE mismatch at row {row_idx} plane {plane_idx} col {col}"
1094                    );
1095                    assert_eq!(
1096                        entry.latch(),
1097                        fresh.latch(),
1098                        "latch mismatch at row {row_idx} plane {plane_idx} col {col}"
1099                    );
1100                    assert_eq!(
1101                        entry.addr(),
1102                        fresh.addr(),
1103                        "addr mismatch at row {row_idx} plane {plane_idx} col {col}"
1104                    );
1105                }
1106            }
1107        }
1108    }
1109
1110    #[cfg(feature = "inter-row-blank")]
1111    #[test]
1112    fn erase_preserves_gap() {
1113        let fb_fresh = TestBuffer::new();
1114        let mut fb = TestBuffer::new();
1115        fb.set_pixel(Point::new(0, 0), Color::WHITE);
1116        fb.erase();
1117
1118        for row_idx in 0..16 {
1119            assert_eq!(
1120                fb.rows[row_idx].gap, fb_fresh.rows[row_idx].gap,
1121                "gap mismatch at row {row_idx}"
1122            );
1123        }
1124    }
1125
1126    #[cfg(feature = "inter-row-blank")]
1127    #[test]
1128    fn gap_entries_hold_prev_addr_and_oe_blank() {
1129        let fb = TestBuffer::new();
1130        for (slot, row) in fb.rows.iter().enumerate() {
1131            let (prev_addr, _) = slot_addresses::<16>(slot);
1132            let prev_addr = u16::from(prev_addr);
1133            for entry in &row.gap {
1134                assert_eq!(
1135                    entry.addr(),
1136                    prev_addr,
1137                    "gap entry should hold prev_addr at slot {slot}"
1138                );
1139                assert_eq!(
1140                    entry.0 & 0b1_0000_0000,
1141                    OE_BLANK,
1142                    "gap entry should have OE blank at slot {slot}"
1143                );
1144                assert!(!entry.latch(), "gap entry must not latch at slot {slot}");
1145            }
1146        }
1147    }
1148
1149    #[test]
1150    #[allow(deprecated)]
1151    fn bcm_const_fns_return_expected_values() {
1152        assert_eq!(TestBuffer::bcm_chunk_count(), TEST_PLANES);
1153        assert_eq!(
1154            TestBuffer::bcm_chunk_bytes(),
1155            TEST_COLS * core::mem::size_of::<Entry>()
1156        );
1157        assert_eq!(TestBuffer::bcm_row_count(), 16);
1158        assert_eq!(TestBuffer::bcm_row_bytes(), GAP_BYTES + TRAILER_BYTES);
1159    }
1160
1161    #[test]
1162    fn draw_target_iter_sets_pixels() {
1163        let mut fb = TestBuffer::new();
1164        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
1165        let result = fb.draw_iter(pixels);
1166        assert!(result.is_ok());
1167
1168        for plane_idx in 0..TEST_PLANES {
1169            let bit = 8 - TEST_PLANES + plane_idx;
1170            let entry = fb.rows[map_row_index::<16>(1)].plane(plane_idx).data[map_index(1)];
1171            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
1172            assert!(!entry.grn1());
1173            assert!(!entry.blu1());
1174        }
1175    }
1176
1177    #[test]
1178    fn set_pixel_ignores_out_of_bounds_and_negative() {
1179        let mut fb = TestBuffer::new();
1180        let before = fb.rows;
1181        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
1182        fb.set_pixel(Point::new(0, -1), Color::WHITE);
1183        fb.set_pixel(Point::new(TEST_COLS as i32, 0), Color::WHITE);
1184        fb.set_pixel(Point::new(0, 32), Color::WHITE);
1185        assert_eq!(fb.rows, before);
1186    }
1187
1188    #[test]
1189    #[cfg(feature = "skip-black-pixels")]
1190    fn test_skip_black_pixels_enabled() {
1191        let mut fb = TestBuffer::new();
1192
1193        // Set a red pixel first
1194        fb.set_pixel_internal(10, 5, Color::RED);
1195
1196        // Verify it's red in the first plane
1197        let mapped_col_10 = map_index(10);
1198        assert!(fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].red1());
1199        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].grn1());
1200        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].blu1());
1201
1202        // Now set it to black - with skip-black-pixels enabled, this should be ignored
1203        fb.set_pixel_internal(10, 5, Color::BLACK);
1204
1205        // The pixel should still be red (black write was skipped)
1206        assert!(fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].red1());
1207        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].grn1());
1208        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].blu1());
1209    }
1210
1211    #[test]
1212    #[cfg(not(feature = "skip-black-pixels"))]
1213    fn test_skip_black_pixels_disabled() {
1214        let mut fb = TestBuffer::new();
1215
1216        // Set a red pixel first
1217        fb.set_pixel_internal(10, 5, Color::RED);
1218
1219        // Verify it's red in the first plane
1220        let mapped_col_10 = map_index(10);
1221        assert!(fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].red1());
1222        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].grn1());
1223        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].blu1());
1224
1225        // Now set it to black - with skip-black-pixels disabled, this should overwrite
1226        fb.set_pixel_internal(10, 5, Color::BLACK);
1227
1228        // The pixel should now be black (all bits false)
1229        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].red1());
1230        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].grn1());
1231        assert!(!fb.rows[map_row_index::<16>(5)].plane(0).data[mapped_col_10].blu1());
1232    }
1233
1234    #[test]
1235    #[should_panic(expected = "NROWS must be within 1..=32")]
1236    fn new_panics_for_too_many_rows() {
1237        let _ = DmaFrameBuffer::<33, TEST_COLS, 8>::new();
1238    }
1239
1240    #[test]
1241    #[should_panic(expected = "PLANES must be within 1..=8")]
1242    fn new_panics_for_too_many_planes() {
1243        let _ = DmaFrameBuffer::<16, TEST_COLS, 9>::new();
1244    }
1245
1246    #[test]
1247    fn pixel_row_ptr_len_returns_correct_pointers() {
1248        let fb = TestBuffer::new();
1249        let (ptr, len) = fb.pixel_row_ptr_len(0, 0);
1250        assert_eq!(len, TEST_COLS * core::mem::size_of::<Entry>());
1251        assert_eq!(ptr, (&raw const fb.rows[0].planes[0]).cast::<u8>());
1252    }
1253
1254    #[test]
1255    fn gap_ptr_len_returns_correct_pointers() {
1256        let fb = TestBuffer::new();
1257        let (ptr, len) = fb.gap_ptr_len(0);
1258        assert_eq!(len, GAP_BYTES);
1259        #[cfg(feature = "inter-row-blank")]
1260        assert_eq!(ptr, (&raw const fb.rows[0].gap).cast::<u8>());
1261        #[cfg(not(feature = "inter-row-blank"))]
1262        assert_eq!(ptr, (&raw const fb.rows[0].planes[0]).cast::<u8>());
1263    }
1264
1265    #[test]
1266    fn trailer_ptr_len_returns_correct_pointers() {
1267        let fb = TestBuffer::new();
1268        let (ptr, len) = fb.trailer_ptr_len(0);
1269        assert_eq!(len, TRAILER_BYTES);
1270        #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
1271        assert_eq!(ptr, (&raw const fb.rows[0].padding).cast::<u8>());
1272        #[cfg(all(feature = "tail-closes-latch", not(feature = "esp32-ordering")))]
1273        assert_eq!(ptr, (&raw const fb.rows[0].tail).cast::<u8>());
1274        #[cfg(not(feature = "tail-closes-latch"))]
1275        assert_eq!(ptr, (&raw const fb.rows[0].planes[0]).cast::<u8>());
1276    }
1277
1278    #[test]
1279    #[should_panic(expected = "out of range")]
1280    fn pixel_row_ptr_len_panics_for_invalid_row() {
1281        let fb = TestBuffer::new();
1282        let _ = fb.pixel_row_ptr_len(16, 0);
1283    }
1284
1285    #[test]
1286    #[should_panic(expected = "out of range")]
1287    fn pixel_row_ptr_len_panics_for_invalid_plane() {
1288        let fb = TestBuffer::new();
1289        let _ = fb.pixel_row_ptr_len(0, 8);
1290    }
1291
1292    #[test]
1293    #[should_panic(expected = "out of range")]
1294    fn trailer_ptr_len_panics_for_invalid_row() {
1295        let fb = TestBuffer::new();
1296        let _ = fb.trailer_ptr_len(16);
1297    }
1298
1299    #[test]
1300    fn origin_dimensions_match_panel_geometry() {
1301        let fb = TestBuffer::new();
1302        assert_eq!(fb.size(), Size::new(TEST_COLS as u32, 32));
1303    }
1304
1305    #[test]
1306    fn default_matches_new() {
1307        let fb_default = TestBuffer::default();
1308        let fb_new = TestBuffer::new();
1309        assert_eq!(fb_default.rows, fb_new.rows);
1310    }
1311
1312    #[test]
1313    fn debug_impl_includes_shape_information() {
1314        let fb = TestBuffer::new();
1315        let s = format!("{fb:?}");
1316        assert!(s.contains("DmaFrameBuffer"));
1317        assert!(s.contains("row_count"));
1318        assert!(s.contains("planes"));
1319    }
1320
1321    static STATIC_FB: TestBuffer = TestBuffer::new();
1322
1323    #[test]
1324    fn static_construction_is_formatted() {
1325        let runtime_fb = TestBuffer::new();
1326
1327        for (ri, row) in STATIC_FB.rows.iter().enumerate() {
1328            #[cfg(feature = "inter-row-blank")]
1329            {
1330                assert_eq!(
1331                    row.plane0, runtime_fb.rows[ri].plane0,
1332                    "static vs runtime plane0 mismatch at row {ri}"
1333                );
1334                assert_eq!(
1335                    row.gap, runtime_fb.rows[ri].gap,
1336                    "static vs runtime gap mismatch at row {ri}"
1337                );
1338            }
1339            assert_eq!(
1340                row.planes, runtime_fb.rows[ri].planes,
1341                "static vs runtime planes mismatch at row {ri}"
1342            );
1343        }
1344    }
1345
1346    #[test]
1347    fn format_reinitializes_at_runtime() {
1348        let mut fb = TestBuffer::new();
1349        fb.erase();
1350        fb.format();
1351
1352        for (ri, row) in fb.rows.iter().enumerate() {
1353            #[cfg(feature = "inter-row-blank")]
1354            {
1355                assert_eq!(
1356                    row.plane0, STATIC_FB.rows[ri].plane0,
1357                    "re-formatted vs static plane0 mismatch at row {ri}"
1358                );
1359                assert_eq!(
1360                    row.gap, STATIC_FB.rows[ri].gap,
1361                    "re-formatted vs static gap mismatch at row {ri}"
1362                );
1363            }
1364            assert_eq!(
1365                row.planes, STATIC_FB.rows[ri].planes,
1366                "re-formatted vs static planes mismatch at row {ri}"
1367            );
1368        }
1369    }
1370
1371    #[test]
1372    fn framebuffer_operations_trait_delegates_correctly() {
1373        let mut fb = TestBuffer::new();
1374        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
1375
1376        // Plane 7 (MSB) carries bit 7; GREEN = 0xFF so bit 7 is set
1377        assert!(fb.rows[map_row_index::<16>(5)].plane(TEST_PLANES - 1).data[map_index(3)].grn1());
1378
1379        FrameBufferOperations::erase(&mut fb);
1380        for row in &fb.rows {
1381            for p in 0..TEST_PLANES {
1382                let plane = row.plane(p);
1383                for entry in &plane.data {
1384                    assert!(!entry.red1());
1385                    assert!(!entry.grn1());
1386                    assert!(!entry.blu1());
1387                    assert!(!entry.red2());
1388                    assert!(!entry.grn2());
1389                    assert!(!entry.blu2());
1390                }
1391            }
1392        }
1393    }
1394
1395    #[test]
1396    fn row_data_size_matches_expected_layout() {
1397        let expected_pixels = core::mem::size_of::<PixelPlane<TEST_COLS>>()
1398            * (TEST_PLANES + usize::from(cfg!(feature = "inter-row-blank")));
1399        let expected_overhead = GAP_BYTES + TRAILER_BYTES;
1400        assert_eq!(
1401            core::mem::size_of::<RowData<TEST_COLS, 8>>(),
1402            expected_pixels + expected_overhead
1403        );
1404    }
1405
1406    #[test]
1407    fn bcm_segment_count_equals_rows_times_planes() {
1408        use crate::FrameBuffer;
1409        let fb = TestBuffer::new();
1410        assert_eq!(fb.bcm_segment_count(), 16 * TEST_PLANES);
1411    }
1412
1413    #[test]
1414    fn bcm_segments_per_group_equals_planes() {
1415        use crate::FrameBuffer;
1416        let fb = TestBuffer::new();
1417        assert_eq!(fb.bcm_segments_per_group(), TEST_PLANES);
1418        assert_eq!(
1419            fb.bcm_segment_count() % fb.bcm_segments_per_group(),
1420            0,
1421            "segment count must be divisible by segments_per_group"
1422        );
1423    }
1424
1425    #[test]
1426    fn bcm_segments_interleave_pixel_and_gap() {
1427        use crate::FrameBuffer;
1428        let fb = TestBuffer::new();
1429        let segments_per_row = TEST_PLANES;
1430
1431        for row in 0..16usize {
1432            // Plane 0 (LSB) comes first. With a gap it streams plane 0 plus
1433            // the gap (whose LATCH=0 entries close plane 0's latch); without
1434            // a gap it covers every plane and ends on the tail word.
1435            let seg = fb.bcm_segment(row * segments_per_row);
1436            let plane_len = TEST_COLS * core::mem::size_of::<Entry>();
1437            let len0 = if cfg!(feature = "inter-row-blank") {
1438                plane_len + GAP_BYTES
1439            } else {
1440                plane_len * TEST_PLANES + TRAILER_BYTES
1441            };
1442            #[cfg(feature = "inter-row-blank")]
1443            {
1444                // With a gap, plane 0's segment streams the dedicated head
1445                // (plane0) rather than the array copy.
1446                assert_eq!(
1447                    seg.ptr,
1448                    (&raw const fb.rows[row].plane0).cast::<u8>(),
1449                    "wrong ptr at row {row} plane 0"
1450                );
1451            }
1452            #[cfg(not(feature = "inter-row-blank"))]
1453            {
1454                let (ptr, _) = fb.pixel_row_ptr_len(row, 0);
1455                assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane 0");
1456            }
1457            assert_eq!(seg.len, len0, "wrong len at row {row} plane 0");
1458            assert_eq!(seg.reps, 1, "wrong reps at row {row} plane 0");
1459
1460            // Planes 1.. each stream the remaining suffix plus the tail word.
1461            for plane in 1..TEST_PLANES {
1462                let seg = fb.bcm_segment(row * segments_per_row + plane);
1463                let (ptr, plane_len) = fb.pixel_row_ptr_len(row, plane);
1464                let expected_reps = if cfg!(feature = "inter-row-blank") && plane == 1 {
1465                    2
1466                } else {
1467                    1 << (plane - 1)
1468                };
1469                assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane {plane}");
1470                assert_eq!(
1471                    seg.len,
1472                    plane_len * (TEST_PLANES - plane) + TRAILER_BYTES,
1473                    "wrong len at row {row} plane {plane}"
1474                );
1475                assert_eq!(
1476                    seg.reps, expected_reps,
1477                    "wrong reps at row {row} plane {plane}"
1478                );
1479            }
1480        }
1481    }
1482
1483    #[test]
1484    fn bcm_segment_total_reps_per_row() {
1485        use crate::FrameBuffer;
1486        let fb = TestBuffer::new();
1487        let segments_per_row = TEST_PLANES;
1488
1489        for row in 0..16usize {
1490            // Suffix coalescing halves the per-row transfer count to
1491            // 2^(PLANES-1); with an inter-row gap, plane 0 stands alone and
1492            // plane 1's extra rep adds one more.
1493            let mut pixel_reps = 0;
1494            for plane in 0..TEST_PLANES {
1495                pixel_reps += fb.bcm_segment(row * segments_per_row + plane).reps;
1496            }
1497            let expected = if cfg!(feature = "inter-row-blank") {
1498                (1 << (TEST_PLANES - 1)) + 1
1499            } else {
1500                1 << (TEST_PLANES - 1)
1501            };
1502            assert_eq!(pixel_reps, expected, "total pixel reps wrong for row {row}");
1503        }
1504    }
1505
1506    #[test]
1507    fn bcm_segment_plane_coverage_matches_bcm_weights() {
1508        use crate::FrameBuffer;
1509        let fb = TestBuffer::new();
1510        let segments_per_row = TEST_PLANES;
1511
1512        for row in 0..16usize {
1513            for plane in 0..TEST_PLANES {
1514                // Sum the reps of every pixel segment that spans this plane:
1515                // the segment for plane `first` covers `plane_suffix` planes
1516                // starting at `first`.
1517                let mut coverage = 0;
1518                for first in 0..=plane {
1519                    let seg = fb.bcm_segment(row * segments_per_row + first);
1520                    let (covered, _) = plane_suffix(first, TEST_PLANES);
1521                    if first + covered > plane {
1522                        coverage += seg.reps;
1523                    }
1524                }
1525                assert_eq!(
1526                    coverage,
1527                    1 << plane,
1528                    "plane {plane} coverage wrong for row {row}"
1529                );
1530            }
1531        }
1532    }
1533
1534    #[test]
1535    #[should_panic(expected = "out of range")]
1536    fn bcm_segment_panics_for_invalid_index() {
1537        use crate::FrameBuffer;
1538        let fb = TestBuffer::new();
1539        let _ = fb.bcm_segment(16 * TEST_PLANES);
1540    }
1541
1542    #[test]
1543    fn bcm_sequence_matches_runtime_segments() {
1544        use crate::FrameBuffer;
1545        let fb = TestBuffer::new();
1546        assert_eq!(
1547            TestBuffer::BCM_SEGMENT_COUNT,
1548            TestBuffer::BCM_SEQUENCE_LEN * TestBuffer::BCM_SEQUENCE_COUNT
1549        );
1550        assert_eq!(fb.bcm_segment_count(), TestBuffer::BCM_SEGMENT_COUNT);
1551        assert_eq!(
1552            fb.bcm_segments_per_group(),
1553            TestBuffer::BCM_SEGMENTS_PER_GROUP
1554        );
1555        assert_eq!(
1556            TestBuffer::BCM_SEQUENCE_LEN % TestBuffer::BCM_SEGMENTS_PER_GROUP,
1557            0
1558        );
1559        for i in 0..TestBuffer::BCM_SEGMENT_COUNT {
1560            let entry = TestBuffer::BCM_SEQUENCE[i % TestBuffer::BCM_SEQUENCE_LEN];
1561            let seg = fb.bcm_segment(i);
1562            assert_eq!((seg.len, seg.reps), (entry.len, entry.reps), "segment {i}");
1563            assert!(!seg.ptr.is_null(), "segment {i} has null pointer");
1564        }
1565        for entry in &TestBuffer::BCM_SEQUENCE[TestBuffer::BCM_SEQUENCE_LEN..] {
1566            assert_eq!(*entry, crate::BcmLenReps::ZERO, "padding must be zero");
1567        }
1568    }
1569
1570    #[test]
1571    fn bcm_segment_data_contains_written_pixels() {
1572        use crate::FrameBuffer;
1573
1574        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1575            128
1576        } else {
1577            64
1578        };
1579        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1580        let mut fb = TrinityBuf::new();
1581
1582        fb.set_pixel(Point::new(10, 0), Color::WHITE);
1583
1584        let col_idx = map_index(10);
1585        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1586        let row0_base = map_row_index::<32>(0) * 5;
1587
1588        for plane_idx in 0..5usize {
1589            // Row 0: plane 0 is the slot's first segment, planes 1+ follow it.
1590            let seg = fb.bcm_segment(row0_base + plane_idx);
1591
1592            let data = unsafe { core::slice::from_raw_parts(seg.ptr as *const u16, TRINITY_COLS) };
1593            let raw = data[col_idx];
1594            let entry = Entry::from_raw(raw);
1595
1596            let bit = 8 - 5 + plane_idx; // LSB-first
1597            let expect_r = (0xFF >> bit) & 1 != 0;
1598            let expect_g = (0xFF >> bit) & 1 != 0;
1599            let expect_b = (0xFF >> bit) & 1 != 0;
1600            assert_eq!(
1601                entry.red1(),
1602                expect_r,
1603                "plane {plane_idx} (bit {bit}) R1 mismatch: raw={raw:#06x}"
1604            );
1605            assert_eq!(
1606                entry.grn1(),
1607                expect_g,
1608                "plane {plane_idx} (bit {bit}) G1 mismatch: raw={raw:#06x}"
1609            );
1610            assert_eq!(
1611                entry.blu1(),
1612                expect_b,
1613                "plane {plane_idx} (bit {bit}) B1 mismatch: raw={raw:#06x}"
1614            );
1615        }
1616    }
1617
1618    #[test]
1619    fn bcm_segment_data_contains_gradient_pixels() {
1620        use crate::FrameBuffer;
1621
1622        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1623            128
1624        } else {
1625            64
1626        };
1627        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1628        let mut fb = TrinityBuf::new();
1629
1630        let step: u8 = (256 / TRINITY_COLS) as u8;
1631        for x in 0..TRINITY_COLS {
1632            let brightness = (x as u8).wrapping_mul(step);
1633            fb.set_pixel(Point::new(x as i32, 0), Color::new(brightness, 0, 0));
1634        }
1635
1636        let mut any_nonzero = false;
1637        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1638        let row0_base = map_row_index::<32>(0) * 5;
1639        for plane_idx in 0..5usize {
1640            // Row 0: plane 0 is the slot's first segment, planes 1+ follow it.
1641            let seg = fb.bcm_segment(row0_base + plane_idx);
1642            let data = unsafe { core::slice::from_raw_parts(seg.ptr as *const u16, TRINITY_COLS) };
1643
1644            for x in 0..TRINITY_COLS {
1645                let col_idx = map_index(x);
1646                let entry = Entry::from_raw(data[col_idx]);
1647                let brightness = (x as u8).wrapping_mul(step);
1648                let bit = 8 - 5 + plane_idx; // LSB-first
1649                let expect_r = (brightness >> bit) & 1 != 0;
1650                assert_eq!(
1651                    entry.red1(),
1652                    expect_r,
1653                    "x={x} plane={plane_idx} bit={bit} brightness={brightness} \
1654                     raw={:#06x}",
1655                    data[col_idx]
1656                );
1657                if expect_r {
1658                    any_nonzero = true;
1659                }
1660            }
1661        }
1662        assert!(
1663            any_nonzero,
1664            "gradient should produce at least some non-zero color bits"
1665        );
1666    }
1667
1668    #[test]
1669    fn make_row_plane_template_both_false_blanks_only_latch() {
1670        let t = make_row_plane_template::<TEST_COLS>(5, false, false);
1671        for i in 0..TEST_COLS {
1672            let entry = t[map_index(i)];
1673            assert_eq!(entry.addr(), 5);
1674            if i == TEST_COLS - 1 {
1675                assert!(entry.latch(), "last entry must latch");
1676                assert_eq!(
1677                    entry.0 & 0b1_0000_0000,
1678                    OE_BLANK,
1679                    "latch should be OE blank"
1680                );
1681            } else {
1682                assert!(!entry.latch(), "col {i} must not latch");
1683                assert_eq!(
1684                    entry.0 & 0b1_0000_0000,
1685                    OE_ACTIVE,
1686                    "col {i} should be OE active"
1687                );
1688            }
1689        }
1690    }
1691
1692    #[test]
1693    fn make_row_plane_template_trail_only() {
1694        let t = make_row_plane_template::<TEST_COLS>(5, true, false);
1695        for i in 0..TEST_COLS {
1696            let entry = t[map_index(i)];
1697            if i < TRAIL_BLANK_DELAY || i == TEST_COLS - 1 {
1698                assert_eq!(
1699                    entry.0 & 0b1_0000_0000,
1700                    OE_BLANK,
1701                    "col {i} should be OE blank"
1702                );
1703            } else {
1704                assert_eq!(
1705                    entry.0 & 0b1_0000_0000,
1706                    OE_ACTIVE,
1707                    "col {i} should be OE active"
1708                );
1709            }
1710        }
1711    }
1712
1713    #[test]
1714    fn make_row_plane_template_lead_only() {
1715        let t = make_row_plane_template::<TEST_COLS>(5, false, true);
1716        for i in 0..TEST_COLS {
1717            let entry = t[map_index(i)];
1718            if i >= TEST_COLS.saturating_sub(LEAD_BLANK_DELAY + 1) {
1719                assert_eq!(
1720                    entry.0 & 0b1_0000_0000,
1721                    OE_BLANK,
1722                    "col {i} should be lead blank"
1723                );
1724            } else {
1725                assert_eq!(
1726                    entry.0 & 0b1_0000_0000,
1727                    OE_ACTIVE,
1728                    "col {i} should be OE active"
1729                );
1730            }
1731        }
1732    }
1733}