Skip to main content

dotzuki_renderer/
indexed_framebuffer.rs

1//! Indexed (palette-based) framebuffer with packed bitplane storage.
2//!
3//! [`IndexedFrameBuffer`] stores one palette index per pixel instead of RGBA
4//! bytes. It is an additive alternative to the engine's RGBA
5//! [`dotzuki_engine::render::FrameBuffer`] for fixed-palette games (the GB-port
6//! plan, `docs/low-end-hardware-optimization.md` §5.4, and NES/SNES-style projects):
7//! a Game Boy 160×144 screen costs 5,760 bytes instead of 92,160.
8//!
9//! # Storage format (packed 2bpp, planar bitplanes)
10//!
11//! Two candidate storages were considered: one byte per pixel (`W*H` bytes)
12//! or packed 2bpp (`W*H/4` bytes). Packed 2bpp was chosen:
13//!
14//! - **VRAM isomorphism.** The packing is the *same planar bitplane layout
15//!   used by Game Boy VRAM tiles* and by this crate's tile pipeline
16//!   ([`crate::tile::Tile::from_2bpp`], `pokered-renderer`'s `png_to_2bpp`):
17//!   each row of 8 pixels is stored as one byte per bitplane, bit 7 =
18//!   leftmost pixel, bitplane 0 first. A [`GbColor`] (2-bit) buffer's raw
19//!   bytes are literally GB 2bpp tile data — tile blits and buffer contents
20//!   are interchangeable, and rendering code translates almost 1:1 to real
21//!   GB VRAM (`docs/low-end-hardware-optimization.md` §5.4).
22//! - **4× smaller**: 5.7 KiB vs 23 KiB for a 160×144 screen.
23//! - The cost — bit twiddling in `set_pixel`/`get_pixel` — is negligible
24//!   for a fixed 5.7 KiB buffer.
25//!
26//! The bit width is derived from `C::MAX` (2 bits for [`GbColor`], 4 bits
27//! for [`GbaColor`]), so the same type serves both the 4-color DMG and
28//! 16-color GBA palettes. RGBA conversion is deferred to display time via
29//! [`IndexedFrameBuffer::to_rgba`], which is what makes palette swaps
30//! (fades, flashes) nearly free — the way real GB hardware does them.
31//!
32//! # Memory
33//!
34//! Dimensions are fixed at construction (runtime values, like the engine's
35//! [`dotzuki_engine::render::FrameBuffer`]); [`Default`] is the 160×144 Game
36//! Boy screen. Storage is a `Vec` allocated exactly once at construction
37//! (`packed_len` bytes) and never resized. A compile-time-sized fixed-array
38//! variant is not possible on stable Rust today — array lengths cannot be
39//! computed from generic parameters (`generic_const_exprs` is unstable) —
40//! so the eventual no_std/GB step can swap the `Vec` for a static buffer
41//! without touching any other code.
42
43use std::marker::PhantomData;
44
45use crate::palette::{ColorIndex, GbColor, GbaColor, Palette, GRAYSCALE_PALETTE};
46use dotzuki_engine::render::Rgba;
47use dotzuki_engine::render_config::RenderConfig;
48
49// ---------------------------------------------------------------------------
50// Constants
51// ---------------------------------------------------------------------------
52
53/// Default screen width in pixels (Game Boy DMG resolution).
54pub const SCREEN_WIDTH: usize = 160;
55/// Default screen height in pixels (Game Boy DMG resolution).
56pub const SCREEN_HEIGHT: usize = 144;
57
58/// Number of bits needed to store one palette index of type `C`.
59///
60/// 2 for [`GbColor`] (4 colors), 4 for [`GbaColor`] (16 colors), at least 1
61/// for any index type.
62pub const fn index_bits<C: ColorIndex>() -> usize {
63    let bits = C::MAX.ilog2();
64    if bits < 1 {
65        1
66    } else {
67        bits as usize
68    }
69}
70
71/// Number of 8-pixel groups per row (rounded up).
72///
73/// A full Game Boy row is 20 groups; rows not divisible by 8 pack a partial
74/// final group whose unused bits stay zero and are never read.
75const fn groups_per_row(width: usize) -> usize {
76    (width + 7) / 8
77}
78
79/// Packed storage length in bytes for a `width × height` buffer of `C`
80/// indices: `height * groups_per_row(width) * index_bits::<C>()`.
81///
82/// 5,760 for a 160×144 [`GbColor`] buffer, 11,520 for [`GbaColor`].
83pub const fn packed_len<C: ColorIndex>(width: usize, height: usize) -> usize {
84    height * groups_per_row(width) * index_bits::<C>()
85}
86
87// ---------------------------------------------------------------------------
88// IndexedFrameBuffer
89// ---------------------------------------------------------------------------
90
91/// A fixed-size framebuffer storing palette indices instead of RGBA pixels.
92///
93/// `C` is the palette index type ([`GbColor`] for 4-color DMG, [`GbaColor`]
94/// for 16-color GBA). Dimensions are chosen at construction (see
95/// [`Default`] for the 160×144 Game Boy screen). Storage is a packed planar
96/// bitplane array (see the [module docs](self)); index values are implicitly
97/// masked to the storage width on write.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct IndexedFrameBuffer<C: ColorIndex = GbColor> {
100    /// Packed planar bitplane storage, `packed_len::<C>(width, height)` bytes.
101    data: Vec<u8>,
102    /// Screen width in pixels.
103    width: usize,
104    /// Screen height in pixels.
105    height: usize,
106    #[doc(hidden)]
107    _phantom: PhantomData<C>,
108}
109
110impl<C: ColorIndex> IndexedFrameBuffer<C> {
111    /// Create a new `width × height` buffer, cleared to `clear`.
112    pub fn new(width: usize, height: usize, clear: C) -> Self {
113        let mut fb = Self {
114            data: vec![0; packed_len::<C>(width, height)],
115            width,
116            height,
117            _phantom: PhantomData,
118        };
119        fb.clear(clear);
120        fb
121    }
122
123    /// Screen width in pixels.
124    #[inline]
125    pub const fn width(&self) -> usize {
126        self.width
127    }
128
129    /// Screen height in pixels.
130    #[inline]
131    pub const fn height(&self) -> usize {
132        self.height
133    }
134
135    /// Total number of pixels.
136    #[inline]
137    pub fn len(&self) -> usize {
138        self.width * self.height
139    }
140
141    /// Whether the buffer holds no pixels.
142    #[inline]
143    pub fn is_empty(&self) -> bool {
144        self.width == 0 || self.height == 0
145    }
146
147    /// Clear the entire buffer to a single index.
148    pub fn clear(&mut self, color: C) {
149        let value = color.to_index();
150        let bits = index_bits::<C>();
151        // In planar layout, byte `i` holds bitplane `i % bits` of a row
152        // group, so filling every byte of a plane with 0xFF/0x00 paints
153        // that plane across all 8 pixels of each group.
154        for (i, byte) in self.data.iter_mut().enumerate() {
155            let plane = i % bits;
156            *byte = if (value >> plane) & 1 == 1 { 0xFF } else { 0x00 };
157        }
158    }
159
160    /// Set a single pixel. Returns false if out of bounds.
161    pub fn set_pixel(&mut self, x: u32, y: u32, color: C) -> bool {
162        if x >= self.width as u32 || y >= self.height as u32 {
163            return false;
164        }
165        let value = color.to_index();
166        let bits = index_bits::<C>();
167        let group = (x as usize) / 8;
168        let bit = 7 - ((x as usize) % 8);
169        let base = ((y as usize) * groups_per_row(self.width) + group) * bits;
170        for plane in 0..bits {
171            let plane_bit = ((value >> plane) & 1) as u8;
172            let byte = &mut self.data[base + plane];
173            *byte = (*byte & !(1 << bit)) | (plane_bit << bit);
174        }
175        true
176    }
177
178    /// Get the index of a single pixel. Returns None if out of bounds.
179    pub fn get_pixel(&self, x: u32, y: u32) -> Option<C> {
180        if x >= self.width as u32 || y >= self.height as u32 {
181            return None;
182        }
183        let bits = index_bits::<C>();
184        let group = (x as usize) / 8;
185        let bit = 7 - ((x as usize) % 8);
186        let base = ((y as usize) * groups_per_row(self.width) + group) * bits;
187        let mut value = 0usize;
188        for plane in 0..bits {
189            if (self.data[base + plane] >> bit) & 1 == 1 {
190                value |= 1 << plane;
191            }
192        }
193        Some(C::from_u8(value as u8))
194    }
195
196    /// Fill a rectangular region with an index. Coordinates are clamped to
197    /// buffer bounds.
198    pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: C) {
199        let x_start = (x as usize).min(self.width);
200        let y_start = (y as usize).min(self.height);
201        let x_end = (x.saturating_add(rect_width) as usize).min(self.width);
202        let y_end = (y.saturating_add(rect_height) as usize).min(self.height);
203        for row in y_start..y_end {
204            for col in x_start..x_end {
205                self.set_pixel(col as u32, row as u32, color);
206            }
207        }
208    }
209
210    /// Expand the indexed buffer into RGBA using `palette`.
211    ///
212    /// Writes `width * height * 4` bytes of row-major `[r, g, b, a]` pixel
213    /// data into `out`. Returns false (and writes nothing) if `out` is too
214    /// small. The palette should define an entry for every index in the
215    /// buffer.
216    pub fn to_rgba(&self, palette: &Palette<C>, out: &mut [u8]) -> bool {
217        let need = self.width * self.height * 4;
218        if out.len() < need {
219            return false;
220        }
221        let mut base = 0;
222        for y in 0..self.height {
223            for x in 0..self.width {
224                let index = self
225                    .get_pixel(x as u32, y as u32)
226                    .expect("pixel in bounds");
227                out[base..base + 4].copy_from_slice(&palette.color(index).to_array());
228                base += 4;
229            }
230        }
231        true
232    }
233
234    /// Raw packed storage, in the planar bitplane format described in the
235    /// [module docs](self). For [`GbColor`] this is GB 2bpp tile data, so
236    /// any 8×8-aligned region can be fed directly to
237    /// [`crate::tile::Tile::from_2bpp`].
238    #[inline]
239    pub fn packed(&self) -> &[u8] {
240        &self.data
241    }
242
243    /// Mutable raw packed storage (e.g. for tile blits into 8×8-aligned
244    /// regions, or for serialization). The caller must preserve the
245    /// planar bitplane layout.
246    #[inline]
247    pub fn packed_mut(&mut self) -> &mut [u8] {
248        &mut self.data
249    }
250}
251
252/// The default [`IndexedFrameBuffer`] is a 160×144 Game Boy screen,
253/// cleared to index 0.
254impl<C: ColorIndex> Default for IndexedFrameBuffer<C> {
255    fn default() -> Self {
256        Self::new(SCREEN_WIDTH, SCREEN_HEIGHT, C::from_u8(0))
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Quantization
262// ---------------------------------------------------------------------------
263
264/// Find the palette entry closest to `color`.
265///
266/// Distance is summed squared channel difference over all four channels
267/// (r, g, b, a), so transparent palette entries only win for transparent
268/// input colors. Only the first `palette.count` entries are considered;
269/// ties prefer the lower index.
270pub fn quantize<C: ColorIndex>(palette: &Palette<C>, color: Rgba) -> C {
271    let mut best = C::from_u8(0);
272    let mut best_dist = u32::MAX;
273    for i in 0..palette.count as usize {
274        let entry = palette.colors[i];
275        let dr = entry.r as i32 - color.r as i32;
276        let dg = entry.g as i32 - color.g as i32;
277        let db = entry.b as i32 - color.b as i32;
278        let da = entry.a as i32 - color.a as i32;
279        let dist = (dr * dr + dg * dg + db * db + da * da) as u32;
280        if dist < best_dist {
281            best_dist = dist;
282            best = C::from_u8(i as u8);
283        }
284    }
285    best
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::palette::{GbaColor, GRAYSCALE_PALETTE, GRAYSCALE_SPRITE_PALETTE};
292    use crate::tile::Tile;
293
294    #[test]
295    fn storage_sizes() {
296        // The flagship case from the port plan: 160×144, 2bpp → 5,760 B.
297        assert_eq!(packed_len::<GbColor>(SCREEN_WIDTH, SCREEN_HEIGHT), 5760);
298        assert_eq!(packed_len::<GbColor>(160, 144), 5760);
299        // 4-bit indices double the footprint.
300        assert_eq!(packed_len::<GbaColor>(160, 144), 11520);
301        assert_eq!(index_bits::<GbColor>(), 2);
302        assert_eq!(index_bits::<GbaColor>(), 4);
303        // The buffer itself is exactly that many bytes, no slack.
304        let fb = IndexedFrameBuffer::<GbColor>::new(160, 144, GbColor::White);
305        assert_eq!(fb.packed().len(), 5760);
306        let gba = IndexedFrameBuffer::<GbaColor>::new(160, 144, GbaColor(0));
307        assert_eq!(gba.packed().len(), 11520);
308    }
309
310    #[test]
311    fn default_is_screen_sized_cleared() {
312        let fb = IndexedFrameBuffer::<GbColor>::default();
313        assert_eq!(fb.width(), SCREEN_WIDTH);
314        assert_eq!(fb.height(), SCREEN_HEIGHT);
315        assert_eq!(fb.len(), 160 * 144);
316        assert_eq!(fb.get_pixel(0, 0), Some(GbColor::White));
317        assert_eq!(fb.get_pixel(159, 143), Some(GbColor::White));
318        let gba = IndexedFrameBuffer::<GbaColor>::default();
319        assert_eq!(gba.get_pixel(159, 143), Some(GbaColor(0)));
320    }
321
322    #[test]
323    fn packing_round_trip_gb() {
324        let mut fb = IndexedFrameBuffer::<GbColor>::new(16, 8, GbColor::White);
325        let pattern = [
326            GbColor::White,
327            GbColor::LightGray,
328            GbColor::DarkGray,
329            GbColor::Black,
330        ];
331        for y in 0..8u32 {
332            for x in 0..16u32 {
333                fb.set_pixel(x, y, pattern[((x + y) as usize) % 4]);
334            }
335        }
336        for y in 0..8u32 {
337            for x in 0..16u32 {
338                assert_eq!(
339                    fb.get_pixel(x, y),
340                    Some(pattern[((x + y) as usize) % 4]),
341                    "mismatch at ({x}, {y})"
342                );
343            }
344        }
345    }
346
347    #[test]
348    fn packing_round_trip_gba() {
349        let mut fb = IndexedFrameBuffer::<GbaColor>::new(8, 4, GbaColor(0));
350        for y in 0..4u32 {
351            for x in 0..8u32 {
352                fb.set_pixel(x, y, GbaColor(((x * 3 + y * 5) % 16) as u8));
353            }
354        }
355        for y in 0..4u32 {
356            for x in 0..8u32 {
357                assert_eq!(
358                    fb.get_pixel(x, y),
359                    Some(GbaColor(((x * 3 + y * 5) % 16) as u8))
360                );
361            }
362        }
363    }
364
365    #[test]
366    fn packing_round_trip_non_multiple_of_8() {
367        // 10×7 is not divisible by 8; the partial row group must stay
368        // readable and never alias into the next row.
369        let mut fb = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::White);
370        for y in 0..7u32 {
371            for x in 0..10u32 {
372                fb.set_pixel(x, y, GbColor::from_u8(((x + y) % 4) as u8));
373            }
374        }
375        for y in 0..7u32 {
376            for x in 0..10u32 {
377                assert_eq!(fb.get_pixel(x, y), Some(GbColor::from_u8(((x + y) % 4) as u8)));
378            }
379        }
380        // The unused bits of the partial group must read back as nothing:
381        // those pixel positions are out of bounds.
382        assert_eq!(fb.get_pixel(10, 0), None);
383        assert_eq!(fb.get_pixel(0, 7), None);
384    }
385
386    #[test]
387    fn packed_layout_is_gb_vram_bitplanes() {
388        // Pin the exact byte layout: row of [1,0,3,0,2,0,1,0] packs to
389        // plane 0 = 0b10100010 (bits 7,5,1), plane 1 = 0b00101000 (bits 5,3).
390        let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 1, GbColor::White);
391        let row = [1u8, 0, 3, 0, 2, 0, 1, 0];
392        for (x, &v) in row.iter().enumerate() {
393            fb.set_pixel(x as u32, 0, GbColor::from_u8(v));
394        }
395        assert_eq!(fb.packed(), &[0xA2, 0x28]);
396    }
397
398    #[test]
399    fn packed_data_feeds_tile_decoder() {
400        // VRAM isomorphism: a GbColor 8×8 buffer's packed bytes are GB 2bpp
401        // tile data, so Tile::from_2bpp decodes them 1:1.
402        let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 8, GbColor::White);
403        for y in 0..8u32 {
404            for x in 0..8u32 {
405                fb.set_pixel(x, y, GbColor::from_u8(((x * y) % 4) as u8));
406            }
407        }
408        let tile = Tile::from_2bpp(fb.packed());
409        for y in 0..8 {
410            for x in 0..8 {
411                assert_eq!(tile.pixels[y][x], ((x * y) % 4) as u8);
412            }
413        }
414    }
415
416    #[test]
417    fn bounds_are_checked() {
418        let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 4, GbColor::White);
419        assert!(fb.set_pixel(7, 3, GbColor::Black));
420        assert!(!fb.set_pixel(8, 0, GbColor::Black));
421        assert!(!fb.set_pixel(0, 4, GbColor::Black));
422        assert!(!fb.set_pixel(u32::MAX, 0, GbColor::Black));
423        assert_eq!(fb.get_pixel(8, 0), None);
424        assert_eq!(fb.get_pixel(0, 4), None);
425        assert_eq!(fb.get_pixel(7, 3), Some(GbColor::Black));
426    }
427
428    #[test]
429    fn clear_fills_every_pixel() {
430        let mut fb = IndexedFrameBuffer::<GbColor>::new(10, 7, GbColor::Black);
431        // Deface it.
432        fb.fill_rect(0, 0, 10, 7, GbColor::LightGray);
433        assert_eq!(fb.get_pixel(5, 3), Some(GbColor::LightGray));
434        fb.clear(GbColor::Black);
435        for y in 0..7u32 {
436            for x in 0..10u32 {
437                assert_eq!(fb.get_pixel(x, y), Some(GbColor::Black));
438            }
439        }
440        assert_eq!(fb.packed(), &[0xFF; packed_len::<GbColor>(10, 7)]);
441    }
442
443    #[test]
444    fn fill_rect_clamps_to_bounds() {
445        let mut fb = IndexedFrameBuffer::<GbColor>::new(8, 8, GbColor::White);
446        // Start beyond the edge and overflow the opposite edge.
447        fb.fill_rect(4, 4, 100, 100, GbColor::Black);
448        assert_eq!(fb.get_pixel(3, 3), Some(GbColor::White));
449        assert_eq!(fb.get_pixel(4, 3), Some(GbColor::White));
450        assert_eq!(fb.get_pixel(3, 4), Some(GbColor::White));
451        assert_eq!(fb.get_pixel(4, 4), Some(GbColor::Black));
452        assert_eq!(fb.get_pixel(7, 7), Some(GbColor::Black));
453        // Fully out of bounds: no-op.
454        fb.fill_rect(8, 8, 4, 4, GbColor::DarkGray);
455        assert_eq!(fb.get_pixel(7, 7), Some(GbColor::Black));
456    }
457
458    #[test]
459    fn to_rgba_applies_palette() {
460        let mut fb = IndexedFrameBuffer::<GbColor>::new(4, 2, GbColor::White);
461        fb.set_pixel(0, 0, GbColor::Black);
462        fb.set_pixel(3, 1, GbColor::DarkGray);
463        let pal = GRAYSCALE_PALETTE;
464        let mut out = [0u8; 4 * 2 * 4];
465        assert!(fb.to_rgba(&pal, &mut out));
466        assert_eq!(&out[0..4], &Rgba::rgb(0x00, 0x00, 0x00).to_array());
467        assert_eq!(&out[1 * 4..2 * 4], &Rgba::rgb(0xFF, 0xFF, 0xFF).to_array());
468        assert_eq!(&out[(3 + 1 * 4) * 4..(3 + 1 * 4) * 4 + 4], &Rgba::rgb(0x55, 0x55, 0x55).to_array());
469    }
470
471    #[test]
472    fn to_rgba_rejects_short_slice() {
473        let fb = IndexedFrameBuffer::<GbColor>::new(4, 2, GbColor::White);
474        let mut out = [0u8; 4 * 2 * 4 - 1];
475        assert!(!fb.to_rgba(&GRAYSCALE_PALETTE, &mut out));
476        assert_eq!(out, [0u8; 4 * 2 * 4 - 1]); // untouched
477    }
478
479    #[test]
480    fn quantize_exact_match() {
481        let pal = GRAYSCALE_PALETTE;
482        assert_eq!(quantize(&pal, Rgba::rgb(0xFF, 0xFF, 0xFF)), GbColor::White);
483        assert_eq!(quantize(&pal, Rgba::rgb(0xAA, 0xAA, 0xAA)), GbColor::LightGray);
484        assert_eq!(quantize(&pal, Rgba::rgb(0x55, 0x55, 0x55)), GbColor::DarkGray);
485        assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0x00)), GbColor::Black);
486    }
487
488    #[test]
489    fn quantize_picks_nearest() {
490        // Midway between white (255) and light gray (170) → light gray.
491        let pal = GRAYSCALE_PALETTE;
492        assert_eq!(quantize(&pal, Rgba::rgb(200, 200, 200)), GbColor::LightGray);
493        // Midway between light gray (170) and dark gray (85) → dark gray.
494        assert_eq!(quantize(&pal, Rgba::rgb(0x7F, 0x7F, 0x7F)), GbColor::DarkGray);
495        // Darkest possible input → black.
496        assert_eq!(quantize(&pal, Rgba::rgb(30, 30, 30)), GbColor::Black);
497    }
498
499    #[test]
500    fn quantize_alpha_aware() {
501        // Sprite palette entry 0 is transparent: an opaque dark pixel must
502        // not quantize to it.
503        let pal = GRAYSCALE_SPRITE_PALETTE;
504        assert_eq!(pal.colors[0], Rgba::TRANSPARENT);
505        assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0x00)), GbColor::Black);
506        // A fully transparent pixel prefers the transparent entry.
507        assert_eq!(quantize(&pal, Rgba::TRANSPARENT), GbColor::White);
508    }
509
510    #[test]
511    fn quantize_gba_palette() {
512        let mut colors = [Rgba::BLACK; 16];
513        colors[0] = Rgba::rgb(0xFF, 0x00, 0x00);
514        colors[1] = Rgba::rgb(0x00, 0xFF, 0x00);
515        colors[2] = Rgba::rgb(0x00, 0x00, 0xFF);
516        let pal = Palette::<GbaColor>::from_gba_palette(colors);
517        assert_eq!(quantize(&pal, Rgba::rgb(0xFF, 0x00, 0x00)), GbaColor(0));
518        assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0xFF, 0x00)), GbaColor(1));
519        assert_eq!(quantize(&pal, Rgba::rgb(0x00, 0x00, 0xFF)), GbaColor(2));
520        // Midway between red and green, the input lands on the closer one.
521        assert_eq!(quantize(&pal, Rgba::rgb(0xC0, 0x40, 0x00)), GbaColor(0));
522    }
523}
524
525// ---------------------------------------------------------------------------
526// DefaultPalette — per-index-type construction default
527// ---------------------------------------------------------------------------
528
529/// The palette [`RgbaIndexedFrameBuffer`] quantizes against when constructed
530/// without an explicit palette.
531pub trait DefaultPalette: ColorIndex {
532    /// Default quantization/display palette for this index type.
533    fn default_palette() -> Palette<Self>;
534}
535
536impl DefaultPalette for GbColor {
537    fn default_palette() -> Palette<Self> {
538        // The pokered render chain draws in GRAYSCALE shades, so quantizing
539        // against the grayscale palette is exact for every current draw call.
540        GRAYSCALE_PALETTE
541    }
542}
543
544impl DefaultPalette for GbaColor {
545    fn default_palette() -> Palette<Self> {
546        let mut colors = [Rgba::BLACK; 16];
547        for i in 0..16 {
548            let v = (255 - i * 17) as u8;
549            colors[i] = Rgba::rgb(v, v, v);
550        }
551        Palette::<GbaColor>::from_gba_palette(colors)
552    }
553}
554
555// ---------------------------------------------------------------------------
556// FbSurface — shared draw/present surface trait
557// ---------------------------------------------------------------------------
558
559/// A framebuffer draw surface shared by the engine's RGBA [`FrameBuffer`]
560/// and the indexed [`RgbaIndexedFrameBuffer`].
561///
562/// Draw code written against RGBA (`set_pixel` / `fill_rect` / `clear`)
563/// compiles and behaves identically on both: the indexed surface quantizes
564/// every RGBA write through its base palette. `present_into` dumps the
565/// final RGBA pixels (applying the display palette for indexed surfaces),
566/// and `pixel_rgba` reads a single pixel (used by terminal halfblock
567/// presenters).
568pub trait FbSurface: Sized {
569    /// Create a new `width × height` surface, cleared to black.
570    fn new_screen(width: u32, height: u32) -> Self;
571    /// Screen width in pixels.
572    fn width(&self) -> u32;
573    /// Screen height in pixels.
574    fn height(&self) -> u32;
575    /// Set a single pixel. Returns false if out of bounds.
576    fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool;
577    /// Get the current color of a single pixel. Returns None if out of bounds.
578    fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba>;
579    /// Clear the entire surface to a single color.
580    fn clear(&mut self, color: Rgba);
581    /// Fill a rectangular region with a color (clamped to bounds).
582    fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba);
583    /// Read a single pixel as RGBA; out-of-bounds reads return transparent.
584    fn pixel_rgba(&self, x: u32, y: u32) -> Rgba {
585        self.get_pixel(x, y).unwrap_or(Rgba::TRANSPARENT)
586    }
587    /// Dump the whole surface as row-major RGBA into `out`, which must hold
588    /// at least `width * height * 4` bytes.
589    fn present_into(&self, out: &mut [u8]);
590}
591
592impl FbSurface for dotzuki_engine::render::FrameBuffer {
593    fn new_screen(width: u32, height: u32) -> Self {
594        Self::new(RenderConfig::new(width, height), Rgba::BLACK)
595    }
596    fn width(&self) -> u32 {
597        self.width
598    }
599    fn height(&self) -> u32 {
600        self.height
601    }
602    fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
603        Self::set_pixel(self, x, y, color)
604    }
605    fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
606        Self::get_pixel(self, x, y)
607    }
608    fn clear(&mut self, color: Rgba) {
609        Self::clear(self, color)
610    }
611    fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
612        Self::fill_rect(self, x, y, rect_width, rect_height, color)
613    }
614    fn present_into(&self, out: &mut [u8]) {
615        assert!(out.len() >= self.data.len(), "present buffer too small");
616        out[..self.data.len()].copy_from_slice(&self.data);
617    }
618}
619
620// ---------------------------------------------------------------------------
621// RgbaIndexedFrameBuffer — RGBA facade over IndexedFrameBuffer
622// ---------------------------------------------------------------------------
623
624/// An [`IndexedFrameBuffer`] with an RGBA-facing facade: RGBA writes are
625/// quantized through a fixed *base* palette (so drawing is stable no matter
626/// what display effect is active), while a separate *display* palette is
627/// applied at present time.
628///
629/// Fades and flashes become palette operations, the way real GB hardware
630/// does them: swap the display palette ([`Self::set_palette`],
631/// [`Self::remap_shades`], [`Self::scale_shades`], [`Self::apply_bgp`])
632/// instead of touching every pixel. The buffer itself stays packed 2bpp —
633/// 5,760 bytes for a 160×144 screen instead of 92,160.
634///
635/// `base` doubles as the initial display palette; [`Self::reset_palette`]
636/// restores it. The indexed API remains reachable via [`Self::indexed`] /
637/// [`Self::indexed_mut`].
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct RgbaIndexedFrameBuffer<C: ColorIndex = GbColor> {
640    /// The indexed pixel storage.
641    buffer: IndexedFrameBuffer<C>,
642    /// Quantization palette: RGBA writes map to the nearest entry's index.
643    base: Palette<C>,
644    /// Display palette applied at present time; fades/flashes remap this.
645    pub palette: Palette<C>,
646}
647
648impl<C: ColorIndex> RgbaIndexedFrameBuffer<C> {
649    /// Create a `config`-sized buffer with an explicit base palette, cleared
650    /// to `clear` (quantized through `base`).
651    pub fn with_palette(config: RenderConfig, clear: Rgba, base: Palette<C>) -> Self {
652        let mut fb = Self {
653            buffer: IndexedFrameBuffer::new(
654                config.screen_width as usize,
655                config.screen_height as usize,
656                C::from_u8(0),
657            ),
658            palette: base,
659            base,
660        };
661        fb.clear(clear);
662        fb
663    }
664
665    /// The current display palette.
666    #[inline]
667    pub fn display_palette(&self) -> &Palette<C> {
668        &self.palette
669    }
670
671    /// Replace the display palette (fade/flash effect).
672    pub fn set_palette(&mut self, palette: Palette<C>) {
673        self.palette = palette;
674    }
675
676    /// Restore the display palette to the base palette.
677    pub fn reset_palette(&mut self) {
678        self.palette = self.base;
679    }
680
681    /// Remap every display shade through `map`: display color `i` becomes the
682    /// base palette entry `map[i]`. This is the indexed-buffer equivalent of
683    /// the per-pixel `remap_shades` loops (rBGP-style register writes).
684    pub fn remap_shades(&mut self, map: &[u8]) {
685        let count = self.palette.count as usize;
686        for i in 0..count {
687            let mapped = map.get(i).copied().unwrap_or(i as u8) as usize % count;
688            self.palette.colors[i] = self.base.colors[mapped];
689        }
690        self.palette.count = self.base.count;
691    }
692
693    /// Scale the display colors toward black by `scale` (0.0 = black,
694    /// 1.0 = base palette). Alpha is preserved. Mirrors the per-pixel
695    /// "brighten/darken" loops (e.g. the Ghost Marowak reveal).
696    pub fn scale_shades(&mut self, scale: f32) {
697        let scale = scale.clamp(0.0, 1.0);
698        for i in 0..self.palette.count as usize {
699            let c = self.base.colors[i];
700            self.palette.colors[i] = Rgba::new(
701                (c.r as f32 * scale) as u8,
702                (c.g as f32 * scale) as u8,
703                (c.b as f32 * scale) as u8,
704                c.a,
705            );
706        }
707    }
708
709    /// Read-only access to the underlying indexed buffer.
710    #[inline]
711    pub fn indexed(&self) -> &IndexedFrameBuffer<C> {
712        &self.buffer
713    }
714
715    /// Mutable access to the underlying indexed buffer (C-index API).
716    #[inline]
717    pub fn indexed_mut(&mut self) -> &mut IndexedFrameBuffer<C> {
718        &mut self.buffer
719    }
720
721    /// Raw packed 2bpp storage (see [`IndexedFrameBuffer::packed`]).
722    #[inline]
723    pub fn packed(&self) -> &[u8] {
724        self.buffer.packed()
725    }
726
727    /// Mutable raw packed storage.
728    #[inline]
729    pub fn packed_mut(&mut self) -> &mut [u8] {
730        self.buffer.packed_mut()
731    }
732
733    /// Expand the buffer into RGBA using the *display* palette.
734    /// Writes `width * height * 4` bytes into `out`; returns false (and
735    /// writes nothing) if `out` is too small.
736    pub fn to_rgba(&self, out: &mut [u8]) -> bool {
737        self.buffer.to_rgba(&self.palette, out)
738    }
739
740    /// Copy the pixels and display palette of `other` into this buffer.
741    /// Both buffers must have the same dimensions.
742    pub fn copy_from(&mut self, other: &Self) {
743        self.buffer.packed_mut().copy_from_slice(other.buffer.packed());
744        self.palette = other.palette;
745        self.base = other.base;
746    }
747
748    /// Set a single pixel by palette index. Returns false if out of bounds.
749    pub fn set_pixel_index(&mut self, x: u32, y: u32, color: C) -> bool {
750        self.buffer.set_pixel(x, y, color)
751    }
752
753    /// Get the palette index of a single pixel. Returns None if out of bounds.
754    pub fn get_index(&self, x: u32, y: u32) -> Option<C> {
755        self.buffer.get_pixel(x, y)
756    }
757
758    /// Clear the entire buffer to a single palette index.
759    pub fn clear_index(&mut self, color: C) {
760        self.buffer.clear(color);
761    }
762
763    /// Total number of pixels.
764    #[inline]
765    pub fn len(&self) -> usize {
766        self.buffer.len()
767    }
768
769    /// Screen width in pixels.
770    #[inline]
771    pub fn width(&self) -> u32 {
772        self.buffer.width() as u32
773    }
774
775    /// Screen height in pixels.
776    #[inline]
777    pub fn height(&self) -> u32 {
778        self.buffer.height() as u32
779    }
780
781    /// Whether the buffer holds no pixels.
782    #[inline]
783    pub fn is_empty(&self) -> bool {
784        self.buffer.is_empty()
785    }
786
787    /// Set a single pixel, quantized through the base palette.
788    /// Returns false if out of bounds.
789    pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
790        let index = quantize(&self.base, color);
791        self.buffer.set_pixel(x, y, index)
792    }
793
794    /// Get the current display color of a single pixel (through the display
795    /// palette). Returns None if out of bounds.
796    pub fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
797        self.buffer.get_pixel(x, y).map(|i| self.palette.color(i))
798    }
799
800    /// Clear the entire buffer to a single color (quantized through the base
801    /// palette).
802    ///
803    /// Also restores the display palette to the base palette. This mirrors
804    /// the RGBA buffer's contract — after a clear the framebuffer is in a
805    /// pristine state — and is what prevents fade/flash palettes from
806    /// leaking into the next frame: every frame starts with a clear, so the
807    /// display mapping always begins from the base and effects re-apply
808    /// their palette at the end of the frame.
809    pub fn clear(&mut self, color: Rgba) {
810        let index = quantize(&self.base, color);
811        self.buffer.clear(index);
812        self.palette = self.base;
813    }
814
815    /// Fill a rectangular region with a color (quantized through the base
816    /// palette). Coordinates are clamped to buffer bounds.
817    pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
818        let index = quantize(&self.base, color);
819        self.buffer.fill_rect(x, y, rect_width, rect_height, index);
820    }
821
822    /// Copy a horizontal line of RGBA data into the buffer (each pixel
823    /// quantized through the base palette). `src` must be exactly
824    /// `count * 4` bytes. Returns false if the line goes out of bounds.
825    pub fn blit_row(&mut self, x: u32, y: u32, src: &[u8], count: u32) -> bool {
826        if y >= self.height() || x >= self.width() {
827            return false;
828        }
829        let actual_count = count.min(self.width() - x) as usize;
830        let src_bytes = actual_count * 4;
831        if src.len() < src_bytes {
832            return false;
833        }
834        for i in 0..actual_count {
835            let off = i * 4;
836            let c = Rgba::new(src[off], src[off + 1], src[off + 2], src[off + 3]);
837            self.buffer
838                .set_pixel(x + i as u32, y, quantize(&self.base, c));
839        }
840        true
841    }
842
843    /// Save the framebuffer as a PNG file (display palette applied).
844    #[cfg(any(feature = "gpu", feature = "image-assets"))]
845    pub fn save_png(&self, path: &std::path::Path) -> std::io::Result<()> {
846        use image::{ImageBuffer, Rgba as ImgRgba};
847        let w = self.width() as u32;
848        let h = self.height() as u32;
849        let mut rgba = vec![0u8; (w * h * 4) as usize];
850        self.to_rgba(&mut rgba);
851        let img: ImageBuffer<ImgRgba<u8>, _> =
852            ImageBuffer::from_raw(w, h, rgba).expect("framebuffer size mismatch");
853        img.save(path)
854            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
855    }
856}
857
858impl<C: ColorIndex + DefaultPalette> RgbaIndexedFrameBuffer<C> {
859    /// Create a `config`-sized buffer using the type's default base palette,
860    /// cleared to `clear`.
861    pub fn new(config: RenderConfig, clear: Rgba) -> Self {
862        Self::with_palette(config, clear, C::default_palette())
863    }
864}
865
866impl RgbaIndexedFrameBuffer<GbColor> {
867    /// Apply a DMG BGP register byte: display color `i` becomes the base
868    /// palette's shade `(bgp >> (2 * i)) & 3`. This is exactly how the
869    /// original hardware performs fades (by writing the BGP register).
870    pub fn apply_bgp(&mut self, bgp: u8) {
871        let mut remapped = [0u8; 4];
872        for i in 0..4 {
873            remapped[i] = (bgp >> (2 * i)) & 3;
874        }
875        self.remap_shades(&remapped);
876    }
877}
878
879impl<C: ColorIndex + DefaultPalette> FbSurface for RgbaIndexedFrameBuffer<C> {
880    fn new_screen(width: u32, height: u32) -> Self {
881        Self::new(RenderConfig::new(width, height), Rgba::BLACK)
882    }
883    fn width(&self) -> u32 {
884        self.buffer.width() as u32
885    }
886    fn height(&self) -> u32 {
887        self.buffer.height() as u32
888    }
889    fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
890        self.set_pixel(x, y, color)
891    }
892    fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
893        self.get_pixel(x, y)
894    }
895    fn clear(&mut self, color: Rgba) {
896        self.clear(color)
897    }
898    fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
899        self.fill_rect(x, y, rect_width, rect_height, color)
900    }
901    fn present_into(&self, out: &mut [u8]) {
902        assert!(out.len() >= self.len() * 4, "present buffer too small");
903        self.to_rgba(out);
904    }
905}
906
907#[cfg(test)]
908mod facade_tests {
909    use super::*;
910    use crate::palette::GRAYSCALE_SPRITE_PALETTE;
911
912    fn fb() -> RgbaIndexedFrameBuffer<GbColor> {
913        RgbaIndexedFrameBuffer::new(RenderConfig::new(160, 144), Rgba::WHITE)
914    }
915
916    #[test]
917    fn storage_is_packed() {
918        let fb = fb();
919        assert_eq!(fb.len(), 160 * 144);
920        assert_eq!(fb.packed().len(), 5760);
921        assert_eq!(fb.packed().len(), packed_len::<GbColor>(160, 144));
922    }
923
924    #[test]
925    fn grayscale_round_trips_exactly() {
926        let mut fb = fb();
927        let colors = [
928            Rgba::WHITE,
929            Rgba::rgb(0xAA, 0xAA, 0xAA),
930            Rgba::rgb(0x55, 0x55, 0x55),
931            Rgba::BLACK,
932        ];
933        for (i, &c) in colors.iter().enumerate() {
934            assert!(fb.set_pixel(i as u32, 0, c));
935        }
936        for (i, &c) in colors.iter().enumerate() {
937            assert_eq!(fb.get_pixel(i as u32, 0), Some(c));
938            assert_eq!(
939                fb.get_index(i as u32, 0),
940                Some(GbColor::from_u8(i as u8))
941            );
942        }
943    }
944
945    #[test]
946    fn near_grays_quantize_to_nearest_shade() {
947        let mut fb = fb();
948        // 0xC0 → light gray (170), 0x80 → light gray (170), 0x40 → dark gray.
949        fb.set_pixel(0, 0, Rgba::rgb(0xC0, 0xC0, 0xC0));
950        fb.set_pixel(1, 0, Rgba::rgb(0x80, 0x80, 0x80));
951        fb.set_pixel(2, 0, Rgba::rgb(0x40, 0x40, 0x40));
952        assert_eq!(fb.get_index(0, 0), Some(GbColor::LightGray));
953        assert_eq!(fb.get_index(1, 0), Some(GbColor::LightGray));
954        assert_eq!(fb.get_index(2, 0), Some(GbColor::DarkGray));
955    }
956
957    #[test]
958    fn transparent_writes_pick_nearest_opaque_shade() {
959        // GRAYSCALE_PALETTE has no transparent entry, so a transparent write
960        // quantizes to the nearest opaque color: black. Presenters ignore
961        // alpha (native/web textures, TUI halfblocks), so this matches what
962        // the old RGBA buffer displayed for such pixels.
963        let mut fb = fb();
964        fb.set_pixel(3, 3, Rgba::TRANSPARENT);
965        assert_eq!(fb.get_index(3, 3), Some(GbColor::Black));
966    }
967
968    #[test]
969    fn bounds_checked_rgba_facade() {
970        let mut fb = fb();
971        assert!(fb.set_pixel(159, 143, Rgba::BLACK));
972        assert!(!fb.set_pixel(160, 0, Rgba::BLACK));
973        assert!(!fb.set_pixel(0, 144, Rgba::BLACK));
974        assert_eq!(fb.get_pixel(160, 0), None);
975        assert_eq!(fb.pixel_rgba(160, 0), Rgba::TRANSPARENT);
976    }
977
978    #[test]
979    fn clear_and_fill_quantize() {
980        let mut fb = fb();
981        fb.fill_rect(0, 0, 100, 100, Rgba::rgb(0x55, 0x55, 0x55));
982        assert_eq!(fb.get_index(50, 50), Some(GbColor::DarkGray));
983        fb.clear(Rgba::BLACK);
984        assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
985        assert_eq!(fb.get_index(159, 143), Some(GbColor::Black));
986    }
987
988    #[test]
989    fn blit_row_quantizes_each_pixel() {
990        let mut fb = fb();
991        let row = [255u8, 255, 255, 255, 0, 0, 0, 0];
992        assert!(fb.blit_row(0, 0, &row, 2));
993        assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
994        assert_eq!(fb.get_index(1, 0), Some(GbColor::Black));
995        assert!(!fb.blit_row(160, 0, &row, 2));
996        assert!(!fb.blit_row(0, 0, &row, 3)); // src too short
997    }
998
999    #[test]
1000    fn remap_shades_inverts() {
1001        let mut fb = fb();
1002        fb.fill_rect(0, 0, 8, 8, Rgba::BLACK);
1003        // Invert: 0→3, 1→2, 2→1, 3→0.
1004        fb.remap_shades(&[3, 2, 1, 0]);
1005        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::WHITE));
1006        // Display palette changed; the index underneath is untouched.
1007        assert_eq!(fb.get_index(0, 0), Some(GbColor::Black));
1008        fb.reset_palette();
1009        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
1010    }
1011
1012    #[test]
1013    fn apply_bgp_fade_to_black() {
1014        let mut fb = fb();
1015        fb.set_pixel(0, 0, Rgba::WHITE);
1016        // rBGP = dc 3,3,3,3 → every shade maps to black.
1017        fb.apply_bgp(0b11111111);
1018        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
1019        assert_eq!(fb.get_index(0, 0), Some(GbColor::White));
1020    }
1021
1022    #[test]
1023    fn scale_shades_dims_display() {
1024        let mut fb = fb();
1025        fb.fill_rect(0, 0, 8, 8, Rgba::WHITE);
1026        fb.scale_shades(0.5);
1027        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::rgb(127, 127, 127)));
1028        fb.scale_shades(0.0);
1029        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
1030    }
1031
1032    #[test]
1033    fn palette_swap_does_not_touch_draws() {
1034        let mut fb = fb();
1035        fb.set_pixel(4, 4, Rgba::rgb(0x55, 0x55, 0x55));
1036        fb.apply_bgp(0b11100100); // identity-ish fade state
1037        // Drawing while a display effect is active still quantizes via base.
1038        fb.set_pixel(5, 4, Rgba::rgb(0xAA, 0xAA, 0xAA));
1039        fb.reset_palette();
1040        assert_eq!(fb.get_pixel(5, 4), Some(Rgba::rgb(0xAA, 0xAA, 0xAA)));
1041    }
1042
1043    #[test]
1044    fn copy_from_copies_pixels_and_palette() {
1045        let mut src = fb();
1046        src.fill_rect(0, 0, 16, 16, Rgba::BLACK);
1047        src.apply_bgp(0b00000000); // all white
1048        let mut dst = fb();
1049        dst.copy_from(&src);
1050        assert_eq!(dst.get_index(8, 8), Some(GbColor::Black));
1051        assert_eq!(dst.get_pixel(8, 8), Some(Rgba::WHITE));
1052        assert_eq!(dst.packed(), src.packed());
1053    }
1054
1055    #[test]
1056    fn clear_resets_display_palette() {
1057        let mut fb = fb();
1058        fb.apply_bgp(0b00000000); // white-out display palette
1059        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::WHITE));
1060        fb.clear(Rgba::BLACK);
1061        // Clear restores the base display mapping: black stays black.
1062        assert_eq!(fb.get_pixel(0, 0), Some(Rgba::BLACK));
1063        fb.set_pixel(1, 0, Rgba::WHITE);
1064        assert_eq!(fb.get_pixel(1, 0), Some(Rgba::WHITE));
1065    }
1066
1067    #[test]
1068    fn to_rgba_uses_display_palette() {
1069        let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new(RenderConfig::new(2, 1), Rgba::WHITE);
1070        fb.set_pixel(0, 0, Rgba::WHITE);
1071        fb.apply_bgp(0b00000000); // white-out
1072        let mut out = [0u8; 8];
1073        assert!(fb.to_rgba(&mut out));
1074        assert_eq!(&out[0..4], &[0xFF, 0xFF, 0xFF, 0xFF]);
1075    }
1076
1077    #[test]
1078    fn fb_surface_present_and_pixels() {
1079        let mut fb = RgbaIndexedFrameBuffer::<GbColor>::new_screen(4, 2);
1080        fb.set_pixel(1, 1, Rgba::WHITE);
1081        assert_eq!(fb.width(), 4);
1082        assert_eq!(fb.height(), 2);
1083        assert_eq!(fb.pixel_rgba(1, 1), Rgba::WHITE);
1084        assert_eq!(fb.pixel_rgba(0, 0), Rgba::BLACK);
1085        let mut out = [0u8; 4 * 2 * 4];
1086        fb.present_into(&mut out);
1087        assert_eq!(&out[5 * 4..6 * 4], &[0xFF, 0xFF, 0xFF, 0xFF]);
1088    }
1089
1090    #[test]
1091    fn sprite_palette_quantization_matches_draw_palette() {
1092        // The pokered sprite path draws with GRAYSCALE_SPRITE_PALETTE colors;
1093        // quantizing those through the facade base must recover the original
1094        // indices. Color 0 is transparent and quantizes to black (nearest
1095        // opaque shade in the grayscale base palette) — the same visual the
1096        // RGBA buffer produced, since presenters ignore alpha.
1097        let mut fb = fb();
1098        for (i, &c) in GRAYSCALE_SPRITE_PALETTE.colors[..4].iter().enumerate() {
1099            fb.set_pixel(i as u32, 0, c);
1100            let expected = if i == 0 { GbColor::Black } else { GbColor::from_u8(i as u8) };
1101            assert_eq!(fb.get_index(i as u32, 0), Some(expected));
1102        }
1103    }
1104}