Skip to main content

retroglyph_window/
sprite_cache.rs

1//! Decoded sprite cache: PNG decoding, tile extraction, and runtime lookup.
2//!
3//! The [`SpriteCache`] is built from [`TilesetOptions`]
4//! and provides O(1) lookup of decoded RGBA8 sprites by codepoint.
5
6use crate::tileset::{TilesetError, TilesetOptions};
7use alpha_blend::rgba::U8x4Rgba;
8use std::collections::BTreeMap;
9
10/// A decoded, ready-to-blit sprite.
11#[derive(Debug, Clone)]
12pub struct Sprite {
13    /// RGBA8 pixel data, row-major, `pixel_width * pixel_height * 4` bytes.
14    pub pixels: Vec<u8>,
15    /// Pixel width of the sprite.
16    pub pixel_width: u32,
17    /// Pixel height of the sprite.
18    pub pixel_height: u32,
19    /// How many grid cells wide this sprite is.
20    pub spacing_cells_x: u16,
21    /// How many grid cells tall this sprite is.
22    pub spacing_cells_y: u16,
23}
24
25/// Cache of decoded sprites, keyed by Unicode codepoint.
26///
27/// # Reload / hot-swap is not supported
28///
29/// [`load`](Self::load) is append-only: it decodes a tileset and merges its sprites into the
30/// existing map, with later registrations winning on codepoint collision (see [`load`](Self::load)
31/// docs). There is no `unload` or `clear`, and nothing observes or invalidates sprites already
32/// handed out via [`get`](Self::get).
33///
34/// This is a deliberate scope decision, not an oversight: games generally don't hot-swap tilesets
35/// at runtime, and a `SpriteCache` is only ever populated once, when a backend is built. If you
36/// need to iterate on a sprite sheet (e.g. during dev-mode asset editing) or otherwise want a
37/// tileset change to take effect, rebuild the whole renderer from a fresh backend configuration
38/// rather than mutating an existing cache in place.
39#[derive(Debug)]
40pub struct SpriteCache {
41    sprites: BTreeMap<char, Sprite>,
42}
43
44impl SpriteCache {
45    /// Creates an empty sprite cache.
46    #[must_use]
47    pub const fn new() -> Self {
48        Self {
49            sprites: BTreeMap::new(),
50        }
51    }
52
53    /// Returns the sprite for `ch`, if registered.
54    #[must_use]
55    pub fn get(&self, ch: char) -> Option<&Sprite> {
56        self.sprites.get(&ch)
57    }
58
59    /// Iterates every registered `(codepoint, sprite)` in codepoint order.
60    ///
61    /// Used by GPU backends to build a sprite atlas from the whole decoded set (the software
62    /// backend only ever needs per-glyph [`get`](Self::get) at blit time).
63    #[must_use]
64    pub fn iter(&self) -> impl ExactSizeIterator<Item = (char, &Sprite)> {
65        self.sprites.iter().map(|(&ch, sprite)| (ch, sprite))
66    }
67
68    /// Whether any sprite is registered.
69    #[must_use]
70    pub fn is_empty(&self) -> bool {
71        self.sprites.is_empty()
72    }
73
74    /// Loads a tileset, decoding the PNG and inserting all sprites.
75    ///
76    /// On codepoint collision, the new sprite replaces the old one and a
77    /// message is logged via `log::warn`.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`TilesetError::PngDecode`] if the bytes are not a valid PNG,
82    /// [`TilesetError::ZeroTileSize`] if `opts.tile_width` or `opts.tile_height`
83    /// is 0, or [`TilesetError::InvalidDimensions`] if the decoded image
84    /// dimensions are not evenly divisible by the tile size.
85    #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
86    pub fn load(&mut self, opts: &TilesetOptions) -> Result<(), TilesetError> {
87        let img = image::load_from_memory(&opts.bytes)
88            .map_err(|e| TilesetError::PngDecode(e.to_string()))?
89            .into_rgba8();
90
91        let img_w = img.width();
92        let img_h = img.height();
93        let tile_w = u32::from(opts.tile_width);
94        let tile_h = u32::from(opts.tile_height);
95
96        if tile_w == 0 || tile_h == 0 {
97            return Err(TilesetError::ZeroTileSize);
98        }
99        if img_w % tile_w != 0 || img_h % tile_h != 0 {
100            return Err(TilesetError::InvalidDimensions(
101                img_w,
102                img_h,
103                opts.tile_width,
104                opts.tile_height,
105            ));
106        }
107
108        let columns = opts.columns.map_or(img_w / tile_w, u32::from);
109        let rows = img_h / tile_h;
110        let total_tiles = (columns * rows) as usize;
111
112        let raw = img.as_raw();
113
114        for tile_idx in 0..total_tiles {
115            let Some(codepoint) = opts.codepage.codepoint(tile_idx) else {
116                break;
117            };
118
119            let tile_col = (tile_idx as u32) % columns;
120            let tile_row = (tile_idx as u32) / columns;
121
122            // Extract RGBA8 sub-image for this tile.
123            let px_x = tile_col * tile_w;
124            let px_y = tile_row * tile_h;
125            let mut pixels = vec![0u8; (tile_w * tile_h * 4) as usize];
126
127            for row in 0..tile_h {
128                let src_start = ((px_y + row) * img_w + px_x) as usize * 4;
129                let dst_start = (row * tile_w) as usize * 4;
130                pixels[dst_start..dst_start + (tile_w as usize * 4)]
131                    .copy_from_slice(&raw[src_start..src_start + (tile_w as usize * 4)]);
132            }
133
134            // Apply transparent colour key if set.
135            if let Some((kr, kg, kb)) = opts.transparent_color {
136                for px in pixels.chunks_exact_mut(4) {
137                    if px[0] == kr && px[1] == kg && px[2] == kb {
138                        px[3] = 0;
139                    }
140                }
141            }
142
143            let sprite = Sprite {
144                pixels,
145                pixel_width: tile_w,
146                pixel_height: tile_h,
147                spacing_cells_x: opts.spacing_cells_x,
148                spacing_cells_y: opts.spacing_cells_y,
149            };
150
151            if self.sprites.insert(codepoint, sprite).is_some() {
152                #[allow(clippy::cast_lossless)]
153                let cp = codepoint as u32;
154                log::warn!("tileset codepoint collision: U+{cp:04X} '{codepoint}' overwritten");
155            }
156        }
157        Ok(())
158    }
159}
160
161impl Default for SpriteCache {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167/// Blends `src` over `dst` using the Porter-Duff `SRC_OVER` operator for
168/// straight-alpha pixels: `out = src + dst * (1 - src.a)` for each channel,
169/// including alpha.
170///
171/// Delegates to the `alpha-blend` crate's `BlendMode::SourceOver`, converting
172/// through `F32x4Rgba` for the blend math.
173#[inline]
174#[must_use]
175pub fn source_over(src: U8x4Rgba, dst: U8x4Rgba) -> U8x4Rgba {
176    use alpha_blend::rgba::F32x4Rgba;
177    use alpha_blend::{BlendMode, RgbaBlend};
178    BlendMode::SourceOver
179        .apply(F32x4Rgba::from(src), F32x4Rgba::from(dst))
180        .into()
181}
182
183// ── Tests ─────────────────────────────────────────────────────────────────
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::tileset::{Codepage, TilesetOptions};
189    use image::ImageEncoder;
190
191    /// Build a programmatic RGBA8 PNG for testing.
192    ///
193    /// Each tile is filled with a unique color derived from its column/row
194    /// position so that tests can verify tile extraction.
195    #[allow(clippy::cast_possible_truncation)]
196    fn make_test_png(tile_w: u32, tile_h: u32, cols: u32, rows: u32) -> Vec<u8> {
197        let img_w = tile_w * cols;
198        let img_h = tile_h * rows;
199        let mut pixels = vec![0u8; (img_w * img_h * 4) as usize];
200
201        for row in 0..rows {
202            for col in 0..cols {
203                let r = ((col * 20) % 256) as u8;
204                let g = ((row * 20) % 256) as u8;
205                for py in 0..tile_h {
206                    for px in 0..tile_w {
207                        let idx = ((row * tile_h + py) * img_w + col * tile_w + px) as usize * 4;
208                        pixels[idx] = r;
209                        pixels[idx + 1] = g;
210                        pixels[idx + 2] = 0;
211                        pixels[idx + 3] = 255;
212                    }
213                }
214            }
215        }
216
217        let mut out = std::io::Cursor::new(Vec::new());
218        let encoder = image::codecs::png::PngEncoder::new(&mut out);
219        encoder
220            .write_image(&pixels, img_w, img_h, image::ExtendedColorType::Rgba8)
221            .unwrap();
222        out.into_inner()
223    }
224
225    #[test]
226    fn sprite_cache_load_cp437_sheet() {
227        let png = make_test_png(16, 16, 16, 16); // 256 tiles
228        let opts = TilesetOptions::from_bytes(png)
229            .tile_size(16, 16)
230            .codepage(Codepage::Cp437)
231            .build()
232            .unwrap();
233        let mut cache = SpriteCache::new();
234        cache.load(&opts).unwrap();
235        let sprite = cache.get('@').expect("'@' must be in CP437 cache");
236        assert_eq!(sprite.pixel_width, 16);
237        assert_eq!(sprite.pixel_height, 16);
238        assert_eq!(sprite.pixels.len(), 16 * 16 * 4);
239    }
240
241    #[test]
242    fn sprite_cache_rejects_bad_dimensions() {
243        let png = make_test_png(17, 16, 1, 1);
244        let opts = TilesetOptions::from_bytes(png)
245            .tile_size(16, 16)
246            .build()
247            .unwrap();
248        let mut cache = SpriteCache::new();
249        let err = cache.load(&opts).unwrap_err();
250        assert!(matches!(
251            err,
252            TilesetError::InvalidDimensions(17, 16, 16, 16)
253        ));
254    }
255
256    #[test]
257    fn sprite_cache_load_empty_bytes_errors() {
258        let opts = TilesetOptions::from_bytes(vec![])
259            .tile_size(16, 16)
260            .build()
261            .unwrap();
262        let mut cache = SpriteCache::new();
263        assert!(matches!(cache.load(&opts), Err(TilesetError::PngDecode(_))));
264    }
265
266    #[test]
267    fn sprite_cache_last_registration_wins_on_collision() {
268        let png1 = make_test_png(16, 16, 1, 1);
269        let png2 = make_test_png(8, 8, 1, 1);
270        let opts1 = TilesetOptions::from_bytes(png1)
271            .tile_size(16, 16)
272            .start_codepoint('A')
273            .build()
274            .unwrap();
275        let opts2 = TilesetOptions::from_bytes(png2)
276            .tile_size(8, 8)
277            .start_codepoint('A')
278            .build()
279            .unwrap();
280        let mut cache = SpriteCache::new();
281        cache.load(&opts1).unwrap();
282        cache.load(&opts2).unwrap();
283        let sprite = cache.get('A').unwrap();
284        assert_eq!(sprite.pixel_width, 8); // opts2 wins
285    }
286
287    #[test]
288    fn sprite_cache_load_identity_codepage() {
289        let png = make_test_png(16, 16, 4, 1); // 4 tiles: index 0..3
290        let opts = TilesetOptions::from_bytes(png)
291            .tile_size(16, 16)
292            .codepage(Codepage::Identity)
293            .build()
294            .unwrap();
295        let mut cache = SpriteCache::new();
296        cache.load(&opts).unwrap();
297        // Tile 0 -> char '\0', tile 1 -> '\x01', etc.
298        assert!(cache.get('\0').is_some());
299        assert!(cache.get('\x01').is_some());
300        assert!(cache.get('\x03').is_some());
301        assert!(cache.get('\x04').is_none()); // only 4 tiles
302    }
303
304    #[test]
305    fn sprite_cache_custom_codepage_stops_at_table_end() {
306        let png = make_test_png(16, 16, 4, 1); // 4 tiles
307        let opts = TilesetOptions::from_bytes(png)
308            .tile_size(16, 16)
309            .codepage(Codepage::Custom(vec!['A', 'B'])) // only 2 entries
310            .build()
311            .unwrap();
312        let mut cache = SpriteCache::new();
313        cache.load(&opts).unwrap();
314        assert!(cache.get('A').is_some());
315        assert!(cache.get('B').is_some());
316        assert!(cache.get('C').is_none()); // tile index 2 unmapped
317    }
318
319    // ── source_over tests ────────────────────────────────────────────────
320
321    #[test]
322    fn source_over_opaque_overwrites_destination() {
323        let src = U8x4Rgba::new(0, 255, 0, 255); // opaque green
324        let dst = U8x4Rgba::new(255, 0, 0, 255); // opaque red
325        let result = source_over(src, dst);
326        assert_eq!(result, src);
327    }
328
329    #[test]
330    fn source_over_transparent_preserves_destination() {
331        let src = U8x4Rgba::TRANSPARENT;
332        let dst = U8x4Rgba::new(255, 0, 0, 255);
333        let result = source_over(src, dst);
334        assert_eq!(result, dst);
335    }
336
337    #[test]
338    fn source_over_half_alpha_blends() {
339        // Green at 50% over red at 100%.
340        let src = U8x4Rgba::new(0, 255, 0, 128);
341        let dst = U8x4Rgba::new(255, 0, 0, 255);
342        let result = source_over(src, dst);
343        // Expected (using float reference):
344        //   out.r = 0*0.5 + 255*0.5 = 127.5  -> 127
345        //   out.g = 255*0.5 + 0*0.5 = 127.5  -> 127
346        //   out.b = 0*0.5 + 0*0.5 = 0
347        //   out.a = 128 + 255*(1-128/255) = 128 + 127 = 255
348        // Porter-Duff SRC_OVER applied uniformly to all channels (including alpha):
349        //   out.r = 0.0*128 + 255.0*127 ≈ 127   (0.498*255)
350        //   out.g = 255.0*128 + 0.0*127 ≈ 128   (0.502*255)
351        //   out.b = 0
352        //   out.a = 128*128 + 255*127 ≈ 191      (0.750*255)
353        assert_eq!(result.r, 127);
354        assert_eq!(result.g, 128);
355        assert_eq!(result.b, 0);
356        assert_eq!(result.a, 191);
357    }
358}