Skip to main content

hub75_framebuffer/bitplane/
plain.rs

1//! Bitplane framebuffer for a 16-bit plain 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::plain::DmaFrameBuffer`]. Each plane holds one bit of every colour
6//! channel, giving `PLANES` planes total (typically 8 for full 8-bit colour).
7//! Row addressing, latch, OE, and colour data are all packed into each 16-bit
8//! word -- no external latch circuit is required.
9//!
10//! # Hardware Requirements
11//! Requires a parallel output peripheral capable of clocking 16 bits (though
12//! only 15 are actually used) at a time. The data is structured to directly
13//! match the HUB75 connector signals, so no external latch or address-decode
14//! hardware is needed.
15//!
16//! # HUB75 Signal Bit Mapping
17//! Each 16-bit `Entry` represents the logic levels driven onto the HUB75 bus
18//! during a single pixel-clock cycle:
19//!
20//! ```text
21//! 15 ─ (spare)
22//! 14 ─ B2       Blue  – lower half of the panel
23//! 13 ─ G2       Green – lower half of the panel
24//! 12 ─ R2       Red   – lower half of the panel
25//! 11 ─ B1       Blue  – upper half of the panel
26//! 10 ─ G1       Green – upper half of the panel
27//!  9 ─ R1       Red   – upper half of the panel
28//!  8 ─ OE       Output-Enable / Blank
29//!  7 ─ (spare)
30//!  6 ─ (spare)
31//!  5 ─ LAT      Latch / STB
32//! 4-0 ─ A..E    Row address lines
33//! ```
34//!
35//! The pixel clock is generated by the peripheral that owns the DMA stream and
36//! is not part of the 16-bit word stored in the framebuffer.
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::plain::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` 16-bit entries, so total size is
68//! `PLANES * NROWS * COLS * 2` 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// Default to 1 if no lead blank delay feature is enabled
92#[cfg(not(any(
93    feature = "lead-blank-1",
94    feature = "lead-blank-2",
95    feature = "lead-blank-4",
96    feature = "lead-blank-8",
97    feature = "lead-blank-16"
98)))]
99const LEAD_BLANK_DELAY: usize = 1;
100
101#[cfg(feature = "trail-blank-1")]
102const TRAIL_BLANK_DELAY: usize = 1;
103#[cfg(feature = "trail-blank-2")]
104const TRAIL_BLANK_DELAY: usize = 2;
105#[cfg(feature = "trail-blank-4")]
106const TRAIL_BLANK_DELAY: usize = 4;
107#[cfg(feature = "trail-blank-8")]
108const TRAIL_BLANK_DELAY: usize = 8;
109#[cfg(feature = "trail-blank-16")]
110const TRAIL_BLANK_DELAY: usize = 16;
111
112// Default to 1 if no trail blank delay feature is enabled
113#[cfg(not(any(
114    feature = "trail-blank-1",
115    feature = "trail-blank-2",
116    feature = "trail-blank-4",
117    feature = "trail-blank-8",
118    feature = "trail-blank-16"
119)))]
120const TRAIL_BLANK_DELAY: usize = 1;
121
122#[cfg(feature = "inter-row-blank-4")]
123const INTER_ROW_BLANK: usize = 4;
124#[cfg(feature = "inter-row-blank-8")]
125const INTER_ROW_BLANK: usize = 8;
126#[cfg(feature = "inter-row-blank-16")]
127const INTER_ROW_BLANK: usize = 16;
128#[cfg(feature = "inter-row-blank-32")]
129const INTER_ROW_BLANK: usize = 32;
130#[cfg(not(any(
131    feature = "inter-row-blank-4",
132    feature = "inter-row-blank-8",
133    feature = "inter-row-blank-16",
134    feature = "inter-row-blank-32"
135)))]
136const INTER_ROW_BLANK: usize = 0;
137
138#[cfg(not(feature = "invert-oe"))]
139const OE_ACTIVE: u16 = 0b1_0000_0000;
140#[cfg(not(feature = "invert-oe"))]
141const OE_BLANK: u16 = 0;
142
143#[cfg(feature = "invert-oe")]
144const OE_ACTIVE: u16 = 0;
145#[cfg(feature = "invert-oe")]
146const OE_BLANK: u16 = 0b1_0000_0000;
147
148#[inline]
149const fn map_index(i: usize) -> usize {
150    #[cfg(feature = "esp32-ordering")]
151    {
152        i ^ 1
153    }
154    #[cfg(not(feature = "esp32-ordering"))]
155    {
156        i
157    }
158}
159
160#[inline]
161const fn make_data_template<const COLS: usize>(addr: u8) -> [Entry; COLS] {
162    let mut data = [Entry::new(); COLS];
163    let mut i = 0;
164
165    while i < COLS {
166        let mut entry = Entry::new();
167        entry.0 = addr as u16 | OE_BLANK;
168
169        if i == COLS - 1 {
170            entry.0 |= 0b0010_0000; // latch
171        } else if i >= TRAIL_BLANK_DELAY && i < COLS.saturating_sub(LEAD_BLANK_DELAY + 1) {
172            entry.0 = (entry.0 & !0b1_0000_0000) | OE_ACTIVE;
173        }
174
175        data[map_index(i)] = entry;
176        i += 1;
177    }
178
179    data
180}
181
182bitfield! {
183    #[derive(Clone, Copy, Default, PartialEq)]
184    #[repr(transparent)]
185    pub(crate) struct Entry(u16);
186    pub(crate) dummy2, set_dummy2: 15;
187    pub(crate) blu2, set_blu2: 14;
188    pub(crate) grn2, set_grn2: 13;
189    pub(crate) red2, set_red2: 12;
190    pub(crate) blu1, set_blu1: 11;
191    pub(crate) grn1, set_grn1: 10;
192    pub(crate) red1, set_red1: 9;
193    pub(crate) output_enable, set_output_enable: 8;
194    pub(crate) dummy1, set_dummy1: 7;
195    pub(crate) dummy0, set_dummy0: 6;
196    pub(crate) latch, set_latch: 5;
197    pub(crate) addr, set_addr: 4, 0;
198}
199
200impl core::fmt::Debug for Entry {
201    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
202        f.debug_tuple("Entry")
203            .field(&format_args!("{:#x}", self.0))
204            .finish()
205    }
206}
207
208impl Entry {
209    const fn new() -> Self {
210        Self(OE_BLANK)
211    }
212
213    const COLOR0_MASK: u16 = 0b0000_1110_0000_0000; // bits 9-11: R1, G1, B1
214    const COLOR1_MASK: u16 = 0b0111_0000_0000_0000; // bits 12-14: R2, G2, B2
215
216    #[inline]
217    fn set_color0_bits(&mut self, bits: u8) {
218        let bits16 = u16::from(bits) << 9;
219        self.0 = (self.0 & !Self::COLOR0_MASK) | (bits16 & Self::COLOR0_MASK);
220    }
221
222    #[inline]
223    fn set_color1_bits(&mut self, bits: u8) {
224        let bits16 = u16::from(bits) << 12;
225        self.0 = (self.0 & !Self::COLOR1_MASK) | (bits16 & Self::COLOR1_MASK);
226    }
227}
228
229#[derive(Clone, Copy, PartialEq, Debug)]
230#[repr(C)]
231/// A single BCM row payload for 16-bit plain output.
232///
233/// Row addressing, latch, OE, and pixel colour data are all encoded into the
234/// 16-bit `Entry` words -- no separate address bytes are needed.
235pub struct Row<const COLS: usize> {
236    pub(crate) data: [Entry; COLS],
237    pub(crate) gap: [Entry; INTER_ROW_BLANK],
238}
239
240impl<const COLS: usize> Row<COLS> {
241    /// Creates a zero-initialized row.
242    ///
243    /// Call [`Self::format`] before first use to populate row control metadata.
244    #[must_use]
245    pub const fn new() -> Self {
246        Self {
247            data: [Entry::new(); COLS],
248            gap: [Entry::new(); INTER_ROW_BLANK],
249        }
250    }
251
252    /// Formats this row for the provided multiplexed row address.
253    ///
254    /// Sets up blanking delay, output-enable, latch, and address bits in the
255    /// pixel stream template.
256    #[inline]
257    pub const fn format(&mut self, prev_addr: u8) {
258        let template = make_data_template::<COLS>(prev_addr);
259        let mut i = 0;
260        while i < COLS {
261            self.data[i] = template[i];
262            i += 1;
263        }
264
265        let gap_val = (prev_addr as u16) | OE_BLANK;
266        let mut i = 0;
267        // INTER_ROW_BLANK is 0 unless an inter-row-blank-* feature is enabled.
268        #[allow(clippy::absurd_extreme_comparisons)]
269        while i < INTER_ROW_BLANK {
270            self.gap[i].0 = gap_val;
271            i += 1;
272        }
273    }
274}
275
276impl<const COLS: usize> Default for Row<COLS> {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282/// A single bit-plane's DMA payload: all rows for the plane, optionally
283/// followed by a tail word (see the `tail-closes-latch` feature).
284#[derive(Clone, Copy, PartialEq, Debug)]
285#[repr(C)]
286pub struct PlaneData<const NROWS: usize, const COLS: usize> {
287    pub(crate) rows: [Row<COLS>; NROWS],
288    // Extra word that parks LATCH=0 and OE=BLANK on the GPIO pins after the
289    // last clock edge, preventing the latch from staying asserted between
290    // DMA descriptor loops.  On an esp32 the words are also re-ordered (swapped) so
291    // we need a padding word.
292    #[cfg(all(feature = "tail-closes-latch", feature = "esp32-ordering"))]
293    pub(crate) padding: Entry,
294    #[cfg(feature = "tail-closes-latch")]
295    pub(crate) tail: Entry,
296}
297
298impl<const NROWS: usize, const COLS: usize> PlaneData<NROWS, COLS> {
299    const fn new() -> Self {
300        Self {
301            rows: [Row::new(); NROWS],
302            #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
303            padding: Entry::new(),
304            #[cfg(feature = "tail-closes-latch")]
305            tail: Entry::new(),
306        }
307    }
308}
309
310/// The entire BCM Frame Buffer (per-plane storage).
311#[derive(Copy, Clone)]
312#[repr(C)]
313pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
314    pub(crate) planes: [PlaneData<NROWS, COLS>; PLANES],
315}
316
317impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
318    DmaFrameBuffer<NROWS, COLS, PLANES>
319{
320    /// Creates a new frame buffer, pre-formatted and ready for use.
321    #[must_use]
322    pub const fn new() -> Self {
323        let mut instance = Self {
324            planes: [PlaneData::new(); PLANES],
325        };
326        instance.format();
327        instance
328    }
329
330    /// Returns the number of BCM chunks (one per bit-plane).
331    #[must_use]
332    pub const fn bcm_chunk_count() -> usize {
333        PLANES
334    }
335
336    /// Returns the byte size of one BCM chunk (a single bit-plane including tail word).
337    #[must_use]
338    pub const fn bcm_chunk_bytes() -> usize {
339        core::mem::size_of::<PlaneData<NROWS, COLS>>()
340    }
341
342    /// Formats the frame buffer with row addresses and control bits.
343    #[inline]
344    pub const fn format(&mut self) {
345        let mut p = 0;
346        while p < PLANES {
347            let mut row_idx = 0;
348            while row_idx < NROWS {
349                let prev_addr = if row_idx == 0 {
350                    NROWS as u8 - 1
351                } else {
352                    row_idx as u8 - 1
353                };
354                self.planes[p].rows[row_idx].format(prev_addr);
355                row_idx += 1;
356            }
357            #[cfg(feature = "tail-closes-latch")]
358            {
359                self.planes[p].tail.0 = 0x1f | OE_BLANK;
360            }
361            #[cfg(all(feature = "esp32-ordering", feature = "tail-closes-latch"))]
362            {
363                self.planes[p].padding.0 = 0x1f | OE_BLANK;
364            }
365            p += 1;
366        }
367    }
368
369    /// Erase pixel colors while preserving row control data.
370    #[inline]
371    pub fn erase(&mut self) {
372        const MASK: u16 = !0b0111_1110_0000_0000; // clear bits 9-14 (R1,G1,B1,R2,G2,B2)
373        for plane in &mut self.planes {
374            for row in &mut plane.rows {
375                for entry in &mut row.data {
376                    entry.0 &= MASK;
377                }
378            }
379        }
380    }
381
382    /// Set a pixel in the framebuffer.
383    #[inline]
384    pub fn set_pixel(&mut self, p: Point, color: Color) {
385        if p.x < 0 || p.y < 0 {
386            return;
387        }
388        self.set_pixel_internal(p.x as usize, p.y as usize, color);
389    }
390
391    #[inline]
392    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
393        if x >= COLS || y >= NROWS * 2 {
394            return;
395        }
396
397        let row_idx = if y < NROWS { y } else { y - NROWS };
398        let is_top = y < NROWS;
399        let red = color.r();
400        let green = color.g();
401        let blue = color.b();
402
403        for plane_idx in 0..PLANES {
404            let bit = 7_u32.saturating_sub(plane_idx as u32);
405            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
406                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
407                | u8::from(((red >> bit) & 1) != 0);
408            let col_idx = map_index(x);
409            let entry = &mut self.planes[plane_idx].rows[row_idx].data[col_idx];
410            if is_top {
411                entry.set_color0_bits(bits);
412            } else {
413                entry.set_color1_bits(bits);
414            }
415        }
416    }
417}
418
419impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
420    for DmaFrameBuffer<NROWS, COLS, PLANES>
421{
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
428    for DmaFrameBuffer<NROWS, COLS, PLANES>
429{
430    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
431        f.debug_struct("DmaFrameBuffer")
432            .field("size", &core::mem::size_of_val(&self.planes))
433            .field("plane_count", &self.planes.len())
434            .field("plane_size", &core::mem::size_of_val(&self.planes[0]))
435            .finish()
436    }
437}
438
439#[cfg(feature = "defmt")]
440impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
441    for DmaFrameBuffer<NROWS, COLS, PLANES>
442{
443    fn format(&self, f: defmt::Formatter) {
444        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
445        defmt::write!(f, " size: {}", core::mem::size_of_val(&self.planes));
446        defmt::write!(
447            f,
448            " plane_size: {}",
449            core::mem::size_of_val(&self.planes[0])
450        );
451    }
452}
453
454impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
455    for DmaFrameBuffer<NROWS, COLS, PLANES>
456{
457    type Word = u16;
458
459    fn plane_count(&self) -> usize {
460        PLANES
461    }
462
463    fn plane_ptr_len(&self, plane_idx: usize) -> (*const u8, usize) {
464        assert!(
465            plane_idx < PLANES,
466            "plane_idx {plane_idx} out of range for {PLANES} planes"
467        );
468        let ptr = (&raw const self.planes[plane_idx]).cast::<u8>();
469        let len = core::mem::size_of::<PlaneData<NROWS, COLS>>();
470        (ptr, len)
471    }
472}
473
474impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
475    for DmaFrameBuffer<NROWS, COLS, PLANES>
476{
477    #[inline]
478    fn erase(&mut self) {
479        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
480    }
481
482    #[inline]
483    fn set_pixel(&mut self, p: Point, color: Color) {
484        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
485    }
486}
487
488impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
489    for DmaFrameBuffer<NROWS, COLS, PLANES>
490{
491}
492
493impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
494    for DmaFrameBuffer<NROWS, COLS, PLANES>
495{
496    fn size(&self) -> Size {
497        Size::new(COLS as u32, (NROWS * 2) as u32)
498    }
499}
500
501impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
502    for DmaFrameBuffer<NROWS, COLS, PLANES>
503{
504    type Color = Color;
505    type Error = Infallible;
506
507    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
508    where
509        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
510    {
511        for pixel in pixels {
512            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
513        }
514        Ok(())
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    extern crate std;
521
522    use super::*;
523    use embedded_graphics::prelude::*;
524    use std::format;
525
526    const TEST_COLS: usize = 64;
527    type TestBuffer = DmaFrameBuffer<16, 64, 8>;
528
529    #[test]
530    fn row_format_sets_address_and_control_bits() {
531        let mut row = Row::<TEST_COLS>::new();
532        row.format(4);
533
534        let last_idx = map_index(TEST_COLS - 1);
535        assert_eq!(row.data[last_idx].latch(), true);
536        assert_eq!(row.data[last_idx].addr(), 4);
537
538        let first_idx = map_index(0);
539        assert_eq!(row.data[first_idx].addr(), 4);
540        assert_eq!(row.data[first_idx].latch(), false);
541    }
542
543    #[test]
544    fn format_sets_expected_row_addresses_for_all_rows() {
545        let mut fb = TestBuffer::new();
546        fb.format();
547
548        for plane_idx in 0..8 {
549            for row_idx in 0..16 {
550                let prev_addr = if row_idx == 0 { 15 } else { row_idx - 1 };
551                let last_col = map_index(63);
552                assert_eq!(
553                    fb.planes[plane_idx].rows[row_idx].data[last_col].addr(),
554                    prev_addr as u16
555                );
556                assert_eq!(
557                    fb.planes[plane_idx].rows[row_idx].data[last_col].latch(),
558                    true
559                );
560            }
561        }
562    }
563
564    #[test]
565    fn set_pixel_maps_top_half_bits_per_plane() {
566        let mut fb = TestBuffer::new();
567        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
568        fb.set_pixel(Point::new(2, 3), color);
569
570        for plane_idx in 0..8 {
571            let bit = 7 - plane_idx;
572            let entry = fb.planes[plane_idx].rows[3].data[map_index(2)];
573            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
574            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
575            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
576        }
577    }
578
579    #[test]
580    fn set_pixel_maps_bottom_half_bits_per_plane() {
581        let mut fb = TestBuffer::new();
582        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
583        fb.set_pixel(Point::new(4, 20), color);
584
585        for plane_idx in 0..8 {
586            let bit = 7 - plane_idx;
587            let entry = fb.planes[plane_idx].rows[4].data[map_index(4)];
588            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
589            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
590            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
591        }
592    }
593
594    #[test]
595    fn erase_clears_only_color_bits() {
596        let mut fb = TestBuffer::new();
597        let oe_before = fb.planes[0].rows[0].data[map_index(1)].output_enable();
598        fb.set_pixel(Point::new(0, 0), Color::WHITE);
599        fb.erase();
600
601        for plane in &fb.planes {
602            for row in &plane.rows {
603                for entry in &row.data {
604                    assert!(!entry.red1());
605                    assert!(!entry.grn1());
606                    assert!(!entry.blu1());
607                    assert!(!entry.red2());
608                    assert!(!entry.grn2());
609                    assert!(!entry.blu2());
610                }
611            }
612        }
613
614        assert_eq!(
615            fb.planes[0].rows[0].data[map_index(1)].output_enable(),
616            oe_before
617        );
618    }
619
620    #[test]
621    fn draw_target_iter_sets_pixels() {
622        let mut fb = TestBuffer::new();
623        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
624        let result = fb.draw_iter(pixels);
625        assert!(result.is_ok());
626
627        for plane_idx in 0..8 {
628            let bit = 7 - plane_idx;
629            let entry = fb.planes[plane_idx].rows[1].data[map_index(1)];
630            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
631            assert!(!entry.grn1());
632            assert!(!entry.blu1());
633        }
634    }
635
636    #[test]
637    fn set_pixel_ignores_out_of_bounds_and_negative() {
638        let mut fb = TestBuffer::new();
639        let before = fb.planes;
640        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
641        fb.set_pixel(Point::new(0, -1), Color::WHITE);
642        fb.set_pixel(Point::new(64, 0), Color::WHITE);
643        fb.set_pixel(Point::new(0, 32), Color::WHITE);
644        assert_eq!(fb.planes, before);
645    }
646
647    #[test]
648    fn bcm_chunk_info_for_common_panel() {
649        assert_eq!(TestBuffer::bcm_chunk_count(), 8);
650        assert_eq!(
651            TestBuffer::bcm_chunk_bytes(),
652            core::mem::size_of::<PlaneData<16, 64>>()
653        );
654    }
655
656    #[test]
657    fn frame_buffer_trait_accessors_report_expected_values() {
658        let fb = TestBuffer::new();
659        let as_trait: &dyn FrameBuffer<Word = u16> = &fb;
660        assert_eq!(as_trait.get_word_size(), crate::WordSize::Sixteen);
661        assert_eq!(as_trait.plane_count(), 8);
662
663        let (ptr, len) = as_trait.plane_ptr_len(0);
664        assert_eq!(len, core::mem::size_of::<PlaneData<16, 64>>());
665        assert_eq!(ptr, (&raw const fb.planes[0]).cast::<u8>());
666    }
667
668    #[test]
669    #[should_panic(expected = "out of range")]
670    fn plane_ptr_len_panics_for_invalid_plane() {
671        let fb = TestBuffer::new();
672        let _ = fb.plane_ptr_len(8);
673    }
674
675    #[test]
676    fn origin_dimensions_match_panel_geometry() {
677        let fb = TestBuffer::new();
678        assert_eq!(fb.size(), Size::new(64, 32));
679    }
680
681    #[test]
682    fn debug_impl_includes_shape_information() {
683        let fb = TestBuffer::new();
684        let s = format!("{fb:?}");
685        assert!(s.contains("DmaFrameBuffer"));
686        assert!(s.contains("plane_count"));
687        assert!(s.contains("plane_size"));
688    }
689
690    #[test]
691    fn row_format_sets_expected_blank_and_latch_positions() {
692        let mut row = Row::<TEST_COLS>::new();
693        row.format(4);
694
695        let oe_active = !cfg!(feature = "invert-oe");
696
697        // DMA index TRAIL_BLANK_DELAY: first active pixel (right edge = DMA start)
698        let idx_active = map_index(TRAIL_BLANK_DELAY);
699        assert_eq!(row.data[idx_active].output_enable(), oe_active);
700        assert_eq!(row.data[idx_active].addr(), 4);
701
702        // i == COLS - LEAD_BLANK_DELAY - 1: lead blanking before latch (left edge)
703        let idx_blank = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
704        assert_eq!(row.data[idx_blank].output_enable(), !oe_active);
705
706        // i == COLS - 1 latches, address stays at prev_addr
707        let idx_last = map_index(TEST_COLS - 1);
708        assert!(row.data[idx_last].latch());
709        assert_eq!(row.data[idx_last].addr(), 4);
710    }
711
712    #[test]
713    fn default_constructors_match_new() {
714        let row_default = Row::<TEST_COLS>::default();
715        let row_new = Row::<TEST_COLS>::new();
716        assert_eq!(row_default, row_new);
717
718        let fb_default = TestBuffer::default();
719        let fb_new = TestBuffer::new();
720        assert_eq!(fb_default.planes, fb_new.planes);
721    }
722
723    #[test]
724    fn framebuffer_operations_trait_delegates_correctly() {
725        let mut fb = TestBuffer::new();
726        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
727
728        assert!(fb.planes[0].rows[5].data[map_index(3)].grn1());
729
730        FrameBufferOperations::erase(&mut fb);
731        for plane in &fb.planes {
732            for row in &plane.rows {
733                for entry in &row.data {
734                    assert!(!entry.red1());
735                    assert!(!entry.grn1());
736                    assert!(!entry.blu1());
737                    assert!(!entry.red2());
738                    assert!(!entry.grn2());
739                    assert!(!entry.blu2());
740                }
741            }
742        }
743    }
744
745    #[test]
746    fn entry_debug_shows_hex_value() {
747        let mut entry = Entry::new();
748        entry.set_red1(true);
749        let s = format!("{entry:?}");
750        assert!(s.contains("Entry"));
751        assert!(s.contains("0x"));
752    }
753
754    #[test]
755    fn entry_new_oe_matches_feature() {
756        let entry = Entry::new();
757        if cfg!(feature = "invert-oe") {
758            assert!(entry.output_enable());
759        } else {
760            assert!(!entry.output_enable());
761        }
762    }
763
764    #[test]
765    fn make_data_template_oe_polarity() {
766        let mut row = Row::<TEST_COLS>::new();
767        row.format(4);
768
769        let active_idx = map_index(TRAIL_BLANK_DELAY);
770        let blank_idx = map_index(TEST_COLS - LEAD_BLANK_DELAY - 1);
771        let latch_idx = map_index(TEST_COLS - 1);
772
773        if cfg!(feature = "invert-oe") {
774            assert!(!row.data[active_idx].output_enable());
775            assert!(row.data[blank_idx].output_enable());
776            assert!(row.data[latch_idx].output_enable());
777        } else {
778            assert!(row.data[active_idx].output_enable());
779            assert!(!row.data[blank_idx].output_enable());
780            assert!(!row.data[latch_idx].output_enable());
781        }
782    }
783
784    static STATIC_FB: TestBuffer = TestBuffer::new();
785
786    #[test]
787    fn test_static_construction_is_formatted() {
788        let runtime_fb = TestBuffer::new();
789
790        for (pi, plane) in STATIC_FB.planes.iter().enumerate() {
791            for (ri, row) in plane.rows.iter().enumerate() {
792                assert_eq!(
793                    row.data, runtime_fb.planes[pi].rows[ri].data,
794                    "static vs runtime mismatch at plane {pi}, row {ri}"
795                );
796                assert_eq!(
797                    row.gap, runtime_fb.planes[pi].rows[ri].gap,
798                    "static vs runtime gap mismatch at plane {pi}, row {ri}"
799                );
800            }
801        }
802    }
803
804    #[test]
805    fn test_format_reinitializes_at_runtime() {
806        let mut fb = TestBuffer::new();
807        fb.erase();
808        fb.format();
809
810        for (pi, plane) in fb.planes.iter().enumerate() {
811            for (ri, row) in plane.rows.iter().enumerate() {
812                assert_eq!(
813                    row.data, STATIC_FB.planes[pi].rows[ri].data,
814                    "re-formatted vs static mismatch at plane {pi}, row {ri}"
815                );
816            }
817        }
818    }
819}