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