Skip to main content

hub75_framebuffer/bitplane/latched/
frame.rs

1//! Plane-major bitplane framebuffer for an 8-bit latched HUB75 interface.
2//!
3//! Stores all rows for one bit-plane contiguously, then the next plane, etc.
4//! Planes are stored **LSB-first**: plane 0 carries the LSB (bit 0) of each
5//! colour channel and is displayed once per frame, plane `PLANES-1` carries
6//! the MSB (bit 7) and is displayed `2^(PLANES-1)` times per frame.
7//!
8//! Each row is `COLS` data bytes plus 4 address bytes (and an optional
9//! inter-row gap), so total size is
10//! `PLANES × NROWS × (COLS + 4 + INTER_ROW_BLANK)` bytes.
11//!
12//! # DMA Descriptor Pattern
13//!
14//! The planes are contiguous (`repr(C)`), so each BCM segment streams a
15//! whole *suffix* of planes: the segment for plane `k` starts at plane `k`
16//! and covers planes `k..PLANES`, repeated just enough times to bring plane
17//! `k`'s total coverage to `2^k` displays per frame. This halves the number
18//! of DMA transfers per frame — `2^(PLANES-1)` instead of `2^PLANES - 1` —
19//! while the streamed bytes, and therefore the brightness, are unchanged.
20//! The four address bytes and the inter-row gap (both embedded in each row)
21//! travel inside the coalesced segments automatically.
22//!
23//! ```text
24//! planes 0..PLANES      × 1 rep        → planes[0..]
25//! planes 1..PLANES      × 1 rep        → planes[1..]
26//! planes 2..PLANES      × 2 reps       → planes[2..]
27//! …
28//! plane  PLANES-1 (MSB) × 2^(PLANES-2) → planes[PLANES-1]
29//! ```
30
31use core::convert::Infallible;
32
33use embedded_graphics::pixelcolor::RgbColor;
34use embedded_graphics::prelude::{DrawTarget, OriginDimensions, Point, Size};
35
36use super::{make_data_template, map_index, Address, Entry, ADDR_TABLE, INTER_ROW_BLANK, OE_BLANK};
37use crate::Color;
38use crate::{map_row_index, slot_addresses};
39use crate::{BcmLenReps, FrameBuffer, BCM_SEQUENCE_CAPACITY};
40use crate::{FrameBufferOperations, MutableFrameBuffer};
41
42#[derive(Clone, Copy, PartialEq, Debug)]
43#[repr(C)]
44/// A single BCM row payload for 8-bit latched output.
45///
46/// Each row contains color-stream data for `COLS` pixels followed by four
47/// address/control bytes that clock the row address into the external latch.
48pub struct Row<const COLS: usize> {
49    pub(crate) data: [Entry; COLS],
50    pub(crate) address: [Address; 4],
51    pub(crate) gap: [Entry; INTER_ROW_BLANK],
52}
53
54impl<const COLS: usize> Row<COLS> {
55    /// Creates a zero-initialized row.
56    ///
57    /// Call [`Self::format`] before first use to populate row address/control
58    /// metadata.
59    #[must_use]
60    pub const fn new() -> Self {
61        Self {
62            data: [Entry::new(); COLS],
63            address: [Address::new(); 4],
64            gap: [Entry::new(); INTER_ROW_BLANK],
65        }
66    }
67
68    /// Formats this row for the provided multiplexed row address.
69    ///
70    /// This sets the trailing address bytes and initializes output-enable/latch
71    /// bits in the pixel stream template.
72    ///
73    /// # Panics
74    ///
75    /// Panics if `addr` is out of range for the address lookup table.
76    #[inline]
77    pub const fn format(&mut self, addr: u8) {
78        assert!((addr as usize) < ADDR_TABLE.len());
79        let src_addr = ADDR_TABLE[addr as usize];
80        self.address[0] = src_addr[0];
81        self.address[1] = src_addr[1];
82        self.address[2] = src_addr[2];
83        self.address[3] = src_addr[3];
84
85        let data_template = make_data_template::<COLS>();
86        let mut i = 0;
87        while i < COLS {
88            self.data[i] = data_template[i];
89            i += 1;
90        }
91
92        let mut i = 0;
93        // INTER_ROW_BLANK is 0 unless an inter-row-blank-* feature is enabled.
94        #[allow(clippy::absurd_extreme_comparisons)]
95        while i < INTER_ROW_BLANK {
96            self.gap[i].0 = OE_BLANK;
97            i += 1;
98        }
99    }
100}
101
102impl<const COLS: usize> Default for Row<COLS> {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108/// Coalesced BCM suffix for plane `plane_idx`: returns
109/// `(covered_planes, reps)` — how many planes the segment spans (starting at
110/// `plane_idx`) and how many times that suffix is streamed. See the
111/// module-level "DMA Descriptor Pattern" section for the full scheme.
112const fn plane_suffix(plane_idx: usize, planes: usize) -> (usize, usize) {
113    let covered = planes - plane_idx;
114    let reps = if plane_idx == 0 {
115        1
116    } else {
117        1 << (plane_idx - 1)
118    };
119    (covered, reps)
120}
121
122/// BCM sequence for one complete frame: one entry per plane, each streaming
123/// the contiguous plane suffix `plane..PLANES`. Entries past `PLANES` are
124/// zero padding.
125const fn bcm_sequence<const NROWS: usize, const COLS: usize, const PLANES: usize>(
126) -> [BcmLenReps; BCM_SEQUENCE_CAPACITY] {
127    assert!(PLANES <= BCM_SEQUENCE_CAPACITY);
128    let plane_bytes = NROWS * core::mem::size_of::<Row<COLS>>();
129    let mut seq = [BcmLenReps::ZERO; BCM_SEQUENCE_CAPACITY];
130    let mut plane = 0usize;
131    while plane < PLANES {
132        let (covered, reps) = plane_suffix(plane, PLANES);
133        seq[plane] = BcmLenReps {
134            len: plane_bytes * covered,
135            reps,
136        };
137        plane += 1;
138    }
139    seq
140}
141
142/// The entire BCM Frame Buffer (Contiguous Memory)
143#[derive(Copy, Clone)]
144#[repr(C, align(4))]
145pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
146    pub(crate) planes: [[Row<COLS>; NROWS]; PLANES],
147}
148
149impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
150    DmaFrameBuffer<NROWS, COLS, PLANES>
151{
152    /// Creates a new frame buffer.
153    ///
154    /// # Panics
155    /// Panics if `NROWS` is not within `1..=32` (5-bit row address) or
156    /// `PLANES` is not within `1..=8` (8-bit color depth). With the
157    /// `esp32-ordering` feature, also panics if `COLS` is not divisible
158    /// by 4 (the ESP32's byte-order rearrangement requires column counts
159    /// that are multiples of 4). In const contexts (e.g. `static`
160    /// framebuffers) this is a compile-time error.
161    #[must_use]
162    pub const fn new() -> Self {
163        assert!(NROWS >= 1 && NROWS <= 32, "NROWS must be within 1..=32");
164        assert!(PLANES >= 1 && PLANES <= 8, "PLANES must be within 1..=8");
165        #[cfg(feature = "esp32-ordering")]
166        assert!(
167            COLS % 4 == 0,
168            "esp32-ordering feature requires COLS to be divisible by 4"
169        );
170        let mut instance = Self {
171            planes: [[Row::new(); NROWS]; PLANES],
172        };
173        instance.format();
174        instance
175    }
176
177    /// Returns the number of BCM chunks (one per bit-plane).
178    #[must_use]
179    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
180    pub const fn bcm_chunk_count() -> usize {
181        PLANES
182    }
183
184    /// Returns the byte size of one BCM chunk (a single bit-plane).
185    #[must_use]
186    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
187    pub const fn bcm_chunk_bytes() -> usize {
188        NROWS * core::mem::size_of::<Row<COLS>>()
189    }
190
191    /// Returns the number of rows per plane for row-based BCM.
192    #[must_use]
193    pub const fn bcm_row_count() -> usize {
194        NROWS
195    }
196
197    /// Formats the frame buffer with row addresses and control bits.
198    #[inline]
199    pub const fn format(&mut self) {
200        let mut p = 0;
201        while p < PLANES {
202            let mut slot = 0;
203            while slot < NROWS {
204                let (_, addr) = slot_addresses::<NROWS>(slot);
205                self.planes[p][slot].format(addr);
206                slot += 1;
207            }
208            p += 1;
209        }
210    }
211
212    /// Erase pixel colors while preserving row control data.
213    #[inline]
214    pub fn erase(&mut self) {
215        const MASK: u8 = !0b0011_1111;
216        for plane in &mut self.planes {
217            for row in plane {
218                for entry in &mut row.data {
219                    entry.0 &= MASK;
220                }
221            }
222        }
223    }
224
225    /// Set a pixel in the framebuffer.
226    #[inline]
227    pub fn set_pixel(&mut self, p: Point, color: Color) {
228        if p.x < 0 || p.y < 0 {
229            return;
230        }
231        self.set_pixel_internal(p.x as usize, p.y as usize, color);
232    }
233
234    #[inline]
235    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
236        if x >= COLS || y >= NROWS * 2 {
237            return;
238        }
239
240        // Early exit for black pixels - common in UI backgrounds
241        // Only enabled when skip-black-pixels feature is active
242        #[cfg(feature = "skip-black-pixels")]
243        if color == Color::BLACK {
244            return;
245        }
246
247        let row_idx = map_row_index::<NROWS>(if y < NROWS { y } else { y - NROWS });
248        let is_top = y < NROWS;
249        let red = color.r();
250        let green = color.g();
251        let blue = color.b();
252
253        for plane_idx in 0..PLANES {
254            let bit = (8 - PLANES + plane_idx) as u32;
255            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
256                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
257                | u8::from(((red >> bit) & 1) != 0);
258            let col_idx = map_index(x);
259            let entry = &mut self.planes[plane_idx][row_idx].data[col_idx];
260            if is_top {
261                entry.set_color0_bits(bits);
262            } else {
263                entry.set_color1_bits(bits);
264            }
265        }
266    }
267}
268
269impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
270    for DmaFrameBuffer<NROWS, COLS, PLANES>
271{
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
278    for DmaFrameBuffer<NROWS, COLS, PLANES>
279{
280    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
281        f.debug_struct("DmaFrameBuffer")
282            .field("size", &core::mem::size_of_val(&self.planes))
283            .field("plane_count", &self.planes.len())
284            .field("plane_size", &core::mem::size_of_val(&self.planes[0]))
285            .finish()
286    }
287}
288
289#[cfg(feature = "defmt")]
290impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
291    for DmaFrameBuffer<NROWS, COLS, PLANES>
292{
293    fn format(&self, f: defmt::Formatter) {
294        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
295        defmt::write!(f, " size: {}", core::mem::size_of_val(&self.planes));
296        defmt::write!(
297            f,
298            " plane_size: {}",
299            core::mem::size_of_val(&self.planes[0])
300        );
301    }
302}
303
304impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
305    for DmaFrameBuffer<NROWS, COLS, PLANES>
306{
307    type Word = u8;
308
309    const BCM_SEQUENCE: [BcmLenReps; BCM_SEQUENCE_CAPACITY] = bcm_sequence::<NROWS, COLS, PLANES>();
310
311    const BCM_SEQUENCE_LEN: usize = PLANES;
312
313    const BCM_SEQUENCE_COUNT: usize = 1;
314
315    fn bcm_segment_ptr(&self, index: usize) -> *const u8 {
316        assert!(
317            index < PLANES,
318            "segment index {index} out of range for {PLANES} planes"
319        );
320        self.planes[index].as_ptr().cast::<u8>()
321    }
322}
323
324impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
325    for DmaFrameBuffer<NROWS, COLS, PLANES>
326{
327    #[inline]
328    fn erase(&mut self) {
329        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
330    }
331
332    #[inline]
333    fn set_pixel(&mut self, p: Point, color: Color) {
334        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
335    }
336}
337
338impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
339    for DmaFrameBuffer<NROWS, COLS, PLANES>
340{
341}
342
343impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
344    for DmaFrameBuffer<NROWS, COLS, PLANES>
345{
346    fn size(&self) -> Size {
347        Size::new(COLS as u32, (NROWS * 2) as u32)
348    }
349}
350
351impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
352    for DmaFrameBuffer<NROWS, COLS, PLANES>
353{
354    type Color = Color;
355    type Error = Infallible;
356
357    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
358    where
359        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
360    {
361        for pixel in pixels {
362            if pixel.0.x < 0 || pixel.0.y < 0 {
363                continue;
364            }
365            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
366        }
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    extern crate std;
374
375    use super::super::{make_addr_table, LEAD_BLANK_DELAY, TRAIL_BLANK_DELAY};
376    use super::*;
377    use embedded_graphics::prelude::*;
378    use std::format;
379
380    const TEST_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
381        128
382    } else {
383        64
384    };
385    type TestBuffer = DmaFrameBuffer<16, TEST_COLS, 8>;
386
387    #[test]
388    fn row_format_sets_address_and_control_bits() {
389        const TEST_N: usize = TEST_COLS;
390        let mut row = Row::<TEST_N>::new();
391        row.format(5);
392        let latch_count = row.address.iter().filter(|a| a.latch()).count();
393        assert_eq!(latch_count, 2);
394        assert_eq!(row.address[map_index(0)].addr(), 5);
395        assert_eq!(row.address[map_index(1)].addr(), 5);
396        assert_eq!(row.address[map_index(2)].addr(), 5);
397        assert_eq!(row.address[map_index(3)].0, OE_BLANK);
398        let oe_active = !cfg!(feature = "invert-oe");
399        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
400        let blank_count = TEST_N - active_count;
401        let oe_blank_count = row
402            .data
403            .iter()
404            .filter(|entry| entry.output_enable() != oe_active)
405            .count();
406        assert_eq!(oe_blank_count, blank_count);
407    }
408
409    #[test]
410    fn format_sets_expected_row_addresses_for_all_rows() {
411        let mut fb = TestBuffer::new();
412        fb.format();
413
414        for plane_idx in 0..8 {
415            for slot in 0..16 {
416                let (_, addr) = slot_addresses::<16>(slot);
417                let row = &fb.planes[plane_idx][slot];
418                assert_eq!(row.address[map_index(0)].addr(), addr);
419                assert_eq!(row.address[map_index(1)].addr(), addr);
420                assert_eq!(row.address[map_index(2)].addr(), addr);
421                assert_eq!(row.address[map_index(3)].0, OE_BLANK);
422            }
423        }
424    }
425
426    #[test]
427    fn set_pixel_maps_top_half_bits_per_plane() {
428        let mut fb = TestBuffer::new();
429        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
430        fb.set_pixel(Point::new(2, 3), color);
431
432        for plane_idx in 0..8 {
433            let bit = plane_idx;
434            let entry = fb.planes[plane_idx][map_row_index::<16>(3)].data[map_index(2)];
435            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
436            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
437            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
438        }
439    }
440
441    #[test]
442    fn set_pixel_maps_bottom_half_bits_per_plane() {
443        let mut fb = TestBuffer::new();
444        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
445        fb.set_pixel(Point::new(4, 20), color);
446
447        for plane_idx in 0..8 {
448            let bit = plane_idx;
449            let entry = fb.planes[plane_idx][map_row_index::<16>(4)].data[map_index(4)];
450            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
451            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
452            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
453        }
454    }
455
456    #[test]
457    #[cfg(feature = "reverse-row-order")]
458    fn reverse_row_order_stores_rows_back_to_front() {
459        let mut fb = TestBuffer::new();
460
461        // Slot 0 is streamed first and renders the last panel row; the final
462        // slot renders panel row 0. The address bytes carry the slot's own
463        // row address.
464        assert_eq!(fb.planes[0][0].address[map_index(0)].addr(), 15);
465        assert_eq!(fb.planes[0][15].address[map_index(0)].addr(), 0);
466
467        // Logical row 0 maps to the last memory slot.
468        fb.set_pixel(Point::new(2, 0), Color::RED);
469        let col2 = map_index(2);
470        assert!(fb.planes[7][15].data[col2].red1());
471        assert!(!fb.planes[7][0].data[col2].red1());
472    }
473
474    #[test]
475    fn erase_clears_only_color_bits() {
476        let mut fb = TestBuffer::new();
477        let oe_before = fb.planes[0][map_row_index::<16>(0)].data[0].output_enable();
478        fb.set_pixel(Point::new(0, 0), Color::WHITE);
479        fb.erase();
480
481        for plane in &fb.planes {
482            for row in plane {
483                for entry in &row.data {
484                    assert!(!entry.red1());
485                    assert!(!entry.grn1());
486                    assert!(!entry.blu1());
487                    assert!(!entry.red2());
488                    assert!(!entry.grn2());
489                    assert!(!entry.blu2());
490                }
491            }
492        }
493
494        assert_eq!(
495            fb.planes[0][map_row_index::<16>(0)].data[0].output_enable(),
496            oe_before
497        );
498    }
499
500    #[test]
501    fn draw_target_iter_sets_pixels() {
502        let mut fb = TestBuffer::new();
503        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
504        let result = fb.draw_iter(pixels);
505        assert!(result.is_ok());
506
507        for plane_idx in 0..8 {
508            let bit = plane_idx;
509            let entry = fb.planes[plane_idx][map_row_index::<16>(1)].data[map_index(1)];
510            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
511            assert!(!entry.grn1());
512            assert!(!entry.blu1());
513        }
514    }
515
516    #[test]
517    fn set_pixel_ignores_out_of_bounds_and_negative() {
518        let mut fb = TestBuffer::new();
519        let before = fb.planes;
520        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
521        fb.set_pixel(Point::new(0, -1), Color::WHITE);
522        fb.set_pixel(Point::new(TEST_COLS as i32, 0), Color::WHITE);
523        fb.set_pixel(Point::new(0, 32), Color::WHITE);
524        assert_eq!(fb.planes, before);
525    }
526
527    #[test]
528    #[cfg(feature = "skip-black-pixels")]
529    fn test_skip_black_pixels_enabled() {
530        let mut fb = TestBuffer::new();
531
532        // Set a red pixel first
533        fb.set_pixel_internal(10, 5, Color::RED);
534
535        // Verify it's red in the first plane
536        let mapped_col_10 = map_index(10);
537        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
538        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
539        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
540
541        // Now set it to black - with skip-black-pixels enabled, this should be ignored
542        fb.set_pixel_internal(10, 5, Color::BLACK);
543
544        // The pixel should still be red (black write was skipped)
545        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
546        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
547        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
548    }
549
550    #[test]
551    #[cfg(not(feature = "skip-black-pixels"))]
552    fn test_skip_black_pixels_disabled() {
553        let mut fb = TestBuffer::new();
554
555        // Set a red pixel first
556        fb.set_pixel_internal(10, 5, Color::RED);
557
558        // Verify it's red in the first plane
559        let mapped_col_10 = map_index(10);
560        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
561        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
562        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
563
564        // Now set it to black - with skip-black-pixels disabled, this should overwrite
565        fb.set_pixel_internal(10, 5, Color::BLACK);
566
567        // The pixel should now be black (all bits false)
568        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
569        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
570        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
571    }
572
573    #[test]
574    #[should_panic(expected = "NROWS must be within 1..=32")]
575    fn new_panics_for_too_many_rows() {
576        let _ = DmaFrameBuffer::<33, TEST_COLS, 8>::new();
577    }
578
579    #[test]
580    #[should_panic(expected = "PLANES must be within 1..=8")]
581    fn new_panics_for_too_many_planes() {
582        let _ = DmaFrameBuffer::<16, TEST_COLS, 9>::new();
583    }
584
585    #[test]
586    #[allow(deprecated)]
587    fn bcm_chunk_info_for_common_panel() {
588        assert_eq!(TestBuffer::bcm_chunk_count(), 8);
589        assert_eq!(
590            TestBuffer::bcm_chunk_bytes(),
591            16 * core::mem::size_of::<Row<TEST_COLS>>()
592        );
593    }
594
595    #[test]
596    fn origin_dimensions_match_panel_geometry() {
597        let fb = TestBuffer::new();
598        assert_eq!(fb.size(), Size::new(TEST_COLS as u32, 32));
599    }
600
601    #[test]
602    fn debug_impl_includes_shape_information() {
603        let fb = TestBuffer::new();
604        let s = format!("{fb:?}");
605        assert!(s.contains("DmaFrameBuffer"));
606        assert!(s.contains("plane_count"));
607        assert!(s.contains("plane_size"));
608    }
609
610    #[test]
611    fn row_format_sets_exactly_one_data_word_with_oe_low() {
612        const TEST_N: usize = TEST_COLS;
613        let mut row = Row::<TEST_N>::new();
614        row.format(9);
615
616        let oe_active = !cfg!(feature = "invert-oe");
617        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
618        let blank_count = TEST_N - active_count;
619        let oe_blank_indices: std::vec::Vec<_> = row
620            .data
621            .iter()
622            .enumerate()
623            .filter_map(|(i, entry)| (entry.output_enable() != oe_active).then_some(i))
624            .collect();
625        assert_eq!(oe_blank_indices.len(), blank_count);
626        assert!(oe_blank_indices.contains(&map_index(TEST_N - 1)));
627    }
628
629    #[test]
630    fn default_constructors_match_new() {
631        let row_default = Row::<TEST_COLS>::default();
632        let row_new = Row::<TEST_COLS>::new();
633        assert_eq!(row_default, row_new);
634
635        let fb_default = TestBuffer::default();
636        let fb_new = TestBuffer::new();
637        assert_eq!(fb_default.planes, fb_new.planes);
638    }
639
640    #[test]
641    fn framebuffer_operations_trait_delegates_correctly() {
642        let mut fb = TestBuffer::new();
643        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
644
645        assert!(fb.planes[0][map_row_index::<16>(5)].data[map_index(3)].grn1());
646
647        FrameBufferOperations::erase(&mut fb);
648        for plane in &fb.planes {
649            for row in plane {
650                for entry in &row.data {
651                    assert!(!entry.red1());
652                    assert!(!entry.grn1());
653                    assert!(!entry.blu1());
654                    assert!(!entry.red2());
655                    assert!(!entry.grn2());
656                    assert!(!entry.blu2());
657                }
658            }
659        }
660    }
661
662    #[test]
663    fn addr_table_entries_are_consistent() {
664        let table = make_addr_table();
665        for addr in 0..32u8 {
666            let row = &table[addr as usize];
667            // First two clocks: latch asserted with row address
668            assert!(row[map_index(0)].latch());
669            assert_eq!(row[map_index(0)].addr(), addr);
670            assert!(row[map_index(1)].latch());
671            assert_eq!(row[map_index(1)].addr(), addr);
672            // Third clock: latch released, address still driven
673            assert!(!row[map_index(2)].latch());
674            assert_eq!(row[map_index(2)].addr(), addr);
675            // Fourth clock: clear cycle, only OE_BLANK
676            assert!(!row[map_index(3)].latch());
677            assert_eq!(row[map_index(3)].0, OE_BLANK);
678        }
679        assert_eq!(table, ADDR_TABLE);
680    }
681
682    #[test]
683    fn test_blanking_delay() {
684        let mut row = Row::<TEST_COLS>::new();
685        row.format(5);
686
687        let oe_active = !cfg!(feature = "invert-oe");
688
689        // Trail blank: indices 0..TRAIL_BLANK_DELAY-1 (DMA start = physical right edge)
690        if TRAIL_BLANK_DELAY > 0 {
691            let trail_blank_idx = map_index(TRAIL_BLANK_DELAY - 1);
692            assert_eq!(row.data[trail_blank_idx].output_enable(), !oe_active);
693        }
694
695        let first_active_idx = map_index(TRAIL_BLANK_DELAY);
696        assert_eq!(row.data[first_active_idx].output_enable(), oe_active);
697
698        // Lead blank: indices before latch (DMA end = physical left edge)
699        let last_active_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 2);
700        assert_eq!(row.data[last_active_idx].output_enable(), oe_active);
701
702        let lead_blank_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
703        assert_eq!(row.data[lead_blank_idx].output_enable(), !oe_active);
704
705        let last_pixel_idx = map_index(TEST_COLS - 1);
706        assert_eq!(row.data[last_pixel_idx].output_enable(), !oe_active);
707    }
708
709    static STATIC_FB: TestBuffer = TestBuffer::new();
710
711    #[test]
712    fn test_static_construction_is_formatted() {
713        let runtime_fb = TestBuffer::new();
714
715        for (pi, plane) in STATIC_FB.planes.iter().enumerate() {
716            for (ri, row) in plane.iter().enumerate() {
717                assert_eq!(
718                    row.data, runtime_fb.planes[pi][ri].data,
719                    "static vs runtime data mismatch at plane {pi}, row {ri}"
720                );
721                assert_eq!(
722                    row.address, runtime_fb.planes[pi][ri].address,
723                    "static vs runtime address mismatch at plane {pi}, row {ri}"
724                );
725                assert_eq!(
726                    row.gap, runtime_fb.planes[pi][ri].gap,
727                    "static vs runtime gap mismatch at plane {pi}, row {ri}"
728                );
729            }
730        }
731    }
732
733    #[test]
734    fn test_format_reinitializes_at_runtime() {
735        let mut fb = TestBuffer::new();
736        fb.erase();
737        fb.format();
738
739        for (pi, plane) in fb.planes.iter().enumerate() {
740            for (ri, row) in plane.iter().enumerate() {
741                assert_eq!(
742                    row.data, STATIC_FB.planes[pi][ri].data,
743                    "re-formatted vs static mismatch at plane {pi}, row {ri}"
744                );
745            }
746        }
747    }
748
749    #[test]
750    fn bcm_segment_count_equals_planes() {
751        use crate::FrameBuffer;
752        let fb = TestBuffer::new();
753        assert_eq!(fb.bcm_segment_count(), 8);
754    }
755
756    #[test]
757    fn bcm_segments_have_correct_reps() {
758        use crate::FrameBuffer;
759        let fb = TestBuffer::new();
760        for i in 0..8 {
761            let seg = fb.bcm_segment(i);
762            let expected = if i == 0 { 1 } else { 1 << (i - 1) };
763            assert_eq!(seg.reps, expected, "wrong reps for segment {i}");
764        }
765    }
766
767    #[test]
768    fn bcm_segments_point_to_plane_data() {
769        use crate::FrameBuffer;
770        let fb = TestBuffer::new();
771        let plane_len = 16 * core::mem::size_of::<Row<TEST_COLS>>();
772        for i in 0..8 {
773            let seg = fb.bcm_segment(i);
774            let expected_ptr = fb.planes[i].as_ptr().cast::<u8>();
775            assert_eq!(seg.ptr, expected_ptr, "wrong ptr for segment {i}");
776            assert_eq!(seg.len, plane_len * (8 - i), "wrong len for segment {i}");
777        }
778    }
779
780    #[test]
781    fn bcm_segment_total_reps_is_halved_by_coalescing() {
782        use crate::FrameBuffer;
783        let fb = TestBuffer::new();
784        let total: usize = (0..fb.bcm_segment_count())
785            .map(|i| fb.bcm_segment(i).reps)
786            .sum();
787        assert_eq!(total, 1 << (8 - 1));
788    }
789
790    #[test]
791    fn bcm_segment_plane_coverage_matches_bcm_weights() {
792        use crate::FrameBuffer;
793        let fb = TestBuffer::new();
794        let plane_bytes = 16 * core::mem::size_of::<Row<TEST_COLS>>();
795
796        for plane in 0..8usize {
797            // Sum the reps of every segment that spans this plane: the
798            // segment for plane `first` covers `len / plane_bytes`
799            // consecutive planes starting at `first`.
800            let mut coverage = 0;
801            for first in 0..=plane {
802                let seg = fb.bcm_segment(first);
803                if first + seg.len / plane_bytes > plane {
804                    coverage += seg.reps;
805                }
806            }
807            assert_eq!(coverage, 1 << plane, "plane {plane} coverage wrong");
808        }
809    }
810
811    #[test]
812    #[should_panic(expected = "out of range")]
813    fn bcm_segment_panics_for_invalid_index() {
814        use crate::FrameBuffer;
815        let fb = TestBuffer::new();
816        let _ = fb.bcm_segment(8);
817    }
818
819    #[test]
820    fn bcm_sequence_matches_runtime_segments() {
821        use crate::FrameBuffer;
822        let fb = TestBuffer::new();
823        assert_eq!(
824            TestBuffer::BCM_SEGMENT_COUNT,
825            TestBuffer::BCM_SEQUENCE_LEN * TestBuffer::BCM_SEQUENCE_COUNT
826        );
827        assert_eq!(fb.bcm_segment_count(), TestBuffer::BCM_SEGMENT_COUNT);
828        assert_eq!(
829            fb.bcm_segments_per_group(),
830            TestBuffer::BCM_SEGMENTS_PER_GROUP
831        );
832        assert_eq!(
833            TestBuffer::BCM_SEQUENCE_LEN % TestBuffer::BCM_SEGMENTS_PER_GROUP,
834            0
835        );
836        for i in 0..TestBuffer::BCM_SEGMENT_COUNT {
837            let entry = TestBuffer::BCM_SEQUENCE[i % TestBuffer::BCM_SEQUENCE_LEN];
838            let seg = fb.bcm_segment(i);
839            assert_eq!((seg.len, seg.reps), (entry.len, entry.reps), "segment {i}");
840            assert!(!seg.ptr.is_null(), "segment {i} has null pointer");
841        }
842        for entry in &TestBuffer::BCM_SEQUENCE[TestBuffer::BCM_SEQUENCE_LEN..] {
843            assert_eq!(*entry, crate::BcmLenReps::ZERO, "padding must be zero");
844        }
845    }
846}