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(not(feature = "invert-oe"))]
121const OE_ACTIVE: u8 = 0b1000_0000;
122#[cfg(not(feature = "invert-oe"))]
123const OE_BLANK: u8 = 0;
124
125#[cfg(feature = "invert-oe")]
126const OE_ACTIVE: u8 = 0;
127#[cfg(feature = "invert-oe")]
128const OE_BLANK: u8 = 0b1000_0000;
129
130bitfield! {
131    #[derive(Clone, Copy, Default, PartialEq, Eq)]
132    #[repr(transparent)]
133    pub(crate) struct Address(u8);
134    impl Debug;
135    pub(crate) output_enable, set_output_enable: 7;
136    pub(crate) latch, set_latch: 6;
137    pub(crate) addr, set_addr: 4, 0;
138}
139
140impl Address {
141    pub const fn new() -> Self {
142        Self(0)
143    }
144}
145
146bitfield! {
147    #[derive(Clone, Copy, Default, PartialEq)]
148    #[repr(transparent)]
149    pub(crate) struct Entry(u8);
150    impl Debug;
151    pub(crate) output_enable, set_output_enable: 7;
152    pub(crate) latch, set_latch: 6;
153    pub(crate) blu2, set_blu2: 5;
154    pub(crate) grn2, set_grn2: 4;
155    pub(crate) red2, set_red2: 3;
156    pub(crate) blu1, set_blu1: 2;
157    pub(crate) grn1, set_grn1: 1;
158    pub(crate) red1, set_red1: 0;
159}
160
161impl Entry {
162    pub const fn new() -> Self {
163        Self(0)
164    }
165
166    const COLOR0_MASK: u8 = 0b0000_0111;
167    const COLOR1_MASK: u8 = 0b0011_1000;
168
169    #[inline]
170    fn set_color0_bits(&mut self, bits: u8) {
171        self.0 = (self.0 & !Self::COLOR0_MASK) | (bits & Self::COLOR0_MASK);
172    }
173
174    #[inline]
175    fn set_color1_bits(&mut self, bits: u8) {
176        self.0 = (self.0 & !Self::COLOR1_MASK) | ((bits << 3) & Self::COLOR1_MASK);
177    }
178}
179
180#[derive(Clone, Copy, PartialEq, Debug)]
181#[repr(C)]
182/// A single BCM row payload for 8-bit latched output.
183///
184/// Each row contains color-stream data for `COLS` pixels followed by four
185/// address/control bytes that clock the row address into the external latch.
186pub struct Row<const COLS: usize> {
187    pub(crate) data: [Entry; COLS],
188    pub(crate) address: [Address; 4],
189}
190
191#[inline]
192const fn map_index(index: usize) -> usize {
193    #[cfg(feature = "esp32-ordering")]
194    {
195        index ^ 2
196    }
197    #[cfg(not(feature = "esp32-ordering"))]
198    {
199        index
200    }
201}
202
203const fn make_addr_table() -> [[Address; 4]; 32] {
204    let mut tbl = [[Address::new(); 4]; 32];
205    let mut addr = 0;
206    while addr < 32 {
207        tbl[addr][map_index(0)].0 = OE_BLANK | 1u8 << 6 | addr as u8;
208        tbl[addr][map_index(1)].0 = OE_BLANK | 1u8 << 6 | addr as u8;
209        tbl[addr][map_index(2)].0 = OE_BLANK | addr as u8;
210        tbl[addr][map_index(3)].0 = OE_BLANK;
211        addr += 1;
212    }
213    tbl
214}
215
216static ADDR_TABLE: [[Address; 4]; 32] = make_addr_table();
217
218#[allow(clippy::absurd_extreme_comparisons)]
219const fn make_data_template<const COLS: usize>() -> [Entry; COLS] {
220    let mut data = [Entry::new(); COLS];
221    let mut i = 0;
222    while i < COLS {
223        let mapped_i = map_index(i);
224        data[mapped_i].0 =
225            if i >= TRAIL_BLANK_DELAY && i < COLS.saturating_sub(LEAD_BLANK_DELAY + 1) {
226                OE_ACTIVE
227            } else {
228                OE_BLANK
229            };
230        i += 1;
231    }
232    data
233}
234
235impl<const COLS: usize> Row<COLS> {
236    /// Creates a zero-initialized row.
237    ///
238    /// Call [`Self::format`] before first use to populate row address/control
239    /// metadata.
240    #[must_use]
241    pub const fn new() -> Self {
242        Self {
243            data: [Entry::new(); COLS],
244            address: [Address::new(); 4],
245        }
246    }
247
248    /// Formats this row for the provided multiplexed row address.
249    ///
250    /// This sets the trailing address bytes and initializes output-enable/latch
251    /// bits in the pixel stream template.
252    #[inline]
253    pub fn format(&mut self, addr: u8) {
254        debug_assert!((addr as usize) < ADDR_TABLE.len());
255        let src_addr = &ADDR_TABLE[addr as usize];
256        self.address[0] = src_addr[0];
257        self.address[1] = src_addr[1];
258        self.address[2] = src_addr[2];
259        self.address[3] = src_addr[3];
260
261        let data_template = make_data_template::<COLS>();
262        let mut i = 0;
263        while i < COLS {
264            self.data[i] = data_template[i];
265            i += 1;
266        }
267    }
268}
269
270impl<const COLS: usize> Default for Row<COLS> {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276/// The entire BCM Frame Buffer (Contiguous Memory)
277#[derive(Copy, Clone)]
278#[repr(C)]
279pub struct DmaFrameBuffer<const NROWS: usize, const COLS: usize, const PLANES: usize> {
280    pub(crate) planes: [[Row<COLS>; NROWS]; PLANES],
281}
282
283impl<const NROWS: usize, const COLS: usize, const PLANES: usize>
284    DmaFrameBuffer<NROWS, COLS, PLANES>
285{
286    /// Creates a new frame buffer.
287    #[must_use]
288    pub fn new() -> Self {
289        let mut instance = Self {
290            planes: [[Row::new(); NROWS]; PLANES],
291        };
292        instance.format();
293        instance
294    }
295
296    /// Returns the number of BCM chunks (one per bit-plane).
297    #[must_use]
298    pub const fn bcm_chunk_count() -> usize {
299        PLANES
300    }
301
302    /// Returns the byte size of one BCM chunk (a single bit-plane).
303    #[must_use]
304    pub const fn bcm_chunk_bytes() -> usize {
305        NROWS * core::mem::size_of::<Row<COLS>>()
306    }
307
308    /// Formats the frame buffer with row addresses and control bits.
309    #[inline]
310    pub fn format(&mut self) {
311        for plane in &mut self.planes {
312            for (row_idx, row) in plane.iter_mut().enumerate() {
313                row.format(row_idx as u8);
314            }
315        }
316    }
317
318    /// Erase pixel colors while preserving row control data.
319    #[inline]
320    pub fn erase(&mut self) {
321        const MASK: u8 = !0b0011_1111;
322        for plane in &mut self.planes {
323            for row in plane {
324                for entry in &mut row.data {
325                    entry.0 &= MASK;
326                }
327            }
328        }
329    }
330
331    /// Set a pixel in the framebuffer.
332    #[inline]
333    pub fn set_pixel(&mut self, p: Point, color: Color) {
334        if p.x < 0 || p.y < 0 {
335            return;
336        }
337        self.set_pixel_internal(p.x as usize, p.y as usize, color);
338    }
339
340    #[inline]
341    fn set_pixel_internal(&mut self, x: usize, y: usize, color: Color) {
342        if x >= COLS || y >= NROWS * 2 {
343            return;
344        }
345
346        let row_idx = if y < NROWS { y } else { y - NROWS };
347        let is_top = y < NROWS;
348        let red = color.r();
349        let green = color.g();
350        let blue = color.b();
351
352        for plane_idx in 0..PLANES {
353            let bit = 7_u32.saturating_sub(plane_idx as u32);
354            let bits = ((u8::from(((blue >> bit) & 1) != 0)) << 2)
355                | ((u8::from(((green >> bit) & 1) != 0)) << 1)
356                | u8::from(((red >> bit) & 1) != 0);
357            let col_idx = map_index(x);
358            let entry = &mut self.planes[plane_idx][row_idx].data[col_idx];
359            if is_top {
360                entry.set_color0_bits(bits);
361            } else {
362                entry.set_color1_bits(bits);
363            }
364        }
365    }
366}
367
368impl<const NROWS: usize, const COLS: usize, const PLANES: usize> Default
369    for DmaFrameBuffer<NROWS, COLS, PLANES>
370{
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376impl<const NROWS: usize, const COLS: usize, const PLANES: usize> core::fmt::Debug
377    for DmaFrameBuffer<NROWS, COLS, PLANES>
378{
379    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
380        f.debug_struct("DmaFrameBuffer")
381            .field("size", &core::mem::size_of_val(&self.planes))
382            .field("plane_count", &self.planes.len())
383            .field("plane_size", &core::mem::size_of_val(&self.planes[0]))
384            .finish()
385    }
386}
387
388#[cfg(feature = "defmt")]
389impl<const NROWS: usize, const COLS: usize, const PLANES: usize> defmt::Format
390    for DmaFrameBuffer<NROWS, COLS, PLANES>
391{
392    fn format(&self, f: defmt::Formatter) {
393        defmt::write!(f, "DmaFrameBuffer<{}, {}, {}>", NROWS, COLS, PLANES);
394        defmt::write!(f, " size: {}", core::mem::size_of_val(&self.planes));
395        defmt::write!(
396            f,
397            " plane_size: {}",
398            core::mem::size_of_val(&self.planes[0])
399        );
400    }
401}
402
403impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBuffer
404    for DmaFrameBuffer<NROWS, COLS, PLANES>
405{
406    type Word = u8;
407
408    fn plane_count(&self) -> usize {
409        PLANES
410    }
411
412    fn plane_ptr_len(&self, plane_idx: usize) -> (*const u8, usize) {
413        assert!(
414            plane_idx < PLANES,
415            "plane_idx {plane_idx} out of range for {PLANES} planes"
416        );
417        let ptr = self.planes[plane_idx].as_ptr().cast::<u8>();
418        let len = NROWS * core::mem::size_of::<Row<COLS>>();
419        (ptr, len)
420    }
421}
422
423impl<const NROWS: usize, const COLS: usize, const PLANES: usize> FrameBufferOperations
424    for DmaFrameBuffer<NROWS, COLS, PLANES>
425{
426    #[inline]
427    fn erase(&mut self) {
428        DmaFrameBuffer::<NROWS, COLS, PLANES>::erase(self);
429    }
430
431    #[inline]
432    fn set_pixel(&mut self, p: Point, color: Color) {
433        DmaFrameBuffer::<NROWS, COLS, PLANES>::set_pixel(self, p, color);
434    }
435}
436
437impl<const NROWS: usize, const COLS: usize, const PLANES: usize> MutableFrameBuffer
438    for DmaFrameBuffer<NROWS, COLS, PLANES>
439{
440}
441
442impl<const NROWS: usize, const COLS: usize, const PLANES: usize> OriginDimensions
443    for DmaFrameBuffer<NROWS, COLS, PLANES>
444{
445    fn size(&self) -> Size {
446        Size::new(COLS as u32, (NROWS * 2) as u32)
447    }
448}
449
450impl<const NROWS: usize, const COLS: usize, const PLANES: usize> DrawTarget
451    for DmaFrameBuffer<NROWS, COLS, PLANES>
452{
453    type Color = Color;
454    type Error = Infallible;
455
456    fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
457    where
458        I: IntoIterator<Item = embedded_graphics::Pixel<Self::Color>>,
459    {
460        for pixel in pixels {
461            self.set_pixel_internal(pixel.0.x as usize, pixel.0.y as usize, pixel.1);
462        }
463        Ok(())
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    extern crate std;
470
471    use super::*;
472    use embedded_graphics::prelude::*;
473    use std::format;
474
475    type TestBuffer = DmaFrameBuffer<16, 64, 8>;
476
477    #[test]
478    fn row_format_sets_address_and_control_bits() {
479        const TEST_N: usize = 64;
480        let mut row = Row::<TEST_N>::new();
481        row.format(5);
482        let latch_count = row.address.iter().filter(|a| a.latch()).count();
483        assert_eq!(latch_count, 2);
484        assert_eq!(row.address[map_index(0)].addr(), 5);
485        assert_eq!(row.address[map_index(1)].addr(), 5);
486        assert_eq!(row.address[map_index(2)].addr(), 5);
487        assert_eq!(row.address[map_index(3)].0, OE_BLANK);
488        let oe_active = !cfg!(feature = "invert-oe");
489        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
490        let blank_count = TEST_N - active_count;
491        let oe_blank_count = row
492            .data
493            .iter()
494            .filter(|entry| entry.output_enable() != oe_active)
495            .count();
496        assert_eq!(oe_blank_count, blank_count);
497    }
498
499    #[test]
500    fn format_sets_expected_row_addresses_for_all_rows() {
501        let mut fb = TestBuffer::new();
502        fb.format();
503
504        for plane_idx in 0..8 {
505            for row_idx in 0..16 {
506                let row = &fb.planes[plane_idx][row_idx];
507                assert_eq!(row.address[map_index(0)].addr(), row_idx as u8);
508                assert_eq!(row.address[map_index(1)].addr(), row_idx as u8);
509                assert_eq!(row.address[map_index(2)].addr(), row_idx as u8);
510                assert_eq!(row.address[map_index(3)].0, OE_BLANK);
511            }
512        }
513    }
514
515    #[test]
516    fn set_pixel_maps_top_half_bits_per_plane() {
517        let mut fb = TestBuffer::new();
518        let color = Color::new(0b1010_0101, 0b0101_1010, 0b1111_0000);
519        fb.set_pixel(Point::new(2, 3), color);
520
521        for plane_idx in 0..8 {
522            let bit = 7 - plane_idx;
523            let entry = fb.planes[plane_idx][3].data[map_index(2)];
524            assert_eq!(entry.red1(), ((color.r() >> bit) & 1) != 0);
525            assert_eq!(entry.grn1(), ((color.g() >> bit) & 1) != 0);
526            assert_eq!(entry.blu1(), ((color.b() >> bit) & 1) != 0);
527        }
528    }
529
530    #[test]
531    fn set_pixel_maps_bottom_half_bits_per_plane() {
532        let mut fb = TestBuffer::new();
533        let color = Color::new(0b1100_0011, 0b0011_1100, 0b1001_0110);
534        fb.set_pixel(Point::new(4, 20), color);
535
536        for plane_idx in 0..8 {
537            let bit = 7 - plane_idx;
538            let entry = fb.planes[plane_idx][4].data[map_index(4)];
539            assert_eq!(entry.red2(), ((color.r() >> bit) & 1) != 0);
540            assert_eq!(entry.grn2(), ((color.g() >> bit) & 1) != 0);
541            assert_eq!(entry.blu2(), ((color.b() >> bit) & 1) != 0);
542        }
543    }
544
545    #[test]
546    fn erase_clears_only_color_bits() {
547        let mut fb = TestBuffer::new();
548        let oe_before = fb.planes[0][0].data[0].output_enable();
549        fb.set_pixel(Point::new(0, 0), Color::WHITE);
550        fb.erase();
551
552        for plane in &fb.planes {
553            for row in plane {
554                for entry in &row.data {
555                    assert!(!entry.red1());
556                    assert!(!entry.grn1());
557                    assert!(!entry.blu1());
558                    assert!(!entry.red2());
559                    assert!(!entry.grn2());
560                    assert!(!entry.blu2());
561                }
562            }
563        }
564
565        assert_eq!(fb.planes[0][0].data[0].output_enable(), oe_before);
566    }
567
568    #[test]
569    fn draw_target_iter_sets_pixels() {
570        let mut fb = TestBuffer::new();
571        let pixels = [Pixel(Point::new(1, 1), Color::RED)];
572        let result = fb.draw_iter(pixels);
573        assert!(result.is_ok());
574
575        for plane_idx in 0..8 {
576            let bit = 7 - plane_idx;
577            let entry = fb.planes[plane_idx][1].data[map_index(1)];
578            assert_eq!(entry.red1(), ((Color::RED.r() >> bit) & 1) != 0);
579            assert!(!entry.grn1());
580            assert!(!entry.blu1());
581        }
582    }
583
584    #[test]
585    fn set_pixel_ignores_out_of_bounds_and_negative() {
586        let mut fb = TestBuffer::new();
587        let before = fb.planes;
588        fb.set_pixel(Point::new(-1, 0), Color::WHITE);
589        fb.set_pixel(Point::new(0, -1), Color::WHITE);
590        fb.set_pixel(Point::new(64, 0), Color::WHITE);
591        fb.set_pixel(Point::new(0, 32), Color::WHITE);
592        assert_eq!(fb.planes, before);
593    }
594
595    #[test]
596    fn bcm_chunk_info_for_common_panel() {
597        assert_eq!(TestBuffer::bcm_chunk_count(), 8);
598        assert_eq!(
599            TestBuffer::bcm_chunk_bytes(),
600            16 * core::mem::size_of::<Row<64>>()
601        );
602    }
603
604    #[test]
605    fn frame_buffer_trait_accessors_report_expected_values() {
606        let fb = TestBuffer::new();
607        let as_trait: &dyn FrameBuffer<Word = u8> = &fb;
608        assert_eq!(as_trait.get_word_size(), crate::WordSize::Eight);
609        assert_eq!(as_trait.plane_count(), 8);
610
611        let (ptr, len) = as_trait.plane_ptr_len(0);
612        assert_eq!(len, 16 * core::mem::size_of::<Row<64>>());
613        assert_eq!(ptr, fb.planes[0].as_ptr().cast::<u8>());
614    }
615
616    #[test]
617    #[should_panic(expected = "out of range")]
618    fn plane_ptr_len_panics_for_invalid_plane() {
619        let fb = TestBuffer::new();
620        let _ = fb.plane_ptr_len(8);
621    }
622
623    #[test]
624    fn origin_dimensions_match_panel_geometry() {
625        let fb = TestBuffer::new();
626        assert_eq!(fb.size(), Size::new(64, 32));
627    }
628
629    #[test]
630    fn debug_impl_includes_shape_information() {
631        let fb = TestBuffer::new();
632        let s = format!("{fb:?}");
633        assert!(s.contains("DmaFrameBuffer"));
634        assert!(s.contains("plane_count"));
635        assert!(s.contains("plane_size"));
636    }
637
638    #[test]
639    fn row_format_sets_exactly_one_data_word_with_oe_low() {
640        const TEST_N: usize = 64;
641        let mut row = Row::<TEST_N>::new();
642        row.format(9);
643
644        let oe_active = !cfg!(feature = "invert-oe");
645        let active_count = TEST_N.saturating_sub(LEAD_BLANK_DELAY + TRAIL_BLANK_DELAY + 1);
646        let blank_count = TEST_N - active_count;
647        let oe_blank_indices: std::vec::Vec<_> = row
648            .data
649            .iter()
650            .enumerate()
651            .filter_map(|(i, entry)| (entry.output_enable() != oe_active).then_some(i))
652            .collect();
653        assert_eq!(oe_blank_indices.len(), blank_count);
654        assert!(oe_blank_indices.contains(&map_index(TEST_N - 1)));
655    }
656
657    #[test]
658    fn default_constructors_match_new() {
659        let row_default = Row::<64>::default();
660        let row_new = Row::<64>::new();
661        assert_eq!(row_default, row_new);
662
663        let fb_default = TestBuffer::default();
664        let fb_new = TestBuffer::new();
665        assert_eq!(fb_default.planes, fb_new.planes);
666    }
667
668    #[test]
669    fn framebuffer_operations_trait_delegates_correctly() {
670        let mut fb = TestBuffer::new();
671        FrameBufferOperations::set_pixel(&mut fb, Point::new(3, 5), Color::GREEN);
672
673        assert!(fb.planes[0][5].data[map_index(3)].grn1());
674
675        FrameBufferOperations::erase(&mut fb);
676        for plane in &fb.planes {
677            for row in plane {
678                for entry in &row.data {
679                    assert!(!entry.red1());
680                    assert!(!entry.grn1());
681                    assert!(!entry.blu1());
682                    assert!(!entry.red2());
683                    assert!(!entry.grn2());
684                    assert!(!entry.blu2());
685                }
686            }
687        }
688    }
689
690    #[test]
691    fn addr_table_entries_are_consistent() {
692        let table = make_addr_table();
693        for addr in 0..32u8 {
694            let row = &table[addr as usize];
695            // First two clocks: latch asserted with row address
696            assert!(row[map_index(0)].latch());
697            assert_eq!(row[map_index(0)].addr(), addr);
698            assert!(row[map_index(1)].latch());
699            assert_eq!(row[map_index(1)].addr(), addr);
700            // Third clock: latch released, address still driven
701            assert!(!row[map_index(2)].latch());
702            assert_eq!(row[map_index(2)].addr(), addr);
703            // Fourth clock: clear cycle, only OE_BLANK
704            assert!(!row[map_index(3)].latch());
705            assert_eq!(row[map_index(3)].0, OE_BLANK);
706        }
707        assert_eq!(table, ADDR_TABLE);
708    }
709
710    #[test]
711    fn test_blanking_delay() {
712        let mut row = Row::<64>::new();
713        row.format(5);
714
715        let oe_active = !cfg!(feature = "invert-oe");
716
717        // Trail blank: indices 0..TRAIL_BLANK_DELAY-1 (DMA start = physical right edge)
718        if TRAIL_BLANK_DELAY > 0 {
719            let trail_blank_idx = map_index(TRAIL_BLANK_DELAY - 1);
720            assert_eq!(row.data[trail_blank_idx].output_enable(), !oe_active);
721        }
722
723        let first_active_idx = map_index(TRAIL_BLANK_DELAY);
724        assert_eq!(row.data[first_active_idx].output_enable(), oe_active);
725
726        // Lead blank: indices before latch (DMA end = physical left edge)
727        let last_active_idx = map_index(64 - LEAD_BLANK_DELAY - 2);
728        assert_eq!(row.data[last_active_idx].output_enable(), oe_active);
729
730        let lead_blank_idx = map_index(64 - LEAD_BLANK_DELAY - 1);
731        assert_eq!(row.data[lead_blank_idx].output_enable(), !oe_active);
732
733        let last_pixel_idx = map_index(63);
734        assert_eq!(row.data[last_pixel_idx].output_enable(), !oe_active);
735    }
736}