Skip to main content

hub75_framebuffer/bitplane/latched/
row.rs

1//! Row-major bitplane framebuffer for an 8-bit latched HUB75 interface.
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//! │       ├── planes: [PlaneRow<COLS>; PLANES]   -- LSB-first
16//! │       │   └── PlaneRow
17//! │       │       ├── data: [Entry; COLS]
18//! │       │       └── address: [Address; 4]
19//! │       └── gap: [Entry; INTER_ROW_BLANK]      -- inter-row blanking gap
20//! ```
21//!
22//! The gap is stored after the planes but is *streamed* between plane 0
23//! and plane 1: each BCM segment carries its own pointer, so streaming
24//! order is independent of storage order.
25//!
26//! Planes are stored **LSB-first**: plane 0 carries the least-significant
27//! bit and is displayed once per row; plane `PLANES-1` carries the MSB and
28//! is displayed `2^(PLANES-1)` times per row. This ordering means the stale
29//! display during the first plane after a row change has minimal visual
30//! weight (a single rep at LSB weight), eliminating the need for a separate
31//! primer segment.
32//!
33//! # Addressing and Blanking
34//!
35//! Every plane's pixel data is followed by its four address bytes, which are
36//! always blanked and pulse LAT — so OE is guaranteed to be blank during
37//! every latch without any per-plane blanking. The row address changes
38//! exactly once per scan row, inside plane 0's address bytes (i.e. after
39//! plane 0's pixel data has been shifted out): the external latch holds the
40//! previous row's address throughout plane 0's shift, so every plane simply
41//! carries the *current* row address. Unlike the plain row-major layout
42//! there is no `prev_addr` handling.
43//!
44//! Blanking is applied only around that single address change, mirroring the
45//! plain row-major layout:
46//!
47//! - **Plane 0** (LSB, first): `LEAD_BLANK_DELAY` trailing blanked entries —
48//!   its address bytes change the row address.
49//! - **Inter-row gap** (with an `inter-row-blank-*` feature): blanked dead
50//!   entries streamed immediately after plane 0's address bytes, giving slow
51//!   row drivers extra blanked time right at the latch and address change.
52//! - **Plane 1** (second): `TRAIL_BLANK_DELAY` leading blanked entries — it
53//!   immediately follows the address change (and the gap).
54//! - **Plane PLANES-1** (MSB, last): runs full-width like the middle planes;
55//!   its address bytes are followed by the next row's plane 0, and no
56//!   address change occurs there.
57//! - **PLANES == 1**: the single plane both precedes and follows the address
58//!   change, so it gets both the lead and the trail blank. The gap is
59//!   streamed after the single plane's address bytes.
60//! - All other planes run a full-width OE window.
61//!
62//! `LEAD_BLANK_DELAY` and `TRAIL_BLANK_DELAY` default to 0 for latched
63//! framebuffers (the four blanked address bytes already cover the latch);
64//! enable the `lead-blank-*` / `trail-blank-*` features for panels that need
65//! more settling time around the latch and address change.
66//!
67//! The inter-row gap appears only once per scan row — streamed between
68//! plane 0 (whose address bytes latch the data and change the row address)
69//! and plane 1 — rather than once per plane as in the plane-major layout.
70//!
71//! # DMA Descriptor Pattern
72//!
73//! The plane rows of a row are contiguous (`repr(C)`), so each BCM segment
74//! streams a whole *suffix* of planes (address bytes included): the segment
75//! for plane `k` starts at plane `k` and covers planes `k..PLANES`, repeated
76//! just enough times to bring plane `k`'s total coverage to `2^k` displays
77//! per row. This halves the number of DMA transfers per row —
78//! `2^(PLANES-1)` instead of `2^PLANES - 1` — while the streamed bytes, and
79//! therefore the brightness, are unchanged.
80//!
81//! In [`FrameBuffer`] terms, one row's segments form
82//! one *sequence* — which is also one *group* (a single DMA transfer per
83//! row); one refresh is that sequence repeated `NROWS` times.
84//!
85//! For each row the driver builds descriptors like (no inter-row gap):
86//!
87//! ```text
88//! planes 0..PLANES      × 1 rep        → rows[r].planes[0..]
89//! planes 1..PLANES      × 1 rep        → rows[r].planes[1..]
90//! planes 2..PLANES      × 2 reps       → rows[r].planes[2..]
91//! …
92//! plane  PLANES-1 (MSB) × 2^(PLANES-2) → rows[r].planes[PLANES-1]
93//! ```
94//!
95//! With an `inter-row-blank-*` feature enabled, plane 0 stands alone so the
96//! gap can be streamed right after its address bytes (where the row address
97//! changes), and plane 1's segment gets a second rep to preserve its weight:
98//!
99//! ```text
100//! plane 0 (LSB)         × 1 rep        → rows[r].planes[0]
101//! inter-row gap         × 1            → rows[r].gap
102//! planes 1..PLANES      × 2 reps       → rows[r].planes[1..]
103//! planes 2..PLANES      × 2 reps       → rows[r].planes[2..]
104//! …
105//! plane  PLANES-1 (MSB) × 2^(PLANES-2) → rows[r].planes[PLANES-1]
106//! ```
107
108use core::convert::Infallible;
109
110use embedded_graphics::pixelcolor::RgbColor;
111use embedded_graphics::prelude::{DrawTarget, OriginDimensions, Point, Size};
112
113use super::{
114    map_index, Address, Entry, ADDR_TABLE, INTER_ROW_BLANK, LEAD_BLANK_DELAY, OE_ACTIVE, OE_BLANK,
115    TRAIL_BLANK_DELAY,
116};
117use crate::Color;
118use crate::{map_row_index, slot_addresses};
119use crate::{BcmLenReps, FrameBuffer, BCM_SEQUENCE_CAPACITY};
120use crate::{FrameBufferOperations, MutableFrameBuffer};
121
122/// One bit-plane's payload for a single scan row: `COLS` pixel bytes
123/// followed by the four address bytes that latch the shifted data and select
124/// the row.
125#[derive(Clone, Copy, PartialEq, Debug)]
126#[repr(C)]
127pub struct PlaneRow<const COLS: usize> {
128    pub(crate) data: [Entry; COLS],
129    pub(crate) address: [Address; 4],
130}
131
132impl<const COLS: usize> PlaneRow<COLS> {
133    const fn new() -> Self {
134        Self {
135            data: [Entry::new(); COLS],
136            address: [Address::new(); 4],
137        }
138    }
139}
140
141/// Byte size of the inter-row blanking gap segment (streamed between
142/// plane 0 and plane 1).
143const GAP_BYTES: usize = INTER_ROW_BLANK * core::mem::size_of::<Entry>();
144
145/// Whether this configuration has a non-empty inter-row gap segment.
146#[allow(clippy::absurd_extreme_comparisons)]
147const HAS_GAP: bool = GAP_BYTES > 0;
148
149/// Coalesced BCM suffix for plane `plane_idx`: returns
150/// `(covered_planes, reps)` — how many planes the segment spans (starting at
151/// `plane_idx`) and how many times that suffix is streamed. See the
152/// module-level "DMA Descriptor Pattern" section for the full scheme.
153///
154/// With an inter-row gap, plane 0 stands alone (the gap segment is streamed
155/// between plane 0 and plane 1), so plane 1's segment needs 2 reps to
156/// preserve its weight.
157const fn plane_suffix(plane_idx: usize, planes: usize) -> (usize, usize) {
158    let covered = if HAS_GAP && plane_idx == 0 {
159        1
160    } else {
161        planes - plane_idx
162    };
163    let reps = if plane_idx == 0 {
164        1
165    } else if HAS_GAP && plane_idx == 1 {
166        2
167    } else {
168        1 << (plane_idx - 1)
169    };
170    (covered, reps)
171}
172
173/// BCM sequence for one row: one entry per plane (each streaming the
174/// contiguous plane suffix `plane..PLANES`), plus the inter-row gap segment
175/// when enabled. Entries past the sequence are zero padding.
176#[allow(clippy::absurd_extreme_comparisons)]
177const fn bcm_sequence<const COLS: usize, const PLANES: usize>(
178) -> [BcmLenReps; BCM_SEQUENCE_CAPACITY] {
179    let gap = HAS_GAP as usize;
180    let seq_len = PLANES + gap;
181    assert!(seq_len <= BCM_SEQUENCE_CAPACITY);
182    let plane_bytes = core::mem::size_of::<PlaneRow<COLS>>();
183    let mut seq = [BcmLenReps::ZERO; BCM_SEQUENCE_CAPACITY];
184    let mut within = 0usize;
185    while within < seq_len {
186        let entry = if within == 0 {
187            let (covered, reps) = plane_suffix(0, PLANES);
188            BcmLenReps {
189                len: plane_bytes * covered,
190                reps,
191            }
192        } else if HAS_GAP && within == 1 {
193            BcmLenReps {
194                len: GAP_BYTES,
195                reps: 1,
196            }
197        } else {
198            let (covered, reps) = plane_suffix(within - gap, PLANES);
199            BcmLenReps {
200                len: plane_bytes * covered,
201                reps,
202            }
203        };
204        seq[within] = entry;
205        within += 1;
206    }
207    seq
208}
209
210/// Builds a per-plane pixel template for the row-major layout.
211///
212/// Blanking is applied only around the single address change, which happens
213/// inside plane 0's address bytes (mirroring the plain row-major layout).
214/// The address bytes themselves are always blanked, so blank-during-latch
215/// needs no help from the pixel stream.
216///
217/// - `trail_blank`: blank the `TRAIL_BLANK_DELAY` leading entries — used by
218///   the plane that immediately follows the address change (plane 1, or
219///   plane 0 when `PLANES == 1`).
220/// - `lead_blank`: blank the `LEAD_BLANK_DELAY` trailing entries — used by
221///   plane 0, whose address bytes change the row address.
222#[inline]
223#[allow(clippy::absurd_extreme_comparisons)]
224const fn make_row_plane_template<const COLS: usize>(
225    trail_blank: bool,
226    lead_blank: bool,
227) -> [Entry; COLS] {
228    let trailing = if lead_blank { LEAD_BLANK_DELAY } else { 0 };
229    let lead_start = COLS.saturating_sub(trailing);
230
231    let mut data = [Entry::new(); COLS];
232    let mut i = 0;
233    while i < COLS {
234        let blanked = (trail_blank && i < TRAIL_BLANK_DELAY) || i >= lead_start;
235        data[map_index(i)].0 = if blanked { OE_BLANK } else { OE_ACTIVE };
236        i += 1;
237    }
238    data
239}
240
241/// One scan row's complete data: all bit-planes (LSB-first), each with its
242/// own address bytes, followed by the inter-row blanking gap.
243///
244/// The gap is stored after the planes but is *streamed* between plane 0
245/// (whose address bytes change the row address) and plane 1.
246///
247/// Every plane carries the *current* row address; the external latch holds
248/// the previous row's address throughout plane 0's shift, so the previously
249/// latched plane displays on the correct row during every shift window.
250#[derive(Clone, Copy, PartialEq, Debug)]
251#[repr(C)]
252pub struct RowData<const COLS: usize, const PLANES: usize> {
253    pub(crate) planes: [PlaneRow<COLS>; PLANES],
254    pub(crate) gap: [Entry; INTER_ROW_BLANK],
255}
256
257impl<const COLS: usize, const PLANES: usize> RowData<COLS, PLANES> {
258    const fn new() -> Self {
259        Self {
260            planes: [PlaneRow::new(); PLANES],
261            gap: [Entry::new(); INTER_ROW_BLANK],
262        }
263    }
264
265    /// Format control signals for this row.
266    ///
267    /// `addr` is the current row address, carried by every plane's address
268    /// bytes. The address change happens inside plane 0's address bytes, so
269    /// plane 0 gets the lead blank and plane 1 gets the trail blank (plane 0
270    /// gets both when `PLANES == 1`). All other planes run full-width.
271    const fn format(&mut self, addr: u8) {
272        let src_addr = ADDR_TABLE[addr as usize];
273
274        let mut p = 0;
275        while p < PLANES {
276            let trail_blank = p == 1 || (p == 0 && PLANES == 1);
277            let lead_blank = p == 0;
278            let template = make_row_plane_template::<COLS>(trail_blank, lead_blank);
279            let mut c = 0;
280            while c < COLS {
281                self.planes[p].data[c] = template[c];
282                c += 1;
283            }
284            self.planes[p].address[0] = src_addr[0];
285            self.planes[p].address[1] = src_addr[1];
286            self.planes[p].address[2] = src_addr[2];
287            self.planes[p].address[3] = src_addr[3];
288            p += 1;
289        }
290
291        let mut i = 0;
292        // INTER_ROW_BLANK is 0 unless an inter-row-blank-* feature is enabled.
293        #[allow(clippy::absurd_extreme_comparisons)]
294        while i < INTER_ROW_BLANK {
295            self.gap[i].0 = OE_BLANK;
296            i += 1;
297        }
298    }
299}
300
301/// Row-major BCM framebuffer for row-by-row Binary Code Modulation.
302///
303/// See the [module-level documentation](self) for layout details and DMA
304/// descriptor patterns.
305///
306/// # Type Parameters
307/// - `NROWS`: number of multiplexed row pairs (panel height / 2)
308/// - `COLS`: number of columns (panel width)
309/// - `PLANES`: number of bit-planes (typically 8 for full 8-bit colour)
310#[derive(Copy, Clone)]
311#[repr(C, align(4))]
312pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
313    pub(crate) rows: [RowData<COLS, PLANES>; NROWS],
314}
315
316impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
317    DmaFrameBuffer<NROWS, COLS, PLANES>
318{
319    /// Creates a new framebuffer, pre-formatted and ready for use.
320    ///
321    /// # Panics
322    /// Panics if `NROWS` is not within `1..=32` (5-bit row address) or
323    /// `PLANES` is not within `1..=8` (8-bit color depth). With the
324    /// `esp32-ordering` feature, also panics if `COLS` is not divisible
325    /// by 4 (the ESP32's byte-order rearrangement requires column counts
326    /// that are multiples of 4). In const contexts (e.g. `static`
327    /// framebuffers) this is a compile-time error.
328    ///
329    /// # Memory placement (⚠️ large buffer)
330    ///
331    /// This framebuffer is a large inline array (roughly
332    /// `PLANES × NROWS × (COLS + gap)` bytes). A 64×64 panel at `PLANES = 8`
333    /// is tens of kilobytes, which will overflow a task stack if bound to a
334    /// local `let`. The framebuffer is mutable, so give it a `'static` home
335    /// that hands out `&'static mut` (e.g. `static_cell::StaticCell`) —
336    /// never `static mut`. See the crate-level **Memory placement** section
337    /// for a worked example.
338    #[must_use]
339    pub const fn new() -> Self {
340        assert!(NROWS >= 1 && NROWS <= 32, "NROWS must be within 1..=32");
341        assert!(PLANES >= 1 && PLANES <= 8, "PLANES must be within 1..=8");
342        #[cfg(feature = "esp32-ordering")]
343        assert!(
344            COLS % 4 == 0,
345            "esp32-ordering feature requires COLS to be divisible by 4"
346        );
347        let mut instance = Self {
348            rows: [RowData::new(); NROWS],
349        };
350        instance.format();
351        instance
352    }
353
354    /// Number of bit-planes.
355    #[must_use]
356    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
357    pub const fn bcm_chunk_count() -> usize {
358        PLANES
359    }
360
361    /// Byte size of one plane's payload for a single row (pixel data plus
362    /// its four address bytes).
363    #[must_use]
364    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
365    pub const fn bcm_chunk_bytes() -> usize {
366        core::mem::size_of::<PlaneRow<COLS>>()
367    }
368
369    /// Number of multiplexed scan rows.
370    #[must_use]
371    pub const fn bcm_row_count() -> usize {
372        NROWS
373    }
374
375    /// Byte size of the per-row inter-row blanking gap.
376    #[must_use]
377    pub const fn bcm_row_bytes() -> usize {
378        GAP_BYTES
379    }
380
381    /// Formats the framebuffer with row addresses and control bits.
382    ///
383    /// Every plane carries the current row address; blanking is applied
384    /// around the plane0→plane1 address change. See the module-level
385    /// documentation for the exact distribution.
386    #[inline]
387    pub const fn format(&mut self) {
388        let mut slot = 0;
389        while slot < NROWS {
390            let (_, addr) = slot_addresses::<NROWS>(slot);
391            self.rows[slot].format(addr);
392            slot += 1;
393        }
394    }
395
396    /// Erase pixel colours while preserving row control data.
397    #[inline]
398    pub fn erase(&mut self) {
399        const MASK: u8 = !crate::pinmap::LATCHED_COLOR_MASK;
400        for row in &mut self.rows {
401            for plane in &mut row.planes {
402                for entry in &mut plane.data {
403                    entry.0 &= MASK;
404                }
405            }
406        }
407    }
408
409    /// Set a pixel in the framebuffer.
410    #[inline]
411    pub fn set_pixel(&mut self, p: Point, color: Color) {
412        if p.x < 0 || p.y < 0 {
413            return;
414        }
415        self.set_pixel_internal(p.x as usize, p.y as usize, color);
416    }
417
418    #[inline]
419    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
420        if x >= COLS || y >= NROWS * 2 {
421            return;
422        }
423
424        // Early exit for black pixels - common in UI backgrounds
425        // Only enabled when skip-black-pixels feature is active
426        #[cfg(feature = "skip-black-pixels")]
427        if color == Color::BLACK {
428            return;
429        }
430
431        let row_idx = map_row_index::<NROWS>(if y < NROWS { y } else { y - NROWS });
432        let is_top = y < NROWS;
433        let red = color.r();
434        let green = color.g();
435        let blue = color.b();
436
437        for plane_idx in 0..PLANES {
438            let bit = (8 - PLANES + plane_idx) as u32;
439            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
440                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
441                | u8::from(((red >> bit) & 1) != 0);
442            let col_idx = map_index(x);
443            let entry = &mut self.rows[row_idx].planes[plane_idx].data[col_idx];
444            if is_top {
445                entry.set_color0_bits(bits);
446            } else {
447                entry.set_color1_bits(bits);
448            }
449        }
450    }
451
452    /// Returns a pointer and byte length for a plane's payload (pixel data
453    /// plus its four address bytes) within a single row.
454    ///
455    /// # Panics
456    /// Panics if `row_idx >= NROWS` or `plane_idx >= PLANES`.
457    #[must_use]
458    pub fn pixel_row_ptr_len(&self, row_idx: usize, plane_idx: usize) -> (*const u8, usize) {
459        assert!(
460            row_idx < NROWS,
461            "row_idx {row_idx} out of range for {NROWS} rows"
462        );
463        assert!(
464            plane_idx < PLANES,
465            "plane_idx {plane_idx} out of range for {PLANES} planes"
466        );
467        let ptr = (&raw const self.rows[row_idx].planes[plane_idx]).cast::<u8>();
468        let len = core::mem::size_of::<PlaneRow<COLS>>();
469        (ptr, len)
470    }
471
472    /// Returns a pointer and byte length for a row's inter-row blanking gap.
473    ///
474    /// The gap is streamed between plane 0 (whose address bytes latch the
475    /// data and change the row address) and plane 1. Returns length 0 when
476    /// no `inter-row-blank-*` feature is enabled.
477    ///
478    /// # Panics
479    /// Panics if `row_idx >= NROWS`.
480    #[must_use]
481    pub fn gap_ptr_len(&self, row_idx: usize) -> (*const u8, usize) {
482        assert!(
483            row_idx < NROWS,
484            "row_idx {row_idx} out of range for {NROWS} rows"
485        );
486        let ptr = (&raw const self.rows[row_idx].gap).cast::<u8>();
487        (ptr, GAP_BYTES)
488    }
489}
490
491impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
492    for DmaFrameBuffer<NROWS, COLS, PLANES>
493{
494    fn default() -> Self {
495        Self::new()
496    }
497}
498
499impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
500    for DmaFrameBuffer<NROWS, COLS, PLANES>
501{
502    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
503        f.debug_struct("DmaFrameBuffer")
504            .field("size", &core::mem::size_of_val(self))
505            .field("row_count", &NROWS)
506            .field("planes", &PLANES)
507            .field("cols", &COLS)
508            .finish()
509    }
510}
511
512#[cfg(feature = "defmt")]
513impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
514    for DmaFrameBuffer<NROWS, COLS, PLANES>
515{
516    fn format(&self, f: defmt::Formatter) {
517        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
518        defmt::write!(f, " size: {}", core::mem::size_of_val(self));
519    }
520}
521
522impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
523    for DmaFrameBuffer<NROWS, COLS, PLANES>
524{
525    type Word = u8;
526
527    const BCM_SEQUENCE: [BcmLenReps; BCM_SEQUENCE_CAPACITY] = bcm_sequence::<COLS, PLANES>();
528
529    const BCM_SEQUENCE_LEN: usize = PLANES + HAS_GAP as usize;
530
531    const BCM_SEQUENCE_COUNT: usize = NROWS;
532
533    const BCM_SEGMENTS_PER_GROUP: usize = PLANES + HAS_GAP as usize;
534
535    fn bcm_segment_ptr(&self, index: usize) -> *const u8 {
536        let gap = usize::from(HAS_GAP);
537        let segments_per_row = PLANES + gap;
538        assert!(
539            index < NROWS * segments_per_row,
540            "segment index {index} out of range"
541        );
542        let row_idx = index / segments_per_row;
543        let within_row = index % segments_per_row;
544        if within_row == 0 {
545            let (ptr, _) = self.pixel_row_ptr_len(row_idx, 0);
546            ptr
547        } else if HAS_GAP && within_row == 1 {
548            let (ptr, _) = self.gap_ptr_len(row_idx);
549            ptr
550        } else {
551            let plane_idx = within_row - gap;
552            let (ptr, _) = self.pixel_row_ptr_len(row_idx, plane_idx);
553            ptr
554        }
555    }
556}
557
558impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
559    for DmaFrameBuffer<NROWS, COLS, PLANES>
560{
561    #[inline]
562    fn erase(&mut self) {
563        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
564    }
565
566    #[inline]
567    fn set_pixel(&mut self, p: Point, color: Color) {
568        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
569    }
570}
571
572impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
573    for DmaFrameBuffer<NROWS, COLS, PLANES>
574{
575}
576
577impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
578    for DmaFrameBuffer<NROWS, COLS, PLANES>
579{
580    fn size(&self) -> Size {
581        Size::new(COLS as u32, (NROWS * 2) as u32)
582    }
583}
584
585impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
586    for DmaFrameBuffer<NROWS, COLS, PLANES>
587{
588    type Color = Color;
589    type Error = Infallible;
590
591    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
592    where
593        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
594    {
595        for pixel in pixels {
596            if pixel.0.x < 0 || pixel.0.y < 0 {
597                continue;
598            }
599            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
600        }
601        Ok(())
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    extern crate std;
608
609    use super::super::{LEAD_BLANK_DELAY, TRAIL_BLANK_DELAY};
610    use super::*;
611    use embedded_graphics::prelude::*;
612    use std::format;
613
614    const TEST_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
615        128
616    } else {
617        64
618    };
619    const TEST_PLANES: usize = 8;
620    type TestBuffer = DmaFrameBuffer<16, TEST_COLS, TEST_PLANES>;
621
622    #[test]
623    fn format_sets_current_row_address_for_all_planes() {
624        let fb = TestBuffer::new();
625
626        for slot in 0..16 {
627            let (_, addr) = slot_addresses::<16>(slot);
628            for plane_idx in 0..TEST_PLANES {
629                let plane = &fb.rows[slot].planes[plane_idx];
630                assert_eq!(
631                    plane.address[map_index(0)].addr(),
632                    addr,
633                    "slot {slot} plane {plane_idx} addr byte 0 should be the current row"
634                );
635                assert_eq!(
636                    plane.address[map_index(1)].addr(),
637                    addr,
638                    "slot {slot} plane {plane_idx} addr byte 1 should be the current row"
639                );
640                assert_eq!(
641                    plane.address[map_index(2)].addr(),
642                    addr,
643                    "slot {slot} plane {plane_idx} addr byte 2 should be the current row"
644                );
645                assert_eq!(
646                    plane.address[map_index(3)].0,
647                    OE_BLANK,
648                    "slot {slot} plane {plane_idx} addr byte 3 should be blanked with addr 0"
649                );
650            }
651        }
652    }
653
654    #[test]
655    fn address_bytes_have_two_latch_pulses() {
656        let fb = TestBuffer::new();
657
658        for row in &fb.rows {
659            for plane in &row.planes {
660                let latch_count = plane.address.iter().filter(|a| a.latch()).count();
661                assert_eq!(latch_count, 2, "expected exactly two latch pulses");
662                assert!(plane.address[map_index(0)].latch());
663                assert!(plane.address[map_index(1)].latch());
664            }
665        }
666    }
667
668    #[test]
669    fn planes_match_row_plane_template() {
670        let fb = TestBuffer::new();
671
672        for row_idx in 0..16 {
673            for p in 0..TEST_PLANES {
674                let expected = make_row_plane_template::<TEST_COLS>(p == 1, p == 0);
675                assert_eq!(
676                    fb.rows[row_idx].planes[p].data, expected,
677                    "row {row_idx} plane {p} doesn't match template"
678                );
679            }
680        }
681    }
682
683    #[test]
684    fn first_plane_has_lead_blank_only() {
685        let fb = TestBuffer::new();
686        let oe_active = !cfg!(feature = "invert-oe");
687
688        for row in &fb.rows {
689            let plane = &row.planes[0];
690
691            // No trail blank: plane 0 follows the previous row's last plane
692            // and its (blanked) address bytes; no address change happens at
693            // its start.
694            let first_idx = map_index(0);
695            assert_eq!(
696                plane.data[first_idx].output_enable(),
697                oe_active,
698                "plane 0: first pixel should have OE active (no trail blank)"
699            );
700
701            // Lead blank: plane 0's address bytes change the row address.
702            for i in (TEST_COLS - LEAD_BLANK_DELAY)..TEST_COLS {
703                assert_eq!(
704                    plane.data[map_index(i)].output_enable(),
705                    !oe_active,
706                    "plane 0: lead blank entry {i} should have OE blank"
707                );
708            }
709            let active_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
710            assert_eq!(
711                plane.data[active_idx].output_enable(),
712                oe_active,
713                "plane 0: entry before the lead blank should have OE active"
714            );
715        }
716    }
717
718    #[test]
719    fn second_plane_has_trail_blank_only() {
720        let fb = TestBuffer::new();
721        let oe_active = !cfg!(feature = "invert-oe");
722
723        for row in &fb.rows {
724            let plane = &row.planes[1];
725
726            // Trail blank: plane 1 immediately follows the address change.
727            for i in 0..TRAIL_BLANK_DELAY {
728                assert_eq!(
729                    plane.data[map_index(i)].output_enable(),
730                    !oe_active,
731                    "plane 1: trail blank entry {i} should have OE blank"
732                );
733            }
734            let active_idx = map_index(TRAIL_BLANK_DELAY);
735            assert_eq!(
736                plane.data[active_idx].output_enable(),
737                oe_active,
738                "plane 1: entry after the trail blank should have OE active"
739            );
740
741            // No lead blank on plane 1: its address bytes do not change the
742            // row address.
743            let last_idx = map_index(TEST_COLS - 1);
744            assert_eq!(
745                plane.data[last_idx].output_enable(),
746                oe_active,
747                "plane 1: last pixel should have OE active (no lead blank)"
748            );
749        }
750    }
751
752    #[test]
753    fn middle_planes_have_full_width_window() {
754        let fb = TestBuffer::new();
755        let oe_active = !cfg!(feature = "invert-oe");
756
757        // Middle planes are 2..PLANES-1 (plane 0 has the lead blank, plane 1
758        // the trail blank).
759        for row in &fb.rows {
760            for plane in row.planes.iter().take(TEST_PLANES - 1).skip(2) {
761                let first_idx = map_index(0);
762                assert_eq!(
763                    plane.data[first_idx].output_enable(),
764                    oe_active,
765                    "middle plane: first pixel should have OE active"
766                );
767                let last_idx = map_index(TEST_COLS - 1);
768                assert_eq!(
769                    plane.data[last_idx].output_enable(),
770                    oe_active,
771                    "middle plane: last pixel should have OE active"
772                );
773            }
774        }
775    }
776
777    #[test]
778    fn last_plane_has_full_width_window() {
779        let fb = TestBuffer::new();
780        let oe_active = !cfg!(feature = "invert-oe");
781        let last = TEST_PLANES - 1;
782
783        for row in &fb.rows {
784            let plane = &row.planes[last];
785
786            // No lead blank on the last plane: its address bytes do not
787            // change the row address, and panels that need more setup time
788            // use the lead-blank-* features instead.
789            let last_idx = map_index(TEST_COLS - 1);
790            assert_eq!(
791                plane.data[last_idx].output_enable(),
792                oe_active,
793                "last plane: last pixel should have OE active (no lead blank)"
794            );
795
796            // No trail blank on the last plane.
797            let first_idx = map_index(0);
798            assert_eq!(
799                plane.data[first_idx].output_enable(),
800                oe_active,
801                "last plane: first pixel should have OE active (no trail blank)"
802            );
803        }
804    }
805
806    #[test]
807    fn oe_window_sizes_match_template() {
808        let fb = TestBuffer::new();
809        let oe_active = !cfg!(feature = "invert-oe");
810
811        for row in &fb.rows {
812            for (p, plane) in row.planes.iter().enumerate() {
813                let trail = if p == 1 { TRAIL_BLANK_DELAY } else { 0 };
814                let lead = if p == 0 { LEAD_BLANK_DELAY } else { 0 };
815                let expected_active = TEST_COLS.saturating_sub(trail + lead);
816                let active = plane
817                    .data
818                    .iter()
819                    .filter(|e| e.output_enable() == oe_active)
820                    .count();
821                assert_eq!(active, expected_active, "plane {p} active window size");
822            }
823        }
824    }
825
826    #[test]
827    fn single_plane_has_trail_and_lead_blank() {
828        type OnePlane = DmaFrameBuffer<16, TEST_COLS, 1>;
829        let fb = OnePlane::new();
830
831        for slot in 0..16 {
832            let (_, addr) = slot_addresses::<16>(slot);
833            let plane = &fb.rows[slot].planes[0];
834            assert_eq!(
835                plane.address[map_index(0)].addr(),
836                addr,
837                "single plane slot {slot}: should use the current row address"
838            );
839
840            // The single plane both precedes and follows the address change,
841            // so it gets both the trail blank and the lead blank.
842            let expected = make_row_plane_template::<TEST_COLS>(true, true);
843            assert_eq!(
844                plane.data, expected,
845                "single plane slot {slot}: template mismatch"
846            );
847        }
848    }
849
850    #[test]
851    fn set_pixel_maps_top_half_bits_per_plane() {
852        let mut fb = TestBuffer::new();
853        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
854        fb.set_pixel(Point::new(2, 3), color);
855
856        for plane_idx in 0..TEST_PLANES {
857            let bit = 8 - TEST_PLANES + plane_idx;
858            let entry = fb.rows[map_row_index::<16>(3)].planes[plane_idx].data[map_index(2)];
859            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
860            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
861            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
862        }
863    }
864
865    #[test]
866    fn set_pixel_maps_bottom_half_bits_per_plane() {
867        let mut fb = TestBuffer::new();
868        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
869        fb.set_pixel(Point::new(4, 20), color);
870
871        for plane_idx in 0..TEST_PLANES {
872            let bit = 8 - TEST_PLANES + plane_idx;
873            let entry = fb.rows[map_row_index::<16>(4)].planes[plane_idx].data[map_index(4)];
874            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
875            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
876            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
877        }
878    }
879
880    #[test]
881    #[cfg(feature = "reverse-row-order")]
882    fn reverse_row_order_stores_rows_back_to_front() {
883        let mut fb = TestBuffer::new();
884
885        // Slot 0 is streamed first and renders the last panel row; the final
886        // slot renders panel row 0. The address bytes carry the slot's own
887        // row address.
888        assert_eq!(fb.rows[0].planes[0].address[map_index(0)].addr(), 15);
889        assert_eq!(fb.rows[15].planes[0].address[map_index(0)].addr(), 0);
890
891        // Logical row 0 maps to the last memory slot.
892        fb.set_pixel(Point::new(2, 0), Color::RED);
893        let col2 = map_index(2);
894        assert!(fb.rows[15].planes[TEST_PLANES - 1].data[col2].red1());
895        assert!(!fb.rows[0].planes[TEST_PLANES - 1].data[col2].red1());
896    }
897
898    #[test]
899    fn erase_clears_only_color_bits() {
900        let mut fb = TestBuffer::new();
901        let oe_before =
902            fb.rows[map_row_index::<16>(0)].planes[0].data[map_index(1)].output_enable();
903        let addr_before = fb.rows[map_row_index::<16>(3)].planes[2].address;
904        fb.set_pixel(Point::new(0, 0), Color::WHITE);
905        fb.erase();
906
907        for row in &fb.rows {
908            for plane in &row.planes {
909                for entry in &plane.data {
910                    assert!(!entry.red1());
911                    assert!(!entry.grn1());
912                    assert!(!entry.blu1());
913                    assert!(!entry.red2());
914                    assert!(!entry.grn2());
915                    assert!(!entry.blu2());
916                }
917            }
918        }
919
920        assert_eq!(
921            fb.rows[map_row_index::<16>(0)].planes[0].data[map_index(1)].output_enable(),
922            oe_before
923        );
924        assert_eq!(
925            fb.rows[map_row_index::<16>(3)].planes[2].address,
926            addr_before
927        );
928    }
929
930    #[test]
931    fn draw_target_iter_sets_pixels() {
932        let mut fb = TestBuffer::new();
933        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
934        let result = fb.draw_iter(pixels);
935        assert!(result.is_ok());
936
937        for plane_idx in 0..TEST_PLANES {
938            let bit = 8 - TEST_PLANES + plane_idx;
939            let entry = fb.rows[map_row_index::<16>(1)].planes[plane_idx].data[map_index(1)];
940            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
941            assert!(!entry.grn1());
942            assert!(!entry.blu1());
943        }
944    }
945
946    #[test]
947    fn set_pixel_ignores_out_of_bounds_and_negative() {
948        let mut fb = TestBuffer::new();
949        let before = fb.rows;
950        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
951        fb.set_pixel(Point::new(0, -1), Color::WHITE);
952        fb.set_pixel(Point::new(TEST_COLS as i32, 0), Color::WHITE);
953        fb.set_pixel(Point::new(0, 32), Color::WHITE);
954        assert_eq!(fb.rows, before);
955    }
956
957    #[test]
958    #[cfg(feature = "skip-black-pixels")]
959    fn test_skip_black_pixels_enabled() {
960        let mut fb = TestBuffer::new();
961
962        // Set a red pixel first
963        fb.set_pixel_internal(10, 5, Color::RED);
964
965        // Verify it's red in the first plane
966        let mapped_col_10 = map_index(10);
967        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
968        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
969        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
970
971        // Now set it to black - with skip-black-pixels enabled, this should be ignored
972        fb.set_pixel_internal(10, 5, Color::BLACK);
973
974        // The pixel should still be red (black write was skipped)
975        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
976        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
977        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
978    }
979
980    #[test]
981    #[cfg(not(feature = "skip-black-pixels"))]
982    fn test_skip_black_pixels_disabled() {
983        let mut fb = TestBuffer::new();
984
985        // Set a red pixel first
986        fb.set_pixel_internal(10, 5, Color::RED);
987
988        // Verify it's red in the first plane
989        let mapped_col_10 = map_index(10);
990        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
991        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
992        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
993
994        // Now set it to black - with skip-black-pixels disabled, this should overwrite
995        fb.set_pixel_internal(10, 5, Color::BLACK);
996
997        // The pixel should now be black (all bits false)
998        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
999        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
1000        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
1001    }
1002
1003    #[test]
1004    #[should_panic(expected = "NROWS must be within 1..=32")]
1005    fn new_panics_for_too_many_rows() {
1006        let _ = DmaFrameBuffer::<33, TEST_COLS, 8>::new();
1007    }
1008
1009    #[test]
1010    #[should_panic(expected = "PLANES must be within 1..=8")]
1011    fn new_panics_for_too_many_planes() {
1012        let _ = DmaFrameBuffer::<16, TEST_COLS, 9>::new();
1013    }
1014
1015    #[test]
1016    fn pixel_row_ptr_len_returns_correct_pointers() {
1017        let fb = TestBuffer::new();
1018        let (ptr, len) = fb.pixel_row_ptr_len(0, 0);
1019        assert_eq!(len, core::mem::size_of::<PlaneRow<TEST_COLS>>());
1020        assert_eq!(ptr, (&raw const fb.rows[0].planes[0]).cast::<u8>());
1021    }
1022
1023    #[test]
1024    fn gap_ptr_len_returns_correct_pointers() {
1025        let fb = TestBuffer::new();
1026        let (ptr, len) = fb.gap_ptr_len(0);
1027        assert_eq!(len, GAP_BYTES);
1028        assert_eq!(ptr, (&raw const fb.rows[0].gap).cast::<u8>());
1029    }
1030
1031    #[test]
1032    #[should_panic(expected = "out of range")]
1033    fn pixel_row_ptr_len_panics_for_invalid_row() {
1034        let fb = TestBuffer::new();
1035        let _ = fb.pixel_row_ptr_len(16, 0);
1036    }
1037
1038    #[test]
1039    #[should_panic(expected = "out of range")]
1040    fn pixel_row_ptr_len_panics_for_invalid_plane() {
1041        let fb = TestBuffer::new();
1042        let _ = fb.pixel_row_ptr_len(0, TEST_PLANES);
1043    }
1044
1045    #[test]
1046    #[should_panic(expected = "out of range")]
1047    fn gap_ptr_len_panics_for_invalid_row() {
1048        let fb = TestBuffer::new();
1049        let _ = fb.gap_ptr_len(16);
1050    }
1051
1052    #[test]
1053    fn origin_dimensions_match_panel_geometry() {
1054        let fb = TestBuffer::new();
1055        assert_eq!(fb.size(), Size::new(TEST_COLS as u32, 32));
1056    }
1057
1058    #[test]
1059    fn default_matches_new() {
1060        let fb_default = TestBuffer::default();
1061        let fb_new = TestBuffer::new();
1062        assert_eq!(fb_default.rows, fb_new.rows);
1063    }
1064
1065    #[test]
1066    fn debug_impl_includes_shape_information() {
1067        let fb = TestBuffer::new();
1068        let s = format!("{fb:?}");
1069        assert!(s.contains("DmaFrameBuffer"));
1070        assert!(s.contains("row_count"));
1071        assert!(s.contains("planes"));
1072    }
1073
1074    static STATIC_FB: TestBuffer = TestBuffer::new();
1075
1076    #[test]
1077    fn static_construction_is_formatted() {
1078        let runtime_fb = TestBuffer::new();
1079
1080        for (ri, row) in STATIC_FB.rows.iter().enumerate() {
1081            assert_eq!(
1082                row.planes, runtime_fb.rows[ri].planes,
1083                "static vs runtime plane mismatch at row {ri}"
1084            );
1085            assert_eq!(
1086                row.gap, runtime_fb.rows[ri].gap,
1087                "static vs runtime gap mismatch at row {ri}"
1088            );
1089        }
1090    }
1091
1092    #[test]
1093    fn format_reinitializes_at_runtime() {
1094        let mut fb = TestBuffer::new();
1095        fb.erase();
1096        fb.format();
1097
1098        for (ri, row) in fb.rows.iter().enumerate() {
1099            assert_eq!(
1100                row.planes, STATIC_FB.rows[ri].planes,
1101                "re-formatted vs static plane mismatch at row {ri}"
1102            );
1103            assert_eq!(
1104                row.gap, STATIC_FB.rows[ri].gap,
1105                "re-formatted vs static gap mismatch at row {ri}"
1106            );
1107        }
1108    }
1109
1110    #[test]
1111    fn framebuffer_operations_trait_delegates_correctly() {
1112        let mut fb = TestBuffer::new();
1113        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
1114
1115        // Plane 7 (MSB) carries bit 7; GREEN = 0xFF so bit 7 is set
1116        assert!(fb.rows[map_row_index::<16>(5)].planes[TEST_PLANES - 1].data[map_index(3)].grn1());
1117
1118        FrameBufferOperations::erase(&mut fb);
1119        for row in &fb.rows {
1120            for plane in &row.planes {
1121                for entry in &plane.data {
1122                    assert!(!entry.red1());
1123                    assert!(!entry.grn1());
1124                    assert!(!entry.blu1());
1125                    assert!(!entry.red2());
1126                    assert!(!entry.grn2());
1127                    assert!(!entry.blu2());
1128                }
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn row_data_size_matches_expected_layout() {
1135        let expected_planes = core::mem::size_of::<PlaneRow<TEST_COLS>>() * TEST_PLANES;
1136        assert_eq!(
1137            core::mem::size_of::<RowData<TEST_COLS, TEST_PLANES>>(),
1138            expected_planes + GAP_BYTES
1139        );
1140    }
1141
1142    #[test]
1143    fn bcm_segment_count_equals_rows_times_planes_plus_gap() {
1144        let fb = TestBuffer::new();
1145        let gap = if HAS_GAP { 1 } else { 0 };
1146        assert_eq!(fb.bcm_segment_count(), 16 * (TEST_PLANES + gap));
1147    }
1148
1149    #[test]
1150    fn bcm_segments_per_group_equals_planes_plus_gap() {
1151        let fb = TestBuffer::new();
1152        let gap = if HAS_GAP { 1 } else { 0 };
1153        assert_eq!(fb.bcm_segments_per_group(), TEST_PLANES + gap);
1154        assert_eq!(
1155            fb.bcm_segment_count() % fb.bcm_segments_per_group(),
1156            0,
1157            "segment count must be divisible by segments_per_group"
1158        );
1159    }
1160
1161    #[test]
1162    fn bcm_segments_interleave_pixel_and_gap() {
1163        let fb = TestBuffer::new();
1164        let gap = if HAS_GAP { 1 } else { 0 };
1165        let segments_per_row = TEST_PLANES + gap;
1166
1167        for row in 0..16usize {
1168            // Plane 0 (LSB) comes first; without a gap it covers all planes.
1169            let seg = fb.bcm_segment(row * segments_per_row);
1170            let (ptr, plane_len) = fb.pixel_row_ptr_len(row, 0);
1171            let covered0 = if HAS_GAP { 1 } else { TEST_PLANES };
1172            assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane 0");
1173            assert_eq!(
1174                seg.len,
1175                plane_len * covered0,
1176                "wrong len at row {row} plane 0"
1177            );
1178            assert_eq!(seg.reps, 1, "wrong reps at row {row} plane 0");
1179
1180            // The inter-row gap (if any) is streamed between plane 0 and 1.
1181            if HAS_GAP {
1182                let seg = fb.bcm_segment(row * segments_per_row + 1);
1183                let (ptr, len) = fb.gap_ptr_len(row);
1184                assert_eq!(seg.ptr, ptr, "wrong gap ptr at row {row}");
1185                assert_eq!(seg.len, len, "wrong gap len at row {row}");
1186                assert_eq!(seg.reps, 1, "gap reps must be 1 at row {row}");
1187            }
1188
1189            // Planes 1.. follow the gap; each streams the remaining suffix.
1190            for plane in 1..TEST_PLANES {
1191                let idx = row * segments_per_row + plane + gap;
1192                let seg = fb.bcm_segment(idx);
1193                let (ptr, plane_len) = fb.pixel_row_ptr_len(row, plane);
1194                let expected_reps = if HAS_GAP && plane == 1 {
1195                    2
1196                } else {
1197                    1 << (plane - 1)
1198                };
1199                assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane {plane}");
1200                assert_eq!(
1201                    seg.len,
1202                    plane_len * (TEST_PLANES - plane),
1203                    "wrong len at row {row} plane {plane}"
1204                );
1205                assert_eq!(
1206                    seg.reps, expected_reps,
1207                    "wrong reps at row {row} plane {plane}"
1208                );
1209            }
1210        }
1211    }
1212
1213    #[test]
1214    fn bcm_segment_total_reps_per_row() {
1215        let fb = TestBuffer::new();
1216        let gap = if HAS_GAP { 1 } else { 0 };
1217        let segments_per_row = TEST_PLANES + gap;
1218
1219        for row in 0..16usize {
1220            // Suffix coalescing halves the per-row transfer count to
1221            // 2^(PLANES-1); with an inter-row gap, plane 0 stands alone and
1222            // plane 1's extra rep adds one more.
1223            let mut pixel_reps = 0;
1224            for plane in 0..TEST_PLANES {
1225                // Plane 0 is at offset 0, planes 1.. at offset 1 + gap.
1226                let offset = if plane == 0 { 0 } else { plane + gap };
1227                pixel_reps += fb.bcm_segment(row * segments_per_row + offset).reps;
1228            }
1229            let expected = if HAS_GAP {
1230                (1 << (TEST_PLANES - 1)) + 1
1231            } else {
1232                1 << (TEST_PLANES - 1)
1233            };
1234            assert_eq!(pixel_reps, expected, "total pixel reps wrong for row {row}");
1235        }
1236    }
1237
1238    #[test]
1239    fn bcm_segment_plane_coverage_matches_bcm_weights() {
1240        let fb = TestBuffer::new();
1241        let gap = if HAS_GAP { 1 } else { 0 };
1242        let segments_per_row = TEST_PLANES + gap;
1243        let plane_bytes = core::mem::size_of::<PlaneRow<TEST_COLS>>();
1244
1245        for row in 0..16usize {
1246            for plane in 0..TEST_PLANES {
1247                // Sum the reps of every pixel segment that spans this plane:
1248                // the segment for plane `first` covers `len / plane_bytes`
1249                // consecutive planes starting at `first`.
1250                let mut coverage = 0;
1251                for first in 0..=plane {
1252                    // Plane 0 is at offset 0, planes 1.. at offset 1 + gap.
1253                    let offset = if first == 0 { 0 } else { first + gap };
1254                    let seg = fb.bcm_segment(row * segments_per_row + offset);
1255                    if first + seg.len / plane_bytes > plane {
1256                        coverage += seg.reps;
1257                    }
1258                }
1259                assert_eq!(
1260                    coverage,
1261                    1 << plane,
1262                    "plane {plane} coverage wrong for row {row}"
1263                );
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    #[should_panic(expected = "out of range")]
1270    fn bcm_segment_panics_for_invalid_index() {
1271        let fb = TestBuffer::new();
1272        let gap = if HAS_GAP { 1 } else { 0 };
1273        let segments_per_row = TEST_PLANES + gap;
1274        let _ = fb.bcm_segment(16 * segments_per_row);
1275    }
1276
1277    #[test]
1278    fn bcm_sequence_matches_runtime_segments() {
1279        use crate::FrameBuffer;
1280        let fb = TestBuffer::new();
1281        assert_eq!(
1282            TestBuffer::BCM_SEGMENT_COUNT,
1283            TestBuffer::BCM_SEQUENCE_LEN * TestBuffer::BCM_SEQUENCE_COUNT
1284        );
1285        assert_eq!(fb.bcm_segment_count(), TestBuffer::BCM_SEGMENT_COUNT);
1286        assert_eq!(
1287            fb.bcm_segments_per_group(),
1288            TestBuffer::BCM_SEGMENTS_PER_GROUP
1289        );
1290        assert_eq!(
1291            TestBuffer::BCM_SEQUENCE_LEN % TestBuffer::BCM_SEGMENTS_PER_GROUP,
1292            0
1293        );
1294        for i in 0..TestBuffer::BCM_SEGMENT_COUNT {
1295            let entry = TestBuffer::BCM_SEQUENCE[i % TestBuffer::BCM_SEQUENCE_LEN];
1296            let seg = fb.bcm_segment(i);
1297            assert_eq!((seg.len, seg.reps), (entry.len, entry.reps), "segment {i}");
1298            assert!(!seg.ptr.is_null(), "segment {i} has null pointer");
1299        }
1300        for entry in &TestBuffer::BCM_SEQUENCE[TestBuffer::BCM_SEQUENCE_LEN..] {
1301            assert_eq!(*entry, crate::BcmLenReps::ZERO, "padding must be zero");
1302        }
1303    }
1304
1305    #[test]
1306    fn bcm_segment_data_contains_written_pixels() {
1307        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1308            128
1309        } else {
1310            64
1311        };
1312        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1313        let mut fb = TrinityBuf::new();
1314
1315        fb.set_pixel(Point::new(10, 0), Color::WHITE);
1316
1317        let col_idx = map_index(10);
1318        let gap = usize::from(HAS_GAP);
1319        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1320        let row0_base = map_row_index::<32>(0) * (5 + gap);
1321
1322        for plane_idx in 0..5usize {
1323            // Row 0: plane 0 is the slot's first segment, planes 1+ follow the
1324            // gap segment.
1325            let seg = fb.bcm_segment(if plane_idx == 0 {
1326                row0_base
1327            } else {
1328                row0_base + plane_idx + gap
1329            });
1330
1331            let data = unsafe { core::slice::from_raw_parts(seg.ptr, TRINITY_COLS) };
1332            let raw = data[col_idx];
1333            let entry = Entry(raw);
1334
1335            let bit = 8 - 5 + plane_idx; // LSB-first
1336            let expect_r = (0xFF >> bit) & 1 != 0;
1337            let expect_g = (0xFF >> bit) & 1 != 0;
1338            let expect_b = (0xFF >> bit) & 1 != 0;
1339            assert_eq!(
1340                entry.red1(),
1341                expect_r,
1342                "plane {plane_idx} (bit {bit}) R1 mismatch: raw={raw:#04x}"
1343            );
1344            assert_eq!(
1345                entry.grn1(),
1346                expect_g,
1347                "plane {plane_idx} (bit {bit}) G1 mismatch: raw={raw:#04x}"
1348            );
1349            assert_eq!(
1350                entry.blu1(),
1351                expect_b,
1352                "plane {plane_idx} (bit {bit}) B1 mismatch: raw={raw:#04x}"
1353            );
1354        }
1355    }
1356
1357    #[test]
1358    fn bcm_segment_data_contains_gradient_pixels() {
1359        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1360            128
1361        } else {
1362            64
1363        };
1364        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1365        let mut fb = TrinityBuf::new();
1366
1367        let step: u8 = (256 / TRINITY_COLS) as u8;
1368        for x in 0..TRINITY_COLS {
1369            let brightness = (x as u8).wrapping_mul(step);
1370            fb.set_pixel(Point::new(x as i32, 0), Color::new(brightness, 0, 0));
1371        }
1372
1373        let mut any_nonzero = false;
1374        let gap = usize::from(HAS_GAP);
1375        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1376        let row0_base = map_row_index::<32>(0) * (5 + gap);
1377        for plane_idx in 0..5usize {
1378            // Row 0: plane 0 is the slot's first segment, planes 1+ follow the
1379            // gap segment.
1380            let seg = fb.bcm_segment(if plane_idx == 0 {
1381                row0_base
1382            } else {
1383                row0_base + plane_idx + gap
1384            });
1385            let data = unsafe { core::slice::from_raw_parts(seg.ptr, TRINITY_COLS) };
1386
1387            for x in 0..TRINITY_COLS {
1388                let col_idx = map_index(x);
1389                let entry = Entry(data[col_idx]);
1390                let brightness = (x as u8).wrapping_mul(step);
1391                let bit = 8 - 5 + plane_idx; // LSB-first
1392                let expect_r = (brightness >> bit) & 1 != 0;
1393                assert_eq!(
1394                    entry.red1(),
1395                    expect_r,
1396                    "x={x} plane={plane_idx} bit={bit} brightness={brightness} \
1397                     raw={:#04x}",
1398                    data[col_idx]
1399                );
1400                if expect_r {
1401                    any_nonzero = true;
1402                }
1403            }
1404        }
1405        assert!(
1406            any_nonzero,
1407            "gradient should produce at least some non-zero color bits"
1408        );
1409    }
1410}