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    ///
162    /// # Memory placement (⚠️ large buffer)
163    ///
164    /// This framebuffer is a large inline array (roughly
165    /// `PLANES × NROWS × (COLS + gap)` bytes). A 64×64 panel at `PLANES = 8`
166    /// is tens of kilobytes, which will overflow a task stack if bound to a
167    /// local `let`. The framebuffer is mutable, so give it a `'static` home
168    /// that hands out `&'static mut` (e.g. `static_cell::StaticCell`) —
169    /// never `static mut`. See the crate-level **Memory placement** section
170    /// for a worked example.
171    #[must_use]
172    pub const fn new() -> Self {
173        assert!(NROWS >= 1 && NROWS <= 32, "NROWS must be within 1..=32");
174        assert!(PLANES >= 1 && PLANES <= 8, "PLANES must be within 1..=8");
175        #[cfg(feature = "esp32-ordering")]
176        assert!(
177            COLS % 4 == 0,
178            "esp32-ordering feature requires COLS to be divisible by 4"
179        );
180        let mut instance = Self {
181            planes: [[Row::new(); NROWS]; PLANES],
182        };
183        instance.format();
184        instance
185    }
186
187    /// Returns the number of BCM chunks (one per bit-plane).
188    #[must_use]
189    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
190    pub const fn bcm_chunk_count() -> usize {
191        PLANES
192    }
193
194    /// Returns the byte size of one BCM chunk (a single bit-plane).
195    #[must_use]
196    #[deprecated(since = "0.11.0", note = "use BCM_SEQUENCE instead")]
197    pub const fn bcm_chunk_bytes() -> usize {
198        NROWS * core::mem::size_of::<Row<COLS>>()
199    }
200
201    /// Returns the number of rows per plane for row-based BCM.
202    #[must_use]
203    pub const fn bcm_row_count() -> usize {
204        NROWS
205    }
206
207    /// Formats the frame buffer with row addresses and control bits.
208    #[inline]
209    pub const fn format(&mut self) {
210        let mut p = 0;
211        while p < PLANES {
212            let mut slot = 0;
213            while slot < NROWS {
214                let (_, addr) = slot_addresses::<NROWS>(slot);
215                self.planes[p][slot].format(addr);
216                slot += 1;
217            }
218            p += 1;
219        }
220    }
221
222    /// Erase pixel colors while preserving row control data.
223    #[inline]
224    pub fn erase(&mut self) {
225        const MASK: u8 = !crate::pinmap::LATCHED_COLOR_MASK;
226        for plane in &mut self.planes {
227            for row in plane {
228                for entry in &mut row.data {
229                    entry.0 &= MASK;
230                }
231            }
232        }
233    }
234
235    /// Set a pixel in the framebuffer.
236    #[inline]
237    pub fn set_pixel(&mut self, p: Point, color: Color) {
238        if p.x < 0 || p.y < 0 {
239            return;
240        }
241        self.set_pixel_internal(p.x as usize, p.y as usize, color);
242    }
243
244    #[inline]
245    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
246        if x >= COLS || y >= NROWS * 2 {
247            return;
248        }
249
250        // Early exit for black pixels - common in UI backgrounds
251        // Only enabled when skip-black-pixels feature is active
252        #[cfg(feature = "skip-black-pixels")]
253        if color == Color::BLACK {
254            return;
255        }
256
257        let row_idx = map_row_index::<NROWS>(if y < NROWS { y } else { y - NROWS });
258        let is_top = y < NROWS;
259        let red = color.r();
260        let green = color.g();
261        let blue = color.b();
262
263        for plane_idx in 0..PLANES {
264            let bit = (8 - PLANES + plane_idx) as u32;
265            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
266                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
267                | u8::from(((red >> bit) & 1) != 0);
268            let col_idx = map_index(x);
269            let entry = &mut self.planes[plane_idx][row_idx].data[col_idx];
270            if is_top {
271                entry.set_color0_bits(bits);
272            } else {
273                entry.set_color1_bits(bits);
274            }
275        }
276    }
277}
278
279impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
280    for DmaFrameBuffer<NROWS, COLS, PLANES>
281{
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
288    for DmaFrameBuffer<NROWS, COLS, PLANES>
289{
290    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
291        f.debug_struct("DmaFrameBuffer")
292            .field("size", &core::mem::size_of_val(&self.planes))
293            .field("plane_count", &self.planes.len())
294            .field("plane_size", &core::mem::size_of_val(&self.planes[0]))
295            .finish()
296    }
297}
298
299#[cfg(feature = "defmt")]
300impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
301    for DmaFrameBuffer<NROWS, COLS, PLANES>
302{
303    fn format(&self, f: defmt::Formatter) {
304        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
305        defmt::write!(f, " size: {}", core::mem::size_of_val(&self.planes));
306        defmt::write!(
307            f,
308            " plane_size: {}",
309            core::mem::size_of_val(&self.planes[0])
310        );
311    }
312}
313
314impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
315    for DmaFrameBuffer<NROWS, COLS, PLANES>
316{
317    type Word = u8;
318
319    const BCM_SEQUENCE: [BcmLenReps; BCM_SEQUENCE_CAPACITY] = bcm_sequence::<NROWS, COLS, PLANES>();
320
321    const BCM_SEQUENCE_LEN: usize = PLANES;
322
323    const BCM_SEQUENCE_COUNT: usize = 1;
324
325    fn bcm_segment_ptr(&self, index: usize) -> *const u8 {
326        assert!(
327            index < PLANES,
328            "segment index {index} out of range for {PLANES} planes"
329        );
330        self.planes[index].as_ptr().cast::<u8>()
331    }
332}
333
334impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
335    for DmaFrameBuffer<NROWS, COLS, PLANES>
336{
337    #[inline]
338    fn erase(&mut self) {
339        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
340    }
341
342    #[inline]
343    fn set_pixel(&mut self, p: Point, color: Color) {
344        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
345    }
346}
347
348impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
349    for DmaFrameBuffer<NROWS, COLS, PLANES>
350{
351}
352
353impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
354    for DmaFrameBuffer<NROWS, COLS, PLANES>
355{
356    fn size(&self) -> Size {
357        Size::new(COLS as u32, (NROWS * 2) as u32)
358    }
359}
360
361impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
362    for DmaFrameBuffer<NROWS, COLS, PLANES>
363{
364    type Color = Color;
365    type Error = Infallible;
366
367    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
368    where
369        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
370    {
371        for pixel in pixels {
372            if pixel.0.x < 0 || pixel.0.y < 0 {
373                continue;
374            }
375            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
376        }
377        Ok(())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    extern crate std;
384
385    use super::super::{make_addr_table, LEAD_BLANK_DELAY, TRAIL_BLANK_DELAY};
386    use super::*;
387    use embedded_graphics::prelude::*;
388    use std::format;
389
390    const TEST_COLS: usize = if LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 2 > 64 {
391        128
392    } else {
393        64
394    };
395    type TestBuffer = DmaFrameBuffer<16, TEST_COLS, 8>;
396
397    #[test]
398    fn row_format_sets_address_and_control_bits() {
399        const TEST_N: usize = TEST_COLS;
400        let mut row = Row::<TEST_N>::new();
401        row.format(5);
402        let latch_count = row.address.iter().filter(|a| a.latch()).count();
403        assert_eq!(latch_count, 2);
404        assert_eq!(row.address[map_index(0)].addr(), 5);
405        assert_eq!(row.address[map_index(1)].addr(), 5);
406        assert_eq!(row.address[map_index(2)].addr(), 5);
407        assert_eq!(row.address[map_index(3)].0, OE_BLANK);
408        let oe_active = !cfg!(feature = "invert-oe");
409        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
410        let blank_count = TEST_N - active_count;
411        let oe_blank_count = row
412            .data
413            .iter()
414            .filter(|entry| entry.output_enable() != oe_active)
415            .count();
416        assert_eq!(oe_blank_count, blank_count);
417    }
418
419    #[test]
420    fn format_sets_expected_row_addresses_for_all_rows() {
421        let mut fb = TestBuffer::new();
422        fb.format();
423
424        for plane_idx in 0..8 {
425            for slot in 0..16 {
426                let (_, addr) = slot_addresses::<16>(slot);
427                let row = &fb.planes[plane_idx][slot];
428                assert_eq!(row.address[map_index(0)].addr(), addr);
429                assert_eq!(row.address[map_index(1)].addr(), addr);
430                assert_eq!(row.address[map_index(2)].addr(), addr);
431                assert_eq!(row.address[map_index(3)].0, OE_BLANK);
432            }
433        }
434    }
435
436    #[test]
437    fn set_pixel_maps_top_half_bits_per_plane() {
438        let mut fb = TestBuffer::new();
439        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
440        fb.set_pixel(Point::new(2, 3), color);
441
442        for plane_idx in 0..8 {
443            let bit = plane_idx;
444            let entry = fb.planes[plane_idx][map_row_index::<16>(3)].data[map_index(2)];
445            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
446            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
447            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
448        }
449    }
450
451    #[test]
452    fn set_pixel_maps_bottom_half_bits_per_plane() {
453        let mut fb = TestBuffer::new();
454        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
455        fb.set_pixel(Point::new(4, 20), color);
456
457        for plane_idx in 0..8 {
458            let bit = plane_idx;
459            let entry = fb.planes[plane_idx][map_row_index::<16>(4)].data[map_index(4)];
460            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
461            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
462            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
463        }
464    }
465
466    #[test]
467    #[cfg(feature = "reverse-row-order")]
468    fn reverse_row_order_stores_rows_back_to_front() {
469        let mut fb = TestBuffer::new();
470
471        // Slot 0 is streamed first and renders the last panel row; the final
472        // slot renders panel row 0. The address bytes carry the slot's own
473        // row address.
474        assert_eq!(fb.planes[0][0].address[map_index(0)].addr(), 15);
475        assert_eq!(fb.planes[0][15].address[map_index(0)].addr(), 0);
476
477        // Logical row 0 maps to the last memory slot.
478        fb.set_pixel(Point::new(2, 0), Color::RED);
479        let col2 = map_index(2);
480        assert!(fb.planes[7][15].data[col2].red1());
481        assert!(!fb.planes[7][0].data[col2].red1());
482    }
483
484    #[test]
485    fn erase_clears_only_color_bits() {
486        let mut fb = TestBuffer::new();
487        let oe_before = fb.planes[0][map_row_index::<16>(0)].data[0].output_enable();
488        fb.set_pixel(Point::new(0, 0), Color::WHITE);
489        fb.erase();
490
491        for plane in &fb.planes {
492            for row in plane {
493                for entry in &row.data {
494                    assert!(!entry.red1());
495                    assert!(!entry.grn1());
496                    assert!(!entry.blu1());
497                    assert!(!entry.red2());
498                    assert!(!entry.grn2());
499                    assert!(!entry.blu2());
500                }
501            }
502        }
503
504        assert_eq!(
505            fb.planes[0][map_row_index::<16>(0)].data[0].output_enable(),
506            oe_before
507        );
508    }
509
510    #[test]
511    fn draw_target_iter_sets_pixels() {
512        let mut fb = TestBuffer::new();
513        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
514        let result = fb.draw_iter(pixels);
515        assert!(result.is_ok());
516
517        for plane_idx in 0..8 {
518            let bit = plane_idx;
519            let entry = fb.planes[plane_idx][map_row_index::<16>(1)].data[map_index(1)];
520            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
521            assert!(!entry.grn1());
522            assert!(!entry.blu1());
523        }
524    }
525
526    #[test]
527    fn set_pixel_ignores_out_of_bounds_and_negative() {
528        let mut fb = TestBuffer::new();
529        let before = fb.planes;
530        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
531        fb.set_pixel(Point::new(0, -1), Color::WHITE);
532        fb.set_pixel(Point::new(TEST_COLS as i32, 0), Color::WHITE);
533        fb.set_pixel(Point::new(0, 32), Color::WHITE);
534        assert_eq!(fb.planes, before);
535    }
536
537    #[test]
538    #[cfg(feature = "skip-black-pixels")]
539    fn test_skip_black_pixels_enabled() {
540        let mut fb = TestBuffer::new();
541
542        // Set a red pixel first
543        fb.set_pixel_internal(10, 5, Color::RED);
544
545        // Verify it's red in the first plane
546        let mapped_col_10 = map_index(10);
547        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
548        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
549        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
550
551        // Now set it to black - with skip-black-pixels enabled, this should be ignored
552        fb.set_pixel_internal(10, 5, Color::BLACK);
553
554        // The pixel should still be red (black write was skipped)
555        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
556        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
557        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
558    }
559
560    #[test]
561    #[cfg(not(feature = "skip-black-pixels"))]
562    fn test_skip_black_pixels_disabled() {
563        let mut fb = TestBuffer::new();
564
565        // Set a red pixel first
566        fb.set_pixel_internal(10, 5, Color::RED);
567
568        // Verify it's red in the first plane
569        let mapped_col_10 = map_index(10);
570        assert!(fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
571        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
572        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
573
574        // Now set it to black - with skip-black-pixels disabled, this should overwrite
575        fb.set_pixel_internal(10, 5, Color::BLACK);
576
577        // The pixel should now be black (all bits false)
578        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].red1());
579        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].grn1());
580        assert!(!fb.planes[0][map_row_index::<16>(5)].data[mapped_col_10].blu1());
581    }
582
583    #[test]
584    #[should_panic(expected = "NROWS must be within 1..=32")]
585    fn new_panics_for_too_many_rows() {
586        let _ = DmaFrameBuffer::<33, TEST_COLS, 8>::new();
587    }
588
589    #[test]
590    #[should_panic(expected = "PLANES must be within 1..=8")]
591    fn new_panics_for_too_many_planes() {
592        let _ = DmaFrameBuffer::<16, TEST_COLS, 9>::new();
593    }
594
595    #[test]
596    #[allow(deprecated)]
597    fn bcm_chunk_info_for_common_panel() {
598        assert_eq!(TestBuffer::bcm_chunk_count(), 8);
599        assert_eq!(
600            TestBuffer::bcm_chunk_bytes(),
601            16 * core::mem::size_of::<Row<TEST_COLS>>()
602        );
603    }
604
605    #[test]
606    fn origin_dimensions_match_panel_geometry() {
607        let fb = TestBuffer::new();
608        assert_eq!(fb.size(), Size::new(TEST_COLS as u32, 32));
609    }
610
611    #[test]
612    fn debug_impl_includes_shape_information() {
613        let fb = TestBuffer::new();
614        let s = format!("{fb:?}");
615        assert!(s.contains("DmaFrameBuffer"));
616        assert!(s.contains("plane_count"));
617        assert!(s.contains("plane_size"));
618    }
619
620    #[test]
621    fn row_format_sets_exactly_one_data_word_with_oe_low() {
622        const TEST_N: usize = TEST_COLS;
623        let mut row = Row::<TEST_N>::new();
624        row.format(9);
625
626        let oe_active = !cfg!(feature = "invert-oe");
627        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
628        let blank_count = TEST_N - active_count;
629        let oe_blank_indices: std::vec::Vec<_> = row
630            .data
631            .iter()
632            .enumerate()
633            .filter_map(|(i, entry)| (entry.output_enable() != oe_active).then_some(i))
634            .collect();
635        assert_eq!(oe_blank_indices.len(), blank_count);
636        assert!(oe_blank_indices.contains(&map_index(TEST_N - 1)));
637    }
638
639    #[test]
640    fn default_constructors_match_new() {
641        let row_default = Row::<TEST_COLS>::default();
642        let row_new = Row::<TEST_COLS>::new();
643        assert_eq!(row_default, row_new);
644
645        let fb_default = TestBuffer::default();
646        let fb_new = TestBuffer::new();
647        assert_eq!(fb_default.planes, fb_new.planes);
648    }
649
650    #[test]
651    fn framebuffer_operations_trait_delegates_correctly() {
652        let mut fb = TestBuffer::new();
653        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
654
655        assert!(fb.planes[0][map_row_index::<16>(5)].data[map_index(3)].grn1());
656
657        FrameBufferOperations::erase(&mut fb);
658        for plane in &fb.planes {
659            for row in plane {
660                for entry in &row.data {
661                    assert!(!entry.red1());
662                    assert!(!entry.grn1());
663                    assert!(!entry.blu1());
664                    assert!(!entry.red2());
665                    assert!(!entry.grn2());
666                    assert!(!entry.blu2());
667                }
668            }
669        }
670    }
671
672    #[test]
673    fn addr_table_entries_are_consistent() {
674        let table = make_addr_table();
675        for addr in 0..32u8 {
676            let row = &table[addr as usize];
677            // First two clocks: latch asserted with row address
678            assert!(row[map_index(0)].latch());
679            assert_eq!(row[map_index(0)].addr(), addr);
680            assert!(row[map_index(1)].latch());
681            assert_eq!(row[map_index(1)].addr(), addr);
682            // Third clock: latch released, address still driven
683            assert!(!row[map_index(2)].latch());
684            assert_eq!(row[map_index(2)].addr(), addr);
685            // Fourth clock: clear cycle, only OE_BLANK
686            assert!(!row[map_index(3)].latch());
687            assert_eq!(row[map_index(3)].0, OE_BLANK);
688        }
689        assert_eq!(table, ADDR_TABLE);
690    }
691
692    #[test]
693    fn test_blanking_delay() {
694        let mut row = Row::<TEST_COLS>::new();
695        row.format(5);
696
697        let oe_active = !cfg!(feature = "invert-oe");
698
699        // Trail blank: indices 0..TRAIL_BLANK_DELAY-1 (DMA start = physical right edge)
700        if TRAIL_BLANK_DELAY > 0 {
701            let trail_blank_idx = map_index(TRAIL_BLANK_DELAY - 1);
702            assert_eq!(row.data[trail_blank_idx].output_enable(), !oe_active);
703        }
704
705        let first_active_idx = map_index(TRAIL_BLANK_DELAY);
706        assert_eq!(row.data[first_active_idx].output_enable(), oe_active);
707
708        // Lead blank: indices before latch (DMA end = physical left edge)
709        let last_active_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 2);
710        assert_eq!(row.data[last_active_idx].output_enable(), oe_active);
711
712        let lead_blank_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
713        assert_eq!(row.data[lead_blank_idx].output_enable(), !oe_active);
714
715        let last_pixel_idx = map_index(TEST_COLS - 1);
716        assert_eq!(row.data[last_pixel_idx].output_enable(), !oe_active);
717    }
718
719    static STATIC_FB: TestBuffer = TestBuffer::new();
720
721    #[test]
722    fn test_static_construction_is_formatted() {
723        let runtime_fb = TestBuffer::new();
724
725        for (pi, plane) in STATIC_FB.planes.iter().enumerate() {
726            for (ri, row) in plane.iter().enumerate() {
727                assert_eq!(
728                    row.data, runtime_fb.planes[pi][ri].data,
729                    "static vs runtime data mismatch at plane {pi}, row {ri}"
730                );
731                assert_eq!(
732                    row.address, runtime_fb.planes[pi][ri].address,
733                    "static vs runtime address mismatch at plane {pi}, row {ri}"
734                );
735                assert_eq!(
736                    row.gap, runtime_fb.planes[pi][ri].gap,
737                    "static vs runtime gap mismatch at plane {pi}, row {ri}"
738                );
739            }
740        }
741    }
742
743    #[test]
744    fn test_format_reinitializes_at_runtime() {
745        let mut fb = TestBuffer::new();
746        fb.erase();
747        fb.format();
748
749        for (pi, plane) in fb.planes.iter().enumerate() {
750            for (ri, row) in plane.iter().enumerate() {
751                assert_eq!(
752                    row.data, STATIC_FB.planes[pi][ri].data,
753                    "re-formatted vs static mismatch at plane {pi}, row {ri}"
754                );
755            }
756        }
757    }
758
759    #[test]
760    fn bcm_segment_count_equals_planes() {
761        use crate::FrameBuffer;
762        let fb = TestBuffer::new();
763        assert_eq!(fb.bcm_segment_count(), 8);
764    }
765
766    #[test]
767    fn bcm_segments_have_correct_reps() {
768        use crate::FrameBuffer;
769        let fb = TestBuffer::new();
770        for i in 0..8 {
771            let seg = fb.bcm_segment(i);
772            let expected = if i == 0 { 1 } else { 1 << (i - 1) };
773            assert_eq!(seg.reps, expected, "wrong reps for segment {i}");
774        }
775    }
776
777    #[test]
778    fn bcm_segments_point_to_plane_data() {
779        use crate::FrameBuffer;
780        let fb = TestBuffer::new();
781        let plane_len = 16 * core::mem::size_of::<Row<TEST_COLS>>();
782        for i in 0..8 {
783            let seg = fb.bcm_segment(i);
784            let expected_ptr = fb.planes[i].as_ptr().cast::<u8>();
785            assert_eq!(seg.ptr, expected_ptr, "wrong ptr for segment {i}");
786            assert_eq!(seg.len, plane_len * (8 - i), "wrong len for segment {i}");
787        }
788    }
789
790    #[test]
791    fn bcm_segment_total_reps_is_halved_by_coalescing() {
792        use crate::FrameBuffer;
793        let fb = TestBuffer::new();
794        let total: usize = (0..fb.bcm_segment_count())
795            .map(|i| fb.bcm_segment(i).reps)
796            .sum();
797        assert_eq!(total, 1 << (8 - 1));
798    }
799
800    #[test]
801    fn bcm_segment_plane_coverage_matches_bcm_weights() {
802        use crate::FrameBuffer;
803        let fb = TestBuffer::new();
804        let plane_bytes = 16 * core::mem::size_of::<Row<TEST_COLS>>();
805
806        for plane in 0..8usize {
807            // Sum the reps of every segment that spans this plane: the
808            // segment for plane `first` covers `len / plane_bytes`
809            // consecutive planes starting at `first`.
810            let mut coverage = 0;
811            for first in 0..=plane {
812                let seg = fb.bcm_segment(first);
813                if first + seg.len / plane_bytes > plane {
814                    coverage += seg.reps;
815                }
816            }
817            assert_eq!(coverage, 1 << plane, "plane {plane} coverage wrong");
818        }
819    }
820
821    #[test]
822    #[should_panic(expected = "out of range")]
823    fn bcm_segment_panics_for_invalid_index() {
824        use crate::FrameBuffer;
825        let fb = TestBuffer::new();
826        let _ = fb.bcm_segment(8);
827    }
828
829    #[test]
830    fn bcm_sequence_matches_runtime_segments() {
831        use crate::FrameBuffer;
832        let fb = TestBuffer::new();
833        assert_eq!(
834            TestBuffer::BCM_SEGMENT_COUNT,
835            TestBuffer::BCM_SEQUENCE_LEN * TestBuffer::BCM_SEQUENCE_COUNT
836        );
837        assert_eq!(fb.bcm_segment_count(), TestBuffer::BCM_SEGMENT_COUNT);
838        assert_eq!(
839            fb.bcm_segments_per_group(),
840            TestBuffer::BCM_SEGMENTS_PER_GROUP
841        );
842        assert_eq!(
843            TestBuffer::BCM_SEQUENCE_LEN % TestBuffer::BCM_SEGMENTS_PER_GROUP,
844            0
845        );
846        for i in 0..TestBuffer::BCM_SEGMENT_COUNT {
847            let entry = TestBuffer::BCM_SEQUENCE[i % TestBuffer::BCM_SEQUENCE_LEN];
848            let seg = fb.bcm_segment(i);
849            assert_eq!((seg.len, seg.reps), (entry.len, entry.reps), "segment {i}");
850            assert!(!seg.ptr.is_null(), "segment {i} has null pointer");
851        }
852        for entry in &TestBuffer::BCM_SEQUENCE[TestBuffer::BCM_SEQUENCE_LEN..] {
853            assert_eq!(*entry, crate::BcmLenReps::ZERO, "padding must be zero");
854        }
855    }
856}