Skip to main content

hub75_framebuffer/bitplane/
latched.rs

1//! Bitplane framebuffer for an 8-bit latched HUB75 interface.
2//!
3//! This module provides a framebuffer that stores colour data as separate
4//! bit-planes rather than the threshold-based frames used by
5//! [`crate::latched::DmaFrameBuffer`]. Each plane holds one bit of every
6//! colour channel, giving `PLANES` planes total (typically 8 for full 8-bit
7//! colour). Row addressing is carried by four trailing `Address` bytes per
8//! row, identical to the non-bitplane latched layout.
9//!
10//! # Hardware Requirements
11//! Requires a parallel output peripheral capable of clocking 8 bits at a time,
12//! plus an external latch circuit to hold the row address and gate the pixel
13//! clock (same circuit as the non-bitplane latched variant).
14//!
15//! # HUB75 Signal Bit Mapping (8-bit words)
16//! Two distinct 8-bit words are streamed to the panel:
17//!
18//! 1. **Address / Timing (`Address`)** -- row-select and latch control.
19//! 2. **Pixel Data (`Entry`)** -- RGB bits for two sub-pixels plus OE/LAT
20//!    shadow bits.
21//!
22//! ```text
23//! Address word (row select & timing)
24//! ┌──7─┬──6──┬─5─-┬─4─-┬─3-─┬─2-─┬─1-─┬─0-─┐
25//! │ OE │ LAT │    │ E  │ D  │ C  │ B  │ A  │
26//! └────┴─────┴───-┴───-┴───-┴───-┴───-┴───-┘
27//! ```
28//! ```text
29//! Entry word (pixel data)
30//! ┌──7─┬──6──┬─5──┬─4──┬─3──┬─2──┬─1──┬─0──┐
31//! │ OE │ LAT │ B2 │ G2 │ R2 │ B1 │ G1 │ R1 │
32//! └────┴─────┴────┴────┴────┴────┴────┴────┘
33//! ```
34//!
35//! Bits 7-6 (OE/LAT) occupy the same positions in both words so the control
36//! lines stay valid throughout the DMA stream.
37//!
38//! # Bitplane BCM Rendering
39//! The framebuffer is organised into `PLANES` bit-planes. Plane 0 carries the
40//! MSB (bit 7) of each colour channel, plane 1 carries bit 6, and so on down
41//! to plane 7 which carries the LSB (bit 0).
42//!
43//! To produce correct brightness via Binary Code Modulation, configure the DMA
44//! descriptor chain so that each plane's data is output (scanned) a number of
45//! times equal to its bit-weight:
46//!
47//! ```text
48//! plane 0 (bit 7) → output 2^7 = 128 times
49//! plane 1 (bit 6) → output 2^6 =  64 times
50//! plane 2 (bit 5) → output 2^5 =  32 times
51//!   …
52//! plane 7 (bit 0) → output 2^0 =   1 time
53//! ```
54//!
55//! That is, each plane is scanned `2^(7 - plane_index)` times. The weighted
56//! repetition counts sum to 255, reproducing the full 8-bit intensity range.
57//! See <https://www.batsocks.co.uk/readme/art_bcm_1.htm> for background on
58//! BCM.
59//!
60//! # Memory Usage
61//! Memory scales linearly with `PLANES`: the buffer contains `PLANES` copies
62//! of the row data (one per bit-plane). Unlike the threshold-based
63//! [`crate::latched::DmaFrameBuffer`] whose frame count grows as
64//! `2^BITS - 1`, this layout uses exactly `PLANES` planes regardless of
65//! colour depth.
66//!
67//! Each row is `COLS` data bytes plus 4 address bytes, so total size is
68//! `PLANES * NROWS * (COLS + 4)` bytes.
69
70use core::convert::Infallible;
71
72use bitfield::bitfield;
73use embedded_graphics::pixelcolor::RgbColor;
74use embedded_graphics::prelude::{DrawTarget, OriginDimensions, Point, Size};
75
76use crate::Color;
77use crate::FrameBuffer;
78use crate::{FrameBufferOperations, MutableFrameBuffer};
79
80#[cfg(feature = "lead-blank-1")]
81const LEAD_BLANK_DELAY: usize = 1;
82#[cfg(feature = "lead-blank-2")]
83const LEAD_BLANK_DELAY: usize = 2;
84#[cfg(feature = "lead-blank-4")]
85const LEAD_BLANK_DELAY: usize = 4;
86#[cfg(feature = "lead-blank-8")]
87const LEAD_BLANK_DELAY: usize = 8;
88#[cfg(feature = "lead-blank-16")]
89const LEAD_BLANK_DELAY: usize = 16;
90
91#[cfg(not(any(
92    feature = "lead-blank-1",
93    feature = "lead-blank-2",
94    feature = "lead-blank-4",
95    feature = "lead-blank-8",
96    feature = "lead-blank-16"
97)))]
98const LEAD_BLANK_DELAY: usize = 0;
99
100#[cfg(feature = "trail-blank-1")]
101const TRAIL_BLANK_DELAY: usize = 1;
102#[cfg(feature = "trail-blank-2")]
103const TRAIL_BLANK_DELAY: usize = 2;
104#[cfg(feature = "trail-blank-4")]
105const TRAIL_BLANK_DELAY: usize = 4;
106#[cfg(feature = "trail-blank-8")]
107const TRAIL_BLANK_DELAY: usize = 8;
108#[cfg(feature = "trail-blank-16")]
109const TRAIL_BLANK_DELAY: usize = 16;
110
111#[cfg(not(any(
112    feature = "trail-blank-1",
113    feature = "trail-blank-2",
114    feature = "trail-blank-4",
115    feature = "trail-blank-8",
116    feature = "trail-blank-16"
117)))]
118const TRAIL_BLANK_DELAY: usize = 0;
119
120#[cfg(feature = "inter-row-blank-4")]
121const INTER_ROW_BLANK: usize = 4;
122#[cfg(feature = "inter-row-blank-8")]
123const INTER_ROW_BLANK: usize = 8;
124#[cfg(feature = "inter-row-blank-16")]
125const INTER_ROW_BLANK: usize = 16;
126#[cfg(feature = "inter-row-blank-32")]
127const INTER_ROW_BLANK: usize = 32;
128#[cfg(not(any(
129    feature = "inter-row-blank-4",
130    feature = "inter-row-blank-8",
131    feature = "inter-row-blank-16",
132    feature = "inter-row-blank-32"
133)))]
134const INTER_ROW_BLANK: usize = 0;
135
136#[cfg(not(feature = "invert-oe"))]
137const OE_ACTIVE: u8 = 0b1000_0000;
138#[cfg(not(feature = "invert-oe"))]
139const OE_BLANK: u8 = 0;
140
141#[cfg(feature = "invert-oe")]
142const OE_ACTIVE: u8 = 0;
143#[cfg(feature = "invert-oe")]
144const OE_BLANK: u8 = 0b1000_0000;
145
146bitfield! {
147    #[derive(Clone, Copy, Default, PartialEq, Eq)]
148    #[repr(transparent)]
149    pub(crate) struct Address(u8);
150    impl Debug;
151    pub(crate) output_enable, set_output_enable: 7;
152    pub(crate) latch, set_latch: 6;
153    pub(crate) addr, set_addr: 4, 0;
154}
155
156impl Address {
157    pub const fn new() -> Self {
158        Self(0)
159    }
160}
161
162bitfield! {
163    #[derive(Clone, Copy, Default, PartialEq)]
164    #[repr(transparent)]
165    pub(crate) struct Entry(u8);
166    impl Debug;
167    pub(crate) output_enable, set_output_enable: 7;
168    pub(crate) latch, set_latch: 6;
169    pub(crate) blu2, set_blu2: 5;
170    pub(crate) grn2, set_grn2: 4;
171    pub(crate) red2, set_red2: 3;
172    pub(crate) blu1, set_blu1: 2;
173    pub(crate) grn1, set_grn1: 1;
174    pub(crate) red1, set_red1: 0;
175}
176
177impl Entry {
178    pub const fn new() -> Self {
179        Self(0)
180    }
181
182    const COLOR0_MASK: u8 = 0b0000_0111;
183    const COLOR1_MASK: u8 = 0b0011_1000;
184
185    #[inline]
186    fn set_color0_bits(&mut self, bits: u8) {
187        self.0 = (self.0 & !Self::COLOR0_MASK) | (bits & Self::COLOR0_MASK);
188    }
189
190    #[inline]
191    fn set_color1_bits(&mut self, bits: u8) {
192        self.0 = (self.0 & !Self::COLOR1_MASK) | ((bits << 3) & Self::COLOR1_MASK);
193    }
194}
195
196#[derive(Clone, Copy, PartialEq, Debug)]
197#[repr(C)]
198/// A single BCM row payload for 8-bit latched output.
199///
200/// Each row contains color-stream data for `COLS` pixels followed by four
201/// address/control bytes that clock the row address into the external latch.
202pub struct Row<const COLS: usize> {
203    pub(crate) data: [Entry; COLS],
204    pub(crate) address: [Address; 4],
205    pub(crate) gap: [Entry; INTER_ROW_BLANK],
206}
207
208#[inline]
209const fn map_index(index: usize) -> usize {
210    #[cfg(feature = "esp32-ordering")]
211    {
212        index ^ 2
213    }
214    #[cfg(not(feature = "esp32-ordering"))]
215    {
216        index
217    }
218}
219
220const fn make_addr_table() -> [[Address; 4]; 32] {
221    let mut tbl = [[Address::new(); 4]; 32];
222    let mut addr = 0;
223    while addr < 32 {
224        tbl[addr][map_index(0)].0 = OE_BLANK | 1u8 << 6 | addr as u8;
225        tbl[addr][map_index(1)].0 = OE_BLANK | 1u8 << 6 | addr as u8;
226        tbl[addr][map_index(2)].0 = OE_BLANK | addr as u8;
227        tbl[addr][map_index(3)].0 = OE_BLANK;
228        addr += 1;
229    }
230    tbl
231}
232
233const ADDR_TABLE: [[Address; 4]; 32] = make_addr_table();
234
235#[allow(clippy::absurd_extreme_comparisons)]
236const fn make_data_template<const COLS: usize>() -> [Entry; COLS] {
237    let mut data = [Entry::new(); COLS];
238    let mut i = 0;
239    while i < COLS {
240        let mapped_i = map_index(i);
241        data[mapped_i].0 =
242            if i >= TRAIL_BLANK_DELAY && i < COLS.saturating_sub(LEAD_BLANK_DELAY + 1) {
243                OE_ACTIVE
244            } else {
245                OE_BLANK
246            };
247        i += 1;
248    }
249    data
250}
251
252impl<const COLS: usize> Row<COLS> {
253    /// Creates a zero-initialized row.
254    ///
255    /// Call [`Self::format`] before first use to populate row address/control
256    /// metadata.
257    #[must_use]
258    pub const fn new() -> Self {
259        Self {
260            data: [Entry::new(); COLS],
261            address: [Address::new(); 4],
262            gap: [Entry::new(); INTER_ROW_BLANK],
263        }
264    }
265
266    /// Formats this row for the provided multiplexed row address.
267    ///
268    /// This sets the trailing address bytes and initializes output-enable/latch
269    /// bits in the pixel stream template.
270    ///
271    /// # Panics
272    ///
273    /// Panics if `addr` is out of range for the address lookup table.
274    #[inline]
275    pub const fn format(&mut self, addr: u8) {
276        assert!((addr as usize) < ADDR_TABLE.len());
277        let src_addr = ADDR_TABLE[addr as usize];
278        self.address[0] = src_addr[0];
279        self.address[1] = src_addr[1];
280        self.address[2] = src_addr[2];
281        self.address[3] = src_addr[3];
282
283        let data_template = make_data_template::<COLS>();
284        let mut i = 0;
285        while i < COLS {
286            self.data[i] = data_template[i];
287            i += 1;
288        }
289
290        let mut i = 0;
291        // INTER_ROW_BLANK is 0 unless an inter-row-blank-* feature is enabled.
292        #[allow(clippy::absurd_extreme_comparisons)]
293        while i < INTER_ROW_BLANK {
294            self.gap[i].0 = OE_BLANK;
295            i += 1;
296        }
297    }
298}
299
300impl<const COLS: usize> Default for Row<COLS> {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306/// The entire BCM Frame Buffer (Contiguous Memory)
307#[derive(Copy, Clone)]
308#[repr(C)]
309pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
310    pub(crate) planes: [[Row<COLS>; NROWS]; PLANES],
311}
312
313impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
314    DmaFrameBuffer<NROWS, COLS, PLANES>
315{
316    /// Creates a new frame buffer.
317    #[must_use]
318    pub const fn new() -> Self {
319        let mut instance = Self {
320            planes: [[Row::new(); NROWS]; PLANES],
321        };
322        instance.format();
323        instance
324    }
325
326    /// Returns the number of BCM chunks (one per bit-plane).
327    #[must_use]
328    pub const fn bcm_chunk_count() -> usize {
329        PLANES
330    }
331
332    /// Returns the byte size of one BCM chunk (a single bit-plane).
333    #[must_use]
334    pub const fn bcm_chunk_bytes() -> usize {
335        NROWS * core::mem::size_of::<Row<COLS>>()
336    }
337
338    /// Returns the number of rows per plane for row-based BCM.
339    #[must_use]
340    pub const fn bcm_row_count() -> usize {
341        NROWS
342    }
343
344    /// Formats the frame buffer with row addresses and control bits.
345    #[inline]
346    pub const fn format(&mut self) {
347        let mut p = 0;
348        while p < PLANES {
349            let mut row_idx = 0;
350            while row_idx < NROWS {
351                self.planes[p][row_idx].format(row_idx as u8);
352                row_idx += 1;
353            }
354            p += 1;
355        }
356    }
357
358    /// Erase pixel colors while preserving row control data.
359    #[inline]
360    pub fn erase(&mut self) {
361        const MASK: u8 = !0b0011_1111;
362        for plane in &mut self.planes {
363            for row in plane {
364                for entry in &mut row.data {
365                    entry.0 &= MASK;
366                }
367            }
368        }
369    }
370
371    /// Set a pixel in the framebuffer.
372    #[inline]
373    pub fn set_pixel(&mut self, p: Point, color: Color) {
374        if p.x < 0 || p.y < 0 {
375            return;
376        }
377        self.set_pixel_internal(p.x as usize, p.y as usize, color);
378    }
379
380    #[inline]
381    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
382        if x >= COLS || y >= NROWS * 2 {
383            return;
384        }
385
386        let row_idx = if y < NROWS { y } else { y - NROWS };
387        let is_top = y < NROWS;
388        let red = color.r();
389        let green = color.g();
390        let blue = color.b();
391
392        for plane_idx in 0..PLANES {
393            let bit = 7_u32.saturating_sub(plane_idx as u32);
394            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
395                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
396                | u8::from(((red >> bit) & 1) != 0);
397            let col_idx = map_index(x);
398            let entry = &mut self.planes[plane_idx][row_idx].data[col_idx];
399            if is_top {
400                entry.set_color0_bits(bits);
401            } else {
402                entry.set_color1_bits(bits);
403            }
404        }
405    }
406}
407
408impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
409    for DmaFrameBuffer<NROWS, COLS, PLANES>
410{
411    fn default() -> Self {
412        Self::new()
413    }
414}
415
416impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
417    for DmaFrameBuffer<NROWS, COLS, PLANES>
418{
419    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
420        f.debug_struct("DmaFrameBuffer")
421            .field("size", &core::mem::size_of_val(&self.planes))
422            .field("plane_count", &self.planes.len())
423            .field("plane_size", &core::mem::size_of_val(&self.planes[0]))
424            .finish()
425    }
426}
427
428#[cfg(feature = "defmt")]
429impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
430    for DmaFrameBuffer<NROWS, COLS, PLANES>
431{
432    fn format(&self, f: defmt::Formatter) {
433        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
434        defmt::write!(f, " size: {}", core::mem::size_of_val(&self.planes));
435        defmt::write!(
436            f,
437            " plane_size: {}",
438            core::mem::size_of_val(&self.planes[0])
439        );
440    }
441}
442
443impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
444    for DmaFrameBuffer<NROWS, COLS, PLANES>
445{
446    type Word = u8;
447
448    fn plane_count(&self) -> usize {
449        PLANES
450    }
451
452    fn plane_ptr_len(&self, plane_idx: usize) -> (*const u8, usize) {
453        assert!(
454            plane_idx < PLANES,
455            "plane_idx {plane_idx} out of range for {PLANES} planes"
456        );
457        let ptr = self.planes[plane_idx].as_ptr().cast::<u8>();
458        let len = NROWS * core::mem::size_of::<Row<COLS>>();
459        (ptr, len)
460    }
461}
462
463impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
464    for DmaFrameBuffer<NROWS, COLS, PLANES>
465{
466    #[inline]
467    fn erase(&mut self) {
468        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
469    }
470
471    #[inline]
472    fn set_pixel(&mut self, p: Point, color: Color) {
473        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
474    }
475}
476
477impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
478    for DmaFrameBuffer<NROWS, COLS, PLANES>
479{
480}
481
482impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
483    for DmaFrameBuffer<NROWS, COLS, PLANES>
484{
485    fn size(&self) -> Size {
486        Size::new(COLS as u32, (NROWS * 2) as u32)
487    }
488}
489
490impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
491    for DmaFrameBuffer<NROWS, COLS, PLANES>
492{
493    type Color = Color;
494    type Error = Infallible;
495
496    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
497    where
498        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
499    {
500        for pixel in pixels {
501            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
502        }
503        Ok(())
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    extern crate std;
510
511    use super::*;
512    use embedded_graphics::prelude::*;
513    use std::format;
514
515    type TestBuffer = DmaFrameBuffer<16, 64, 8>;
516
517    #[test]
518    fn row_format_sets_address_and_control_bits() {
519        const TEST_N: usize = 64;
520        let mut row = Row::<TEST_N>::new();
521        row.format(5);
522        let latch_count = row.address.iter().filter(|a| a.latch()).count();
523        assert_eq!(latch_count, 2);
524        assert_eq!(row.address[map_index(0)].addr(), 5);
525        assert_eq!(row.address[map_index(1)].addr(), 5);
526        assert_eq!(row.address[map_index(2)].addr(), 5);
527        assert_eq!(row.address[map_index(3)].0, OE_BLANK);
528        let oe_active = !cfg!(feature = "invert-oe");
529        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
530        let blank_count = TEST_N - active_count;
531        let oe_blank_count = row
532            .data
533            .iter()
534            .filter(|entry| entry.output_enable() != oe_active)
535            .count();
536        assert_eq!(oe_blank_count, blank_count);
537    }
538
539    #[test]
540    fn format_sets_expected_row_addresses_for_all_rows() {
541        let mut fb = TestBuffer::new();
542        fb.format();
543
544        for plane_idx in 0..8 {
545            for row_idx in 0..16 {
546                let row = &fb.planes[plane_idx][row_idx];
547                assert_eq!(row.address[map_index(0)].addr(), row_idx as u8);
548                assert_eq!(row.address[map_index(1)].addr(), row_idx as u8);
549                assert_eq!(row.address[map_index(2)].addr(), row_idx as u8);
550                assert_eq!(row.address[map_index(3)].0, OE_BLANK);
551            }
552        }
553    }
554
555    #[test]
556    fn set_pixel_maps_top_half_bits_per_plane() {
557        let mut fb = TestBuffer::new();
558        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
559        fb.set_pixel(Point::new(2, 3), color);
560
561        for plane_idx in 0..8 {
562            let bit = 7 - plane_idx;
563            let entry = fb.planes[plane_idx][3].data[map_index(2)];
564            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
565            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
566            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
567        }
568    }
569
570    #[test]
571    fn set_pixel_maps_bottom_half_bits_per_plane() {
572        let mut fb = TestBuffer::new();
573        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
574        fb.set_pixel(Point::new(4, 20), color);
575
576        for plane_idx in 0..8 {
577            let bit = 7 - plane_idx;
578            let entry = fb.planes[plane_idx][4].data[map_index(4)];
579            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
580            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
581            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
582        }
583    }
584
585    #[test]
586    fn erase_clears_only_color_bits() {
587        let mut fb = TestBuffer::new();
588        let oe_before = fb.planes[0][0].data[0].output_enable();
589        fb.set_pixel(Point::new(0, 0), Color::WHITE);
590        fb.erase();
591
592        for plane in &fb.planes {
593            for row in plane {
594                for entry in &row.data {
595                    assert!(!entry.red1());
596                    assert!(!entry.grn1());
597                    assert!(!entry.blu1());
598                    assert!(!entry.red2());
599                    assert!(!entry.grn2());
600                    assert!(!entry.blu2());
601                }
602            }
603        }
604
605        assert_eq!(fb.planes[0][0].data[0].output_enable(), oe_before);
606    }
607
608    #[test]
609    fn draw_target_iter_sets_pixels() {
610        let mut fb = TestBuffer::new();
611        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
612        let result = fb.draw_iter(pixels);
613        assert!(result.is_ok());
614
615        for plane_idx in 0..8 {
616            let bit = 7 - plane_idx;
617            let entry = fb.planes[plane_idx][1].data[map_index(1)];
618            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
619            assert!(!entry.grn1());
620            assert!(!entry.blu1());
621        }
622    }
623
624    #[test]
625    fn set_pixel_ignores_out_of_bounds_and_negative() {
626        let mut fb = TestBuffer::new();
627        let before = fb.planes;
628        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
629        fb.set_pixel(Point::new(0, -1), Color::WHITE);
630        fb.set_pixel(Point::new(64, 0), Color::WHITE);
631        fb.set_pixel(Point::new(0, 32), Color::WHITE);
632        assert_eq!(fb.planes, before);
633    }
634
635    #[test]
636    fn bcm_chunk_info_for_common_panel() {
637        assert_eq!(TestBuffer::bcm_chunk_count(), 8);
638        assert_eq!(
639            TestBuffer::bcm_chunk_bytes(),
640            16 * core::mem::size_of::<Row<64>>()
641        );
642    }
643
644    #[test]
645    fn frame_buffer_trait_accessors_report_expected_values() {
646        let fb = TestBuffer::new();
647        let as_trait: &dyn FrameBuffer<Word = u8> = &fb;
648        assert_eq!(as_trait.get_word_size(), crate::WordSize::Eight);
649        assert_eq!(as_trait.plane_count(), 8);
650
651        let (ptr, len) = as_trait.plane_ptr_len(0);
652        assert_eq!(len, 16 * core::mem::size_of::<Row<64>>());
653        assert_eq!(ptr, fb.planes[0].as_ptr().cast::<u8>());
654    }
655
656    #[test]
657    #[should_panic(expected = "out of range")]
658    fn plane_ptr_len_panics_for_invalid_plane() {
659        let fb = TestBuffer::new();
660        let _ = fb.plane_ptr_len(8);
661    }
662
663    #[test]
664    fn origin_dimensions_match_panel_geometry() {
665        let fb = TestBuffer::new();
666        assert_eq!(fb.size(), Size::new(64, 32));
667    }
668
669    #[test]
670    fn debug_impl_includes_shape_information() {
671        let fb = TestBuffer::new();
672        let s = format!("{fb:?}");
673        assert!(s.contains("DmaFrameBuffer"));
674        assert!(s.contains("plane_count"));
675        assert!(s.contains("plane_size"));
676    }
677
678    #[test]
679    fn row_format_sets_exactly_one_data_word_with_oe_low() {
680        const TEST_N: usize = 64;
681        let mut row = Row::<TEST_N>::new();
682        row.format(9);
683
684        let oe_active = !cfg!(feature = "invert-oe");
685        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
686        let blank_count = TEST_N - active_count;
687        let oe_blank_indices: std::vec::Vec<_> = row
688            .data
689            .iter()
690            .enumerate()
691            .filter_map(|(i, entry)| (entry.output_enable() != oe_active).then_some(i))
692            .collect();
693        assert_eq!(oe_blank_indices.len(), blank_count);
694        assert!(oe_blank_indices.contains(&map_index(TEST_N - 1)));
695    }
696
697    #[test]
698    fn default_constructors_match_new() {
699        let row_default = Row::<64>::default();
700        let row_new = Row::<64>::new();
701        assert_eq!(row_default, row_new);
702
703        let fb_default = TestBuffer::default();
704        let fb_new = TestBuffer::new();
705        assert_eq!(fb_default.planes, fb_new.planes);
706    }
707
708    #[test]
709    fn framebuffer_operations_trait_delegates_correctly() {
710        let mut fb = TestBuffer::new();
711        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
712
713        assert!(fb.planes[0][5].data[map_index(3)].grn1());
714
715        FrameBufferOperations::erase(&mut fb);
716        for plane in &fb.planes {
717            for row in plane {
718                for entry in &row.data {
719                    assert!(!entry.red1());
720                    assert!(!entry.grn1());
721                    assert!(!entry.blu1());
722                    assert!(!entry.red2());
723                    assert!(!entry.grn2());
724                    assert!(!entry.blu2());
725                }
726            }
727        }
728    }
729
730    #[test]
731    fn addr_table_entries_are_consistent() {
732        let table = make_addr_table();
733        for addr in 0..32u8 {
734            let row = &table[addr as usize];
735            // First two clocks: latch asserted with row address
736            assert!(row[map_index(0)].latch());
737            assert_eq!(row[map_index(0)].addr(), addr);
738            assert!(row[map_index(1)].latch());
739            assert_eq!(row[map_index(1)].addr(), addr);
740            // Third clock: latch released, address still driven
741            assert!(!row[map_index(2)].latch());
742            assert_eq!(row[map_index(2)].addr(), addr);
743            // Fourth clock: clear cycle, only OE_BLANK
744            assert!(!row[map_index(3)].latch());
745            assert_eq!(row[map_index(3)].0, OE_BLANK);
746        }
747        assert_eq!(table, ADDR_TABLE);
748    }
749
750    #[test]
751    fn test_blanking_delay() {
752        let mut row = Row::<64>::new();
753        row.format(5);
754
755        let oe_active = !cfg!(feature = "invert-oe");
756
757        // Trail blank: indices 0..TRAIL_BLANK_DELAY-1 (DMA start = physical right edge)
758        if TRAIL_BLANK_DELAY > 0 {
759            let trail_blank_idx = map_index(TRAIL_BLANK_DELAY - 1);
760            assert_eq!(row.data[trail_blank_idx].output_enable(), !oe_active);
761        }
762
763        let first_active_idx = map_index(TRAIL_BLANK_DELAY);
764        assert_eq!(row.data[first_active_idx].output_enable(), oe_active);
765
766        // Lead blank: indices before latch (DMA end = physical left edge)
767        let last_active_idx = map_index(64 - LEAD_BLANK_DELAY - 2);
768        assert_eq!(row.data[last_active_idx].output_enable(), oe_active);
769
770        let lead_blank_idx = map_index(64 - LEAD_BLANK_DELAY - 1);
771        assert_eq!(row.data[lead_blank_idx].output_enable(), !oe_active);
772
773        let last_pixel_idx = map_index(63);
774        assert_eq!(row.data[last_pixel_idx].output_enable(), !oe_active);
775    }
776
777    static STATIC_FB: TestBuffer = TestBuffer::new();
778
779    #[test]
780    fn test_static_construction_is_formatted() {
781        let runtime_fb = TestBuffer::new();
782
783        for (pi, plane) in STATIC_FB.planes.iter().enumerate() {
784            for (ri, row) in plane.iter().enumerate() {
785                assert_eq!(
786                    row.data, runtime_fb.planes[pi][ri].data,
787                    "static vs runtime data mismatch at plane {pi}, row {ri}"
788                );
789                assert_eq!(
790                    row.address, runtime_fb.planes[pi][ri].address,
791                    "static vs runtime address mismatch at plane {pi}, row {ri}"
792                );
793                assert_eq!(
794                    row.gap, runtime_fb.planes[pi][ri].gap,
795                    "static vs runtime gap mismatch at plane {pi}, row {ri}"
796                );
797            }
798        }
799    }
800
801    #[test]
802    fn test_format_reinitializes_at_runtime() {
803        let mut fb = TestBuffer::new();
804        fb.erase();
805        fb.format();
806
807        for (pi, plane) in fb.planes.iter().enumerate() {
808            for (ri, row) in plane.iter().enumerate() {
809                assert_eq!(
810                    row.data, STATIC_FB.planes[pi][ri].data,
811                    "re-formatted vs static mismatch at plane {pi}, row {ri}"
812                );
813            }
814        }
815    }
816}