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    #[must_use]
329    pub const fn new() -> Self {
330        assert!(NROWS >= 1 && NROWS <= 32, "NROWS must be within 1..=32");
331        assert!(PLANES >= 1 && PLANES <= 8, "PLANES must be within 1..=8");
332        #[cfg(feature = "esp32-ordering")]
333        assert!(
334            COLS % 4 == 0,
335            "esp32-ordering feature requires COLS to be divisible by 4"
336        );
337        let mut instance = Self {
338            rows: [RowData::new(); NROWS],
339        };
340        instance.format();
341        instance
342    }
343
344    /// Number of bit-planes.
345    #[must_use]
346    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
347    pub const fn bcm_chunk_count() -> usize {
348        PLANES
349    }
350
351    /// Byte size of one plane's payload for a single row (pixel data plus
352    /// its four address bytes).
353    #[must_use]
354    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
355    pub const fn bcm_chunk_bytes() -> usize {
356        core::mem::size_of::<PlaneRow<COLS>>()
357    }
358
359    /// Number of multiplexed scan rows.
360    #[must_use]
361    pub const fn bcm_row_count() -> usize {
362        NROWS
363    }
364
365    /// Byte size of the per-row inter-row blanking gap.
366    #[must_use]
367    pub const fn bcm_row_bytes() -> usize {
368        GAP_BYTES
369    }
370
371    /// Formats the framebuffer with row addresses and control bits.
372    ///
373    /// Every plane carries the current row address; blanking is applied
374    /// around the plane0→plane1 address change. See the module-level
375    /// documentation for the exact distribution.
376    #[inline]
377    pub const fn format(&mut self) {
378        let mut slot = 0;
379        while slot < NROWS {
380            let (_, addr) = slot_addresses::<NROWS>(slot);
381            self.rows[slot].format(addr);
382            slot += 1;
383        }
384    }
385
386    /// Erase pixel colours while preserving row control data.
387    #[inline]
388    pub fn erase(&mut self) {
389        const MASK: u8 = !0b0011_1111;
390        for row in &mut self.rows {
391            for plane in &mut row.planes {
392                for entry in &mut plane.data {
393                    entry.0 &= MASK;
394                }
395            }
396        }
397    }
398
399    /// Set a pixel in the framebuffer.
400    #[inline]
401    pub fn set_pixel(&mut self, p: Point, color: Color) {
402        if p.x < 0 || p.y < 0 {
403            return;
404        }
405        self.set_pixel_internal(p.x as usize, p.y as usize, color);
406    }
407
408    #[inline]
409    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
410        if x >= COLS || y >= NROWS * 2 {
411            return;
412        }
413
414        // Early exit for black pixels - common in UI backgrounds
415        // Only enabled when skip-black-pixels feature is active
416        #[cfg(feature = "skip-black-pixels")]
417        if color == Color::BLACK {
418            return;
419        }
420
421        let row_idx = map_row_index::<NROWS>(if y < NROWS { y } else { y - NROWS });
422        let is_top = y < NROWS;
423        let red = color.r();
424        let green = color.g();
425        let blue = color.b();
426
427        for plane_idx in 0..PLANES {
428            let bit = (8 - PLANES + plane_idx) as u32;
429            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
430                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
431                | u8::from(((red >> bit) & 1) != 0);
432            let col_idx = map_index(x);
433            let entry = &mut self.rows[row_idx].planes[plane_idx].data[col_idx];
434            if is_top {
435                entry.set_color0_bits(bits);
436            } else {
437                entry.set_color1_bits(bits);
438            }
439        }
440    }
441
442    /// Returns a pointer and byte length for a plane's payload (pixel data
443    /// plus its four address bytes) within a single row.
444    ///
445    /// # Panics
446    /// Panics if `row_idx >= NROWS` or `plane_idx >= PLANES`.
447    #[must_use]
448    pub fn pixel_row_ptr_len(&self, row_idx: usize, plane_idx: usize) -> (*const u8, usize) {
449        assert!(
450            row_idx < NROWS,
451            "row_idx {row_idx} out of range for {NROWS} rows"
452        );
453        assert!(
454            plane_idx < PLANES,
455            "plane_idx {plane_idx} out of range for {PLANES} planes"
456        );
457        let ptr = (&raw const self.rows[row_idx].planes[plane_idx]).cast::<u8>();
458        let len = core::mem::size_of::<PlaneRow<COLS>>();
459        (ptr, len)
460    }
461
462    /// Returns a pointer and byte length for a row's inter-row blanking gap.
463    ///
464    /// The gap is streamed between plane 0 (whose address bytes latch the
465    /// data and change the row address) and plane 1. Returns length 0 when
466    /// no `inter-row-blank-*` feature is enabled.
467    ///
468    /// # Panics
469    /// Panics if `row_idx >= NROWS`.
470    #[must_use]
471    pub fn gap_ptr_len(&self, row_idx: usize) -> (*const u8, usize) {
472        assert!(
473            row_idx < NROWS,
474            "row_idx {row_idx} out of range for {NROWS} rows"
475        );
476        let ptr = (&raw const self.rows[row_idx].gap).cast::<u8>();
477        (ptr, GAP_BYTES)
478    }
479}
480
481impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
482    for DmaFrameBuffer<NROWS, COLS, PLANES>
483{
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
490    for DmaFrameBuffer<NROWS, COLS, PLANES>
491{
492    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
493        f.debug_struct("DmaFrameBuffer")
494            .field("size", &core::mem::size_of_val(self))
495            .field("row_count", &NROWS)
496            .field("planes", &PLANES)
497            .field("cols", &COLS)
498            .finish()
499    }
500}
501
502#[cfg(feature = "defmt")]
503impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
504    for DmaFrameBuffer<NROWS, COLS, PLANES>
505{
506    fn format(&self, f: defmt::Formatter) {
507        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
508        defmt::write!(f, " size: {}", core::mem::size_of_val(self));
509    }
510}
511
512impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
513    for DmaFrameBuffer<NROWS, COLS, PLANES>
514{
515    type Word = u8;
516
517    const BCM_SEQUENCE: [BcmLenReps; BCM_SEQUENCE_CAPACITY] = bcm_sequence::<COLS, PLANES>();
518
519    const BCM_SEQUENCE_LEN: usize = PLANES + HAS_GAP as usize;
520
521    const BCM_SEQUENCE_COUNT: usize = NROWS;
522
523    const BCM_SEGMENTS_PER_GROUP: usize = PLANES + HAS_GAP as usize;
524
525    fn bcm_segment_ptr(&self, index: usize) -> *const u8 {
526        let gap = usize::from(HAS_GAP);
527        let segments_per_row = PLANES + gap;
528        assert!(
529            index < NROWS * segments_per_row,
530            "segment index {index} out of range"
531        );
532        let row_idx = index / segments_per_row;
533        let within_row = index % segments_per_row;
534        if within_row == 0 {
535            let (ptr, _) = self.pixel_row_ptr_len(row_idx, 0);
536            ptr
537        } else if HAS_GAP && within_row == 1 {
538            let (ptr, _) = self.gap_ptr_len(row_idx);
539            ptr
540        } else {
541            let plane_idx = within_row - gap;
542            let (ptr, _) = self.pixel_row_ptr_len(row_idx, plane_idx);
543            ptr
544        }
545    }
546}
547
548impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
549    for DmaFrameBuffer<NROWS, COLS, PLANES>
550{
551    #[inline]
552    fn erase(&mut self) {
553        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
554    }
555
556    #[inline]
557    fn set_pixel(&mut self, p: Point, color: Color) {
558        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
559    }
560}
561
562impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
563    for DmaFrameBuffer<NROWS, COLS, PLANES>
564{
565}
566
567impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
568    for DmaFrameBuffer<NROWS, COLS, PLANES>
569{
570    fn size(&self) -> Size {
571        Size::new(COLS as u32, (NROWS * 2) as u32)
572    }
573}
574
575impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
576    for DmaFrameBuffer<NROWS, COLS, PLANES>
577{
578    type Color = Color;
579    type Error = Infallible;
580
581    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
582    where
583        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
584    {
585        for pixel in pixels {
586            if pixel.0.x < 0 || pixel.0.y < 0 {
587                continue;
588            }
589            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
590        }
591        Ok(())
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    extern crate std;
598
599    use super::super::{LEAD_BLANK_DELAY, TRAIL_BLANK_DELAY};
600    use super::*;
601    use embedded_graphics::prelude::*;
602    use std::format;
603
604    const TEST_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
605        128
606    } else {
607        64
608    };
609    const TEST_PLANES: usize = 8;
610    type TestBuffer = DmaFrameBuffer<16, TEST_COLS, TEST_PLANES>;
611
612    #[test]
613    fn format_sets_current_row_address_for_all_planes() {
614        let fb = TestBuffer::new();
615
616        for slot in 0..16 {
617            let (_, addr) = slot_addresses::<16>(slot);
618            for plane_idx in 0..TEST_PLANES {
619                let plane = &fb.rows[slot].planes[plane_idx];
620                assert_eq!(
621                    plane.address[map_index(0)].addr(),
622                    addr,
623                    "slot {slot} plane {plane_idx} addr byte 0 should be the current row"
624                );
625                assert_eq!(
626                    plane.address[map_index(1)].addr(),
627                    addr,
628                    "slot {slot} plane {plane_idx} addr byte 1 should be the current row"
629                );
630                assert_eq!(
631                    plane.address[map_index(2)].addr(),
632                    addr,
633                    "slot {slot} plane {plane_idx} addr byte 2 should be the current row"
634                );
635                assert_eq!(
636                    plane.address[map_index(3)].0,
637                    OE_BLANK,
638                    "slot {slot} plane {plane_idx} addr byte 3 should be blanked with addr 0"
639                );
640            }
641        }
642    }
643
644    #[test]
645    fn address_bytes_have_two_latch_pulses() {
646        let fb = TestBuffer::new();
647
648        for row in &fb.rows {
649            for plane in &row.planes {
650                let latch_count = plane.address.iter().filter(|a| a.latch()).count();
651                assert_eq!(latch_count, 2, "expected exactly two latch pulses");
652                assert!(plane.address[map_index(0)].latch());
653                assert!(plane.address[map_index(1)].latch());
654            }
655        }
656    }
657
658    #[test]
659    fn planes_match_row_plane_template() {
660        let fb = TestBuffer::new();
661
662        for row_idx in 0..16 {
663            for p in 0..TEST_PLANES {
664                let expected = make_row_plane_template::<TEST_COLS>(p == 1, p == 0);
665                assert_eq!(
666                    fb.rows[row_idx].planes[p].data, expected,
667                    "row {row_idx} plane {p} doesn't match template"
668                );
669            }
670        }
671    }
672
673    #[test]
674    fn first_plane_has_lead_blank_only() {
675        let fb = TestBuffer::new();
676        let oe_active = !cfg!(feature = "invert-oe");
677
678        for row in &fb.rows {
679            let plane = &row.planes[0];
680
681            // No trail blank: plane 0 follows the previous row's last plane
682            // and its (blanked) address bytes; no address change happens at
683            // its start.
684            let first_idx = map_index(0);
685            assert_eq!(
686                plane.data[first_idx].output_enable(),
687                oe_active,
688                "plane 0: first pixel should have OE active (no trail blank)"
689            );
690
691            // Lead blank: plane 0's address bytes change the row address.
692            for i in (TEST_COLS - LEAD_BLANK_DELAY)..TEST_COLS {
693                assert_eq!(
694                    plane.data[map_index(i)].output_enable(),
695                    !oe_active,
696                    "plane 0: lead blank entry {i} should have OE blank"
697                );
698            }
699            let active_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
700            assert_eq!(
701                plane.data[active_idx].output_enable(),
702                oe_active,
703                "plane 0: entry before the lead blank should have OE active"
704            );
705        }
706    }
707
708    #[test]
709    fn second_plane_has_trail_blank_only() {
710        let fb = TestBuffer::new();
711        let oe_active = !cfg!(feature = "invert-oe");
712
713        for row in &fb.rows {
714            let plane = &row.planes[1];
715
716            // Trail blank: plane 1 immediately follows the address change.
717            for i in 0..TRAIL_BLANK_DELAY {
718                assert_eq!(
719                    plane.data[map_index(i)].output_enable(),
720                    !oe_active,
721                    "plane 1: trail blank entry {i} should have OE blank"
722                );
723            }
724            let active_idx = map_index(TRAIL_BLANK_DELAY);
725            assert_eq!(
726                plane.data[active_idx].output_enable(),
727                oe_active,
728                "plane 1: entry after the trail blank should have OE active"
729            );
730
731            // No lead blank on plane 1: its address bytes do not change the
732            // row address.
733            let last_idx = map_index(TEST_COLS - 1);
734            assert_eq!(
735                plane.data[last_idx].output_enable(),
736                oe_active,
737                "plane 1: last pixel should have OE active (no lead blank)"
738            );
739        }
740    }
741
742    #[test]
743    fn middle_planes_have_full_width_window() {
744        let fb = TestBuffer::new();
745        let oe_active = !cfg!(feature = "invert-oe");
746
747        // Middle planes are 2..PLANES-1 (plane 0 has the lead blank, plane 1
748        // the trail blank).
749        for row in &fb.rows {
750            for plane in row.planes.iter().take(TEST_PLANES - 1).skip(2) {
751                let first_idx = map_index(0);
752                assert_eq!(
753                    plane.data[first_idx].output_enable(),
754                    oe_active,
755                    "middle plane: first pixel should have OE active"
756                );
757                let last_idx = map_index(TEST_COLS - 1);
758                assert_eq!(
759                    plane.data[last_idx].output_enable(),
760                    oe_active,
761                    "middle plane: last pixel should have OE active"
762                );
763            }
764        }
765    }
766
767    #[test]
768    fn last_plane_has_full_width_window() {
769        let fb = TestBuffer::new();
770        let oe_active = !cfg!(feature = "invert-oe");
771        let last = TEST_PLANES - 1;
772
773        for row in &fb.rows {
774            let plane = &row.planes[last];
775
776            // No lead blank on the last plane: its address bytes do not
777            // change the row address, and panels that need more setup time
778            // use the lead-blank-* features instead.
779            let last_idx = map_index(TEST_COLS - 1);
780            assert_eq!(
781                plane.data[last_idx].output_enable(),
782                oe_active,
783                "last plane: last pixel should have OE active (no lead blank)"
784            );
785
786            // No trail blank on the last plane.
787            let first_idx = map_index(0);
788            assert_eq!(
789                plane.data[first_idx].output_enable(),
790                oe_active,
791                "last plane: first pixel should have OE active (no trail blank)"
792            );
793        }
794    }
795
796    #[test]
797    fn oe_window_sizes_match_template() {
798        let fb = TestBuffer::new();
799        let oe_active = !cfg!(feature = "invert-oe");
800
801        for row in &fb.rows {
802            for (p, plane) in row.planes.iter().enumerate() {
803                let trail = if p == 1 { TRAIL_BLANK_DELAY } else { 0 };
804                let lead = if p == 0 { LEAD_BLANK_DELAY } else { 0 };
805                let expected_active = TEST_COLS.saturating_sub(trail + lead);
806                let active = plane
807                    .data
808                    .iter()
809                    .filter(|e| e.output_enable() == oe_active)
810                    .count();
811                assert_eq!(active, expected_active, "plane {p} active window size");
812            }
813        }
814    }
815
816    #[test]
817    fn single_plane_has_trail_and_lead_blank() {
818        type OnePlane = DmaFrameBuffer<16, TEST_COLS, 1>;
819        let fb = OnePlane::new();
820
821        for slot in 0..16 {
822            let (_, addr) = slot_addresses::<16>(slot);
823            let plane = &fb.rows[slot].planes[0];
824            assert_eq!(
825                plane.address[map_index(0)].addr(),
826                addr,
827                "single plane slot {slot}: should use the current row address"
828            );
829
830            // The single plane both precedes and follows the address change,
831            // so it gets both the trail blank and the lead blank.
832            let expected = make_row_plane_template::<TEST_COLS>(true, true);
833            assert_eq!(
834                plane.data, expected,
835                "single plane slot {slot}: template mismatch"
836            );
837        }
838    }
839
840    #[test]
841    fn set_pixel_maps_top_half_bits_per_plane() {
842        let mut fb = TestBuffer::new();
843        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
844        fb.set_pixel(Point::new(2, 3), color);
845
846        for plane_idx in 0..TEST_PLANES {
847            let bit = 8 - TEST_PLANES + plane_idx;
848            let entry = fb.rows[map_row_index::<16>(3)].planes[plane_idx].data[map_index(2)];
849            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
850            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
851            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
852        }
853    }
854
855    #[test]
856    fn set_pixel_maps_bottom_half_bits_per_plane() {
857        let mut fb = TestBuffer::new();
858        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
859        fb.set_pixel(Point::new(4, 20), color);
860
861        for plane_idx in 0..TEST_PLANES {
862            let bit = 8 - TEST_PLANES + plane_idx;
863            let entry = fb.rows[map_row_index::<16>(4)].planes[plane_idx].data[map_index(4)];
864            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
865            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
866            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
867        }
868    }
869
870    #[test]
871    #[cfg(feature = "reverse-row-order")]
872    fn reverse_row_order_stores_rows_back_to_front() {
873        let mut fb = TestBuffer::new();
874
875        // Slot 0 is streamed first and renders the last panel row; the final
876        // slot renders panel row 0. The address bytes carry the slot's own
877        // row address.
878        assert_eq!(fb.rows[0].planes[0].address[map_index(0)].addr(), 15);
879        assert_eq!(fb.rows[15].planes[0].address[map_index(0)].addr(), 0);
880
881        // Logical row 0 maps to the last memory slot.
882        fb.set_pixel(Point::new(2, 0), Color::RED);
883        let col2 = map_index(2);
884        assert!(fb.rows[15].planes[TEST_PLANES - 1].data[col2].red1());
885        assert!(!fb.rows[0].planes[TEST_PLANES - 1].data[col2].red1());
886    }
887
888    #[test]
889    fn erase_clears_only_color_bits() {
890        let mut fb = TestBuffer::new();
891        let oe_before =
892            fb.rows[map_row_index::<16>(0)].planes[0].data[map_index(1)].output_enable();
893        let addr_before = fb.rows[map_row_index::<16>(3)].planes[2].address;
894        fb.set_pixel(Point::new(0, 0), Color::WHITE);
895        fb.erase();
896
897        for row in &fb.rows {
898            for plane in &row.planes {
899                for entry in &plane.data {
900                    assert!(!entry.red1());
901                    assert!(!entry.grn1());
902                    assert!(!entry.blu1());
903                    assert!(!entry.red2());
904                    assert!(!entry.grn2());
905                    assert!(!entry.blu2());
906                }
907            }
908        }
909
910        assert_eq!(
911            fb.rows[map_row_index::<16>(0)].planes[0].data[map_index(1)].output_enable(),
912            oe_before
913        );
914        assert_eq!(
915            fb.rows[map_row_index::<16>(3)].planes[2].address,
916            addr_before
917        );
918    }
919
920    #[test]
921    fn draw_target_iter_sets_pixels() {
922        let mut fb = TestBuffer::new();
923        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
924        let result = fb.draw_iter(pixels);
925        assert!(result.is_ok());
926
927        for plane_idx in 0..TEST_PLANES {
928            let bit = 8 - TEST_PLANES + plane_idx;
929            let entry = fb.rows[map_row_index::<16>(1)].planes[plane_idx].data[map_index(1)];
930            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
931            assert!(!entry.grn1());
932            assert!(!entry.blu1());
933        }
934    }
935
936    #[test]
937    fn set_pixel_ignores_out_of_bounds_and_negative() {
938        let mut fb = TestBuffer::new();
939        let before = fb.rows;
940        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
941        fb.set_pixel(Point::new(0, -1), Color::WHITE);
942        fb.set_pixel(Point::new(TEST_COLS as i32, 0), Color::WHITE);
943        fb.set_pixel(Point::new(0, 32), Color::WHITE);
944        assert_eq!(fb.rows, before);
945    }
946
947    #[test]
948    #[cfg(feature = "skip-black-pixels")]
949    fn test_skip_black_pixels_enabled() {
950        let mut fb = TestBuffer::new();
951
952        // Set a red pixel first
953        fb.set_pixel_internal(10, 5, Color::RED);
954
955        // Verify it's red in the first plane
956        let mapped_col_10 = map_index(10);
957        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
958        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
959        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
960
961        // Now set it to black - with skip-black-pixels enabled, this should be ignored
962        fb.set_pixel_internal(10, 5, Color::BLACK);
963
964        // The pixel should still be red (black write was skipped)
965        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
966        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
967        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
968    }
969
970    #[test]
971    #[cfg(not(feature = "skip-black-pixels"))]
972    fn test_skip_black_pixels_disabled() {
973        let mut fb = TestBuffer::new();
974
975        // Set a red pixel first
976        fb.set_pixel_internal(10, 5, Color::RED);
977
978        // Verify it's red in the first plane
979        let mapped_col_10 = map_index(10);
980        assert!(fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
981        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
982        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
983
984        // Now set it to black - with skip-black-pixels disabled, this should overwrite
985        fb.set_pixel_internal(10, 5, Color::BLACK);
986
987        // The pixel should now be black (all bits false)
988        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].red1());
989        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].grn1());
990        assert!(!fb.rows[map_row_index::<16>(5)].planes[0].data[mapped_col_10].blu1());
991    }
992
993    #[test]
994    #[should_panic(expected = "NROWS must be within 1..=32")]
995    fn new_panics_for_too_many_rows() {
996        let _ = DmaFrameBuffer::<33, TEST_COLS, 8>::new();
997    }
998
999    #[test]
1000    #[should_panic(expected = "PLANES must be within 1..=8")]
1001    fn new_panics_for_too_many_planes() {
1002        let _ = DmaFrameBuffer::<16, TEST_COLS, 9>::new();
1003    }
1004
1005    #[test]
1006    fn pixel_row_ptr_len_returns_correct_pointers() {
1007        let fb = TestBuffer::new();
1008        let (ptr, len) = fb.pixel_row_ptr_len(0, 0);
1009        assert_eq!(len, core::mem::size_of::<PlaneRow<TEST_COLS>>());
1010        assert_eq!(ptr, (&raw const fb.rows[0].planes[0]).cast::<u8>());
1011    }
1012
1013    #[test]
1014    fn gap_ptr_len_returns_correct_pointers() {
1015        let fb = TestBuffer::new();
1016        let (ptr, len) = fb.gap_ptr_len(0);
1017        assert_eq!(len, GAP_BYTES);
1018        assert_eq!(ptr, (&raw const fb.rows[0].gap).cast::<u8>());
1019    }
1020
1021    #[test]
1022    #[should_panic(expected = "out of range")]
1023    fn pixel_row_ptr_len_panics_for_invalid_row() {
1024        let fb = TestBuffer::new();
1025        let _ = fb.pixel_row_ptr_len(16, 0);
1026    }
1027
1028    #[test]
1029    #[should_panic(expected = "out of range")]
1030    fn pixel_row_ptr_len_panics_for_invalid_plane() {
1031        let fb = TestBuffer::new();
1032        let _ = fb.pixel_row_ptr_len(0, TEST_PLANES);
1033    }
1034
1035    #[test]
1036    #[should_panic(expected = "out of range")]
1037    fn gap_ptr_len_panics_for_invalid_row() {
1038        let fb = TestBuffer::new();
1039        let _ = fb.gap_ptr_len(16);
1040    }
1041
1042    #[test]
1043    fn origin_dimensions_match_panel_geometry() {
1044        let fb = TestBuffer::new();
1045        assert_eq!(fb.size(), Size::new(TEST_COLS as u32, 32));
1046    }
1047
1048    #[test]
1049    fn default_matches_new() {
1050        let fb_default = TestBuffer::default();
1051        let fb_new = TestBuffer::new();
1052        assert_eq!(fb_default.rows, fb_new.rows);
1053    }
1054
1055    #[test]
1056    fn debug_impl_includes_shape_information() {
1057        let fb = TestBuffer::new();
1058        let s = format!("{fb:?}");
1059        assert!(s.contains("DmaFrameBuffer"));
1060        assert!(s.contains("row_count"));
1061        assert!(s.contains("planes"));
1062    }
1063
1064    static STATIC_FB: TestBuffer = TestBuffer::new();
1065
1066    #[test]
1067    fn static_construction_is_formatted() {
1068        let runtime_fb = TestBuffer::new();
1069
1070        for (ri, row) in STATIC_FB.rows.iter().enumerate() {
1071            assert_eq!(
1072                row.planes, runtime_fb.rows[ri].planes,
1073                "static vs runtime plane mismatch at row {ri}"
1074            );
1075            assert_eq!(
1076                row.gap, runtime_fb.rows[ri].gap,
1077                "static vs runtime gap mismatch at row {ri}"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn format_reinitializes_at_runtime() {
1084        let mut fb = TestBuffer::new();
1085        fb.erase();
1086        fb.format();
1087
1088        for (ri, row) in fb.rows.iter().enumerate() {
1089            assert_eq!(
1090                row.planes, STATIC_FB.rows[ri].planes,
1091                "re-formatted vs static plane mismatch at row {ri}"
1092            );
1093            assert_eq!(
1094                row.gap, STATIC_FB.rows[ri].gap,
1095                "re-formatted vs static gap mismatch at row {ri}"
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn framebuffer_operations_trait_delegates_correctly() {
1102        let mut fb = TestBuffer::new();
1103        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
1104
1105        // Plane 7 (MSB) carries bit 7; GREEN = 0xFF so bit 7 is set
1106        assert!(fb.rows[map_row_index::<16>(5)].planes[TEST_PLANES - 1].data[map_index(3)].grn1());
1107
1108        FrameBufferOperations::erase(&mut fb);
1109        for row in &fb.rows {
1110            for plane in &row.planes {
1111                for entry in &plane.data {
1112                    assert!(!entry.red1());
1113                    assert!(!entry.grn1());
1114                    assert!(!entry.blu1());
1115                    assert!(!entry.red2());
1116                    assert!(!entry.grn2());
1117                    assert!(!entry.blu2());
1118                }
1119            }
1120        }
1121    }
1122
1123    #[test]
1124    fn row_data_size_matches_expected_layout() {
1125        let expected_planes = core::mem::size_of::<PlaneRow<TEST_COLS>>() * TEST_PLANES;
1126        assert_eq!(
1127            core::mem::size_of::<RowData<TEST_COLS, TEST_PLANES>>(),
1128            expected_planes + GAP_BYTES
1129        );
1130    }
1131
1132    #[test]
1133    fn bcm_segment_count_equals_rows_times_planes_plus_gap() {
1134        let fb = TestBuffer::new();
1135        let gap = if HAS_GAP { 1 } else { 0 };
1136        assert_eq!(fb.bcm_segment_count(), 16 * (TEST_PLANES + gap));
1137    }
1138
1139    #[test]
1140    fn bcm_segments_per_group_equals_planes_plus_gap() {
1141        let fb = TestBuffer::new();
1142        let gap = if HAS_GAP { 1 } else { 0 };
1143        assert_eq!(fb.bcm_segments_per_group(), TEST_PLANES + gap);
1144        assert_eq!(
1145            fb.bcm_segment_count() % fb.bcm_segments_per_group(),
1146            0,
1147            "segment count must be divisible by segments_per_group"
1148        );
1149    }
1150
1151    #[test]
1152    fn bcm_segments_interleave_pixel_and_gap() {
1153        let fb = TestBuffer::new();
1154        let gap = if HAS_GAP { 1 } else { 0 };
1155        let segments_per_row = TEST_PLANES + gap;
1156
1157        for row in 0..16usize {
1158            // Plane 0 (LSB) comes first; without a gap it covers all planes.
1159            let seg = fb.bcm_segment(row * segments_per_row);
1160            let (ptr, plane_len) = fb.pixel_row_ptr_len(row, 0);
1161            let covered0 = if HAS_GAP { 1 } else { TEST_PLANES };
1162            assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane 0");
1163            assert_eq!(
1164                seg.len,
1165                plane_len * covered0,
1166                "wrong len at row {row} plane 0"
1167            );
1168            assert_eq!(seg.reps, 1, "wrong reps at row {row} plane 0");
1169
1170            // The inter-row gap (if any) is streamed between plane 0 and 1.
1171            if HAS_GAP {
1172                let seg = fb.bcm_segment(row * segments_per_row + 1);
1173                let (ptr, len) = fb.gap_ptr_len(row);
1174                assert_eq!(seg.ptr, ptr, "wrong gap ptr at row {row}");
1175                assert_eq!(seg.len, len, "wrong gap len at row {row}");
1176                assert_eq!(seg.reps, 1, "gap reps must be 1 at row {row}");
1177            }
1178
1179            // Planes 1.. follow the gap; each streams the remaining suffix.
1180            for plane in 1..TEST_PLANES {
1181                let idx = row * segments_per_row + plane + gap;
1182                let seg = fb.bcm_segment(idx);
1183                let (ptr, plane_len) = fb.pixel_row_ptr_len(row, plane);
1184                let expected_reps = if HAS_GAP && plane == 1 {
1185                    2
1186                } else {
1187                    1 << (plane - 1)
1188                };
1189                assert_eq!(seg.ptr, ptr, "wrong ptr at row {row} plane {plane}");
1190                assert_eq!(
1191                    seg.len,
1192                    plane_len * (TEST_PLANES - plane),
1193                    "wrong len at row {row} plane {plane}"
1194                );
1195                assert_eq!(
1196                    seg.reps, expected_reps,
1197                    "wrong reps at row {row} plane {plane}"
1198                );
1199            }
1200        }
1201    }
1202
1203    #[test]
1204    fn bcm_segment_total_reps_per_row() {
1205        let fb = TestBuffer::new();
1206        let gap = if HAS_GAP { 1 } else { 0 };
1207        let segments_per_row = TEST_PLANES + gap;
1208
1209        for row in 0..16usize {
1210            // Suffix coalescing halves the per-row transfer count to
1211            // 2^(PLANES-1); with an inter-row gap, plane 0 stands alone and
1212            // plane 1's extra rep adds one more.
1213            let mut pixel_reps = 0;
1214            for plane in 0..TEST_PLANES {
1215                // Plane 0 is at offset 0, planes 1.. at offset 1 + gap.
1216                let offset = if plane == 0 { 0 } else { plane + gap };
1217                pixel_reps += fb.bcm_segment(row * segments_per_row + offset).reps;
1218            }
1219            let expected = if HAS_GAP {
1220                (1 << (TEST_PLANES - 1)) + 1
1221            } else {
1222                1 << (TEST_PLANES - 1)
1223            };
1224            assert_eq!(pixel_reps, expected, "total pixel reps wrong for row {row}");
1225        }
1226    }
1227
1228    #[test]
1229    fn bcm_segment_plane_coverage_matches_bcm_weights() {
1230        let fb = TestBuffer::new();
1231        let gap = if HAS_GAP { 1 } else { 0 };
1232        let segments_per_row = TEST_PLANES + gap;
1233        let plane_bytes = core::mem::size_of::<PlaneRow<TEST_COLS>>();
1234
1235        for row in 0..16usize {
1236            for plane in 0..TEST_PLANES {
1237                // Sum the reps of every pixel segment that spans this plane:
1238                // the segment for plane `first` covers `len / plane_bytes`
1239                // consecutive planes starting at `first`.
1240                let mut coverage = 0;
1241                for first in 0..=plane {
1242                    // Plane 0 is at offset 0, planes 1.. at offset 1 + gap.
1243                    let offset = if first == 0 { 0 } else { first + gap };
1244                    let seg = fb.bcm_segment(row * segments_per_row + offset);
1245                    if first + seg.len / plane_bytes > plane {
1246                        coverage += seg.reps;
1247                    }
1248                }
1249                assert_eq!(
1250                    coverage,
1251                    1 << plane,
1252                    "plane {plane} coverage wrong for row {row}"
1253                );
1254            }
1255        }
1256    }
1257
1258    #[test]
1259    #[should_panic(expected = "out of range")]
1260    fn bcm_segment_panics_for_invalid_index() {
1261        let fb = TestBuffer::new();
1262        let gap = if HAS_GAP { 1 } else { 0 };
1263        let segments_per_row = TEST_PLANES + gap;
1264        let _ = fb.bcm_segment(16 * segments_per_row);
1265    }
1266
1267    #[test]
1268    fn bcm_sequence_matches_runtime_segments() {
1269        use crate::FrameBuffer;
1270        let fb = TestBuffer::new();
1271        assert_eq!(
1272            TestBuffer::BCM_SEGMENT_COUNT,
1273            TestBuffer::BCM_SEQUENCE_LEN * TestBuffer::BCM_SEQUENCE_COUNT
1274        );
1275        assert_eq!(fb.bcm_segment_count(), TestBuffer::BCM_SEGMENT_COUNT);
1276        assert_eq!(
1277            fb.bcm_segments_per_group(),
1278            TestBuffer::BCM_SEGMENTS_PER_GROUP
1279        );
1280        assert_eq!(
1281            TestBuffer::BCM_SEQUENCE_LEN % TestBuffer::BCM_SEGMENTS_PER_GROUP,
1282            0
1283        );
1284        for i in 0..TestBuffer::BCM_SEGMENT_COUNT {
1285            let entry = TestBuffer::BCM_SEQUENCE[i % TestBuffer::BCM_SEQUENCE_LEN];
1286            let seg = fb.bcm_segment(i);
1287            assert_eq!((seg.len, seg.reps), (entry.len, entry.reps), "segment {i}");
1288            assert!(!seg.ptr.is_null(), "segment {i} has null pointer");
1289        }
1290        for entry in &TestBuffer::BCM_SEQUENCE[TestBuffer::BCM_SEQUENCE_LEN..] {
1291            assert_eq!(*entry, crate::BcmLenReps::ZERO, "padding must be zero");
1292        }
1293    }
1294
1295    #[test]
1296    fn bcm_segment_data_contains_written_pixels() {
1297        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1298            128
1299        } else {
1300            64
1301        };
1302        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1303        let mut fb = TrinityBuf::new();
1304
1305        fb.set_pixel(Point::new(10, 0), Color::WHITE);
1306
1307        let col_idx = map_index(10);
1308        let gap = usize::from(HAS_GAP);
1309        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1310        let row0_base = map_row_index::<32>(0) * (5 + gap);
1311
1312        for plane_idx in 0..5usize {
1313            // Row 0: plane 0 is the slot's first segment, planes 1+ follow the
1314            // gap segment.
1315            let seg = fb.bcm_segment(if plane_idx == 0 {
1316                row0_base
1317            } else {
1318                row0_base + plane_idx + gap
1319            });
1320
1321            let data = unsafe { core::slice::from_raw_parts(seg.ptr, TRINITY_COLS) };
1322            let raw = data[col_idx];
1323            let entry = Entry(raw);
1324
1325            let bit = 8 - 5 + plane_idx; // LSB-first
1326            let expect_r = (0xFF >> bit) & 1 != 0;
1327            let expect_g = (0xFF >> bit) & 1 != 0;
1328            let expect_b = (0xFF >> bit) & 1 != 0;
1329            assert_eq!(
1330                entry.red1(),
1331                expect_r,
1332                "plane {plane_idx} (bit {bit}) R1 mismatch: raw={raw:#04x}"
1333            );
1334            assert_eq!(
1335                entry.grn1(),
1336                expect_g,
1337                "plane {plane_idx} (bit {bit}) G1 mismatch: raw={raw:#04x}"
1338            );
1339            assert_eq!(
1340                entry.blu1(),
1341                expect_b,
1342                "plane {plane_idx} (bit {bit}) B1 mismatch: raw={raw:#04x}"
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn bcm_segment_data_contains_gradient_pixels() {
1349        const TRINITY_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
1350            128
1351        } else {
1352            64
1353        };
1354        type TrinityBuf = DmaFrameBuffer<32, TRINITY_COLS, 5>;
1355        let mut fb = TrinityBuf::new();
1356
1357        let step: u8 = (256 / TRINITY_COLS) as u8;
1358        for x in 0..TRINITY_COLS {
1359            let brightness = (x as u8).wrapping_mul(step);
1360            fb.set_pixel(Point::new(x as i32, 0), Color::new(brightness, 0, 0));
1361        }
1362
1363        let mut any_nonzero = false;
1364        let gap = usize::from(HAS_GAP);
1365        // Physical slot holding logical row 0 (reversed by reverse-row-order).
1366        let row0_base = map_row_index::<32>(0) * (5 + gap);
1367        for plane_idx in 0..5usize {
1368            // Row 0: plane 0 is the slot's first segment, planes 1+ follow the
1369            // gap segment.
1370            let seg = fb.bcm_segment(if plane_idx == 0 {
1371                row0_base
1372            } else {
1373                row0_base + plane_idx + gap
1374            });
1375            let data = unsafe { core::slice::from_raw_parts(seg.ptr, TRINITY_COLS) };
1376
1377            for x in 0..TRINITY_COLS {
1378                let col_idx = map_index(x);
1379                let entry = Entry(data[col_idx]);
1380                let brightness = (x as u8).wrapping_mul(step);
1381                let bit = 8 - 5 + plane_idx; // LSB-first
1382                let expect_r = (brightness >> bit) & 1 != 0;
1383                assert_eq!(
1384                    entry.red1(),
1385                    expect_r,
1386                    "x={x} plane={plane_idx} bit={bit} brightness={brightness} \
1387                     raw={:#04x}",
1388                    data[col_idx]
1389                );
1390                if expect_r {
1391                    any_nonzero = true;
1392                }
1393            }
1394        }
1395        assert!(
1396            any_nonzero,
1397            "gradient should produce at least some non-zero color bits"
1398        );
1399    }
1400}