Skip to main content

retroglyph_window/
sprite_cache.rs

1//! Decoded sprite cache: sprite sheet 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::{SheetColor, SpriteAlign, TilesetError, TilesetOptions};
7// Only used by the source_over tests below (retroglyph#547): production code no longer has its
8// own source_over to exercise this type through, now that it delegates to the inherent
9// U8x4Rgba::source_over directly at its real call sites.
10#[cfg(test)]
11use alpha_blend::rgba::U8x4Rgba;
12use retroglyph_core::color::{Color, Tint};
13use retroglyph_core::dev_only;
14use std::collections::{BTreeMap, BTreeSet};
15
16/// A decoded, ready-to-blit sprite.
17#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct Sprite {
20    /// RGBA8 pixel data, row-major, `pixel_width * pixel_height * 4` bytes.
21    pub pixels: Vec<u8>,
22    /// Pixel width of the sprite.
23    pub pixel_width: u32,
24    /// Pixel height of the sprite.
25    pub pixel_height: u32,
26    /// Where this sprite sits inside the multi-cell box a span reserves for it.
27    pub align: SpriteAlign,
28    /// What this sprite's own sheet declared its pixels to mean.
29    ///
30    /// Copied from the sheet at load time rather than looked up at draw time: a `SpriteCache` is
31    /// a flat map keyed by codepoint, so a sprite loses track of which sheet it came from the
32    /// moment it lands there. One byte per sprite keeps the sheet's declaration with the pixels
33    /// it describes.
34    pub color: SheetColor,
35}
36
37impl Sprite {
38    /// Returns the offset, in unscaled pixels, from the top-left corner of a `span_w` x `span_h`
39    /// cell box to where this sprite's own top-left pixel belongs, per [`align`](Self::align).
40    ///
41    /// `span_w`/`span_h` come from [`Tile::span`](retroglyph_core::tile::Tile::span) and `glyph_w`/
42    /// `glyph_h` are the unscaled cell size, so the box is `span_w * glyph_w` x
43    /// `span_h * glyph_h` pixels. A zero cell size is treated as one pixel, leaving the sprite on
44    /// its anchor rather than offsetting it by a meaningless amount.
45    ///
46    /// Returns `(0, 0)` whenever the art already fills its box, which is the common case, so a
47    /// backend can add the result to a tile's
48    /// [`dx`](retroglyph_core::tile::Tile::dx)/[`dy`](retroglyph_core::tile::Tile::dy) unconditionally.
49    #[must_use]
50    pub const fn align_offset(
51        &self,
52        span_w: u16,
53        span_h: u16,
54        glyph_w: u8,
55        glyph_h: u8,
56    ) -> (i16, i16) {
57        self.align.offset_in_span(
58            self.pixel_width,
59            self.pixel_height,
60            span_w,
61            span_h,
62            glyph_w,
63            glyph_h,
64        )
65    }
66}
67
68/// Cache of decoded sprites, keyed by Unicode codepoint.
69///
70/// # Reload / hot-swap is not supported
71///
72/// [`load`](Self::load) is append-only: it decodes a tileset and merges its sprites into the
73/// existing map, with later registrations winning on codepoint collision (see [`load`](Self::load)
74/// docs). There is no `unload` or `clear`, and nothing observes or invalidates sprites already
75/// handed out via [`get`](Self::get).
76///
77/// This is a deliberate scope decision, not an oversight: games generally don't hot-swap tilesets
78/// at runtime, and a `SpriteCache` is only ever populated once, when a backend is built. If you
79/// need to iterate on a sprite sheet (e.g. during dev-mode asset editing) or otherwise want a
80/// tileset change to take effect, rebuild the whole renderer from a fresh backend configuration
81/// rather than mutating an existing cache in place.
82#[derive(Debug)]
83pub struct SpriteCache {
84    sprites: BTreeMap<char, Sprite>,
85}
86
87impl SpriteCache {
88    /// Creates an empty sprite cache.
89    #[must_use]
90    pub const fn new() -> Self {
91        Self {
92            sprites: BTreeMap::new(),
93        }
94    }
95
96    /// Returns the sprite for `ch`, if registered.
97    #[must_use]
98    pub fn get(&self, ch: char) -> Option<&Sprite> {
99        self.sprites.get(&ch)
100    }
101
102    /// Iterates every registered `(codepoint, sprite)` in codepoint order.
103    ///
104    /// Used by GPU backends to build a sprite atlas from the whole decoded set (the software
105    /// backend only ever needs per-glyph [`get`](Self::get) at blit time).
106    #[must_use]
107    pub fn iter(&self) -> impl ExactSizeIterator<Item = (char, &Sprite)> {
108        self.sprites.iter().map(|(&ch, sprite)| (ch, sprite))
109    }
110
111    /// Whether any sprite is registered.
112    #[must_use]
113    pub fn is_empty(&self) -> bool {
114        self.sprites.is_empty()
115    }
116
117    /// Builds a cache by [`load`](Self::load)ing every tileset in `opts`, in order.
118    ///
119    /// This is what both pixel backends call from their builder's `build`/`into_renderer`
120    /// instead of each looping over their own configured tilesets by hand; later tilesets win
121    /// on codepoint collision, same as calling [`load`](Self::load) directly in a loop.
122    ///
123    /// # Errors
124    ///
125    /// Returns the first [`TilesetError`] any tileset's [`load`](Self::load) call fails with; no
126    /// later tileset is loaded once one fails.
127    pub fn from_tilesets(opts: &[TilesetOptions]) -> Result<Self, TilesetError> {
128        let mut cache = Self::new();
129        for tileset in opts {
130            cache.load(tileset)?;
131        }
132        Ok(cache)
133    }
134
135    /// Loads a tileset, decoding the sprite sheet and inserting all sprites.
136    ///
137    /// On codepoint collision, the new sprite replaces the old one and a
138    /// message is logged via `log::warn`. Unlike [`warn_sprite_needs_span`] and
139    /// [`warn_tint_needs_sprite`], this warning is not gated behind
140    /// [`dev_only!`](retroglyph_core::dev_only): it fires at most once per tileset load rather
141    /// than once per frame, so it needs no `seen` dedup table and has no redraw-loop cost, and it
142    /// reports a tileset/codepage authoring mistake a consumer may want visible even in a shipped
143    /// build. See the "Load-time versus per-frame" section of [`retroglyph_core::dev`]'s module
144    /// docs.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`TilesetError::ImageDecode`] if the bytes are not a valid image,
149    /// [`TilesetError::ZeroTileSize`] if `opts.tile_width` or `opts.tile_height`
150    /// is 0, [`TilesetError::InvalidDimensions`] if the decoded image
151    /// dimensions are not evenly divisible by the tile size, or
152    /// [`TilesetError::TooManyColumns`] if `opts.columns` declares more columns
153    /// than the image actually has at `opts.tile_width`.
154    #[allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
155    pub fn load(&mut self, opts: &TilesetOptions) -> Result<(), TilesetError> {
156        let img = image::load_from_memory(&opts.bytes)
157            .map_err(|e| TilesetError::ImageDecode(e.to_string()))?
158            .into_rgba8();
159
160        let img_w = img.width();
161        let img_h = img.height();
162        let tile_w = u32::from(opts.tile_width);
163        let tile_h = u32::from(opts.tile_height);
164
165        if tile_w == 0 || tile_h == 0 {
166            return Err(TilesetError::ZeroTileSize);
167        }
168        if img_w % tile_w != 0 || img_h % tile_h != 0 {
169            return Err(TilesetError::InvalidDimensions(
170                img_w,
171                img_h,
172                opts.tile_width,
173                opts.tile_height,
174            ));
175        }
176
177        let natural_columns = img_w / tile_w;
178        let columns = opts.columns.map_or(natural_columns, u32::from);
179        if columns > natural_columns {
180            return Err(TilesetError::TooManyColumns(
181                opts.columns.unwrap_or(0),
182                natural_columns,
183            ));
184        }
185        let rows = img_h / tile_h;
186        let total_tiles = (columns * rows) as usize;
187
188        let raw = img.as_raw();
189
190        for tile_idx in 0..total_tiles {
191            let Some(codepoint) = opts.codepage.codepoint(tile_idx) else {
192                continue;
193            };
194
195            let tile_col = (tile_idx as u32) % columns;
196            let tile_row = (tile_idx as u32) / columns;
197
198            // Extract RGBA8 sub-image for this tile.
199            let px_x = tile_col * tile_w;
200            let px_y = tile_row * tile_h;
201            let mut pixels = vec![0u8; (tile_w * tile_h * 4) as usize];
202
203            for row in 0..tile_h {
204                let src_start = ((px_y + row) * img_w + px_x) as usize * 4;
205                let dst_start = (row * tile_w) as usize * 4;
206                pixels[dst_start..dst_start + (tile_w as usize * 4)]
207                    .copy_from_slice(&raw[src_start..src_start + (tile_w as usize * 4)]);
208            }
209
210            // Apply transparent colour key if set.
211            if let Some((kr, kg, kb)) = opts.transparent_color {
212                for px in pixels.chunks_exact_mut(4) {
213                    if px[0] == kr && px[1] == kg && px[2] == kb {
214                        px[3] = 0;
215                    }
216                }
217            }
218
219            let sprite = Sprite {
220                pixels,
221                pixel_width: tile_w,
222                pixel_height: tile_h,
223                align: opts.align,
224                color: opts.color,
225            };
226
227            if self.sprites.insert(codepoint, sprite).is_some() {
228                // Not `dev_only!`: this fires once at load, not once per frame, so it has no
229                // redraw-loop cost to gate away, and it reports a tileset authoring mistake
230                // worth seeing even in a shipped build. See `load`'s doc comment.
231                #[allow(clippy::cast_lossless)]
232                let cp = codepoint as u32;
233                log::warn!("tileset codepoint collision: U+{cp:04X} '{codepoint}' overwritten");
234            }
235        }
236        Ok(())
237    }
238}
239
240impl Default for SpriteCache {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246/// The complete recolouring one sprite goes through in one cell: the sheet's own treatment,
247/// then the cell's tint.
248///
249/// Two stages rather than one because they do not always fold together. A [`SheetColor::Mask`]
250/// sheet is a multiply by the cell's foreground, and a multiply composes with another multiply,
251/// but not with a [`Tint::Mix`]: "colour this mask red, then flash it half-way to white" is two
252/// operations and cannot be written as one.
253///
254/// Both pixel backends resolve through here, so a sprite recoloured on the software rasteriser
255/// and the same sprite recoloured in the GL fragment shader cannot disagree. The GL side uploads
256/// the two stages as instance attributes and mirrors [`apply`](Self::apply)'s order.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
258pub struct SpriteTint {
259    /// The sheet's own treatment, applied first.
260    ///
261    /// [`Tint::Multiply`] by the cell's resolved foreground colour for a [`SheetColor::Mask`]
262    /// sheet, [`Tint::None`] for [`SheetColor::Art`].
263    pub mask: Tint,
264    /// The cell's own tint, applied second.
265    pub tint: Tint,
266}
267
268impl SpriteTint {
269    /// Resolves what a sprite from a `sheet`-coloured tileset should look like in a cell with
270    /// foreground `fg` and tint `tint`.
271    ///
272    /// Takes the sheet's declaration rather than the [`Sprite`] itself, because that is all the
273    /// answer depends on: the GPU backend resolves against an atlas slot and never holds the
274    /// pixels at draw time.
275    ///
276    /// `default_fg` is the palette fallback for [`Color::Default`], which has no reading as a
277    /// modulation value on its own (see [`Tint`]); [`palette::DEFAULT_FG`](crate::palette) is
278    /// what both backends pass.
279    #[must_use]
280    pub const fn resolve(
281        sheet: SheetColor,
282        fg: Color,
283        tint: Tint,
284        default_fg: (u8, u8, u8),
285    ) -> Self {
286        let mask = match sheet {
287            SheetColor::Art => Tint::None,
288            SheetColor::Mask => {
289                let (r, g, b) = fg.resolve_rgb(default_fg);
290                Tint::multiply(r, g, b)
291            }
292        };
293        Self { mask, tint }
294    }
295
296    /// Whether this leaves every pixel exactly as authored, so a renderer can take its untinted
297    /// path.
298    #[must_use]
299    pub const fn is_identity(&self) -> bool {
300        self.mask.is_identity() && self.tint.is_identity()
301    }
302
303    /// Applies both stages to one straight-alpha RGB triple, sheet treatment first.
304    #[must_use]
305    pub const fn apply(&self, rgb: (u8, u8, u8)) -> (u8, u8, u8) {
306        self.tint.apply(self.mask.apply(rgb))
307    }
308}
309
310/// Warns, at most once per glyph, that `glyph`'s sprite is larger than one cell but was drawn
311/// without a span reserving the cells it covers.
312///
313/// For backend implementors: both graphical backends call this from their sprite blit, so the
314/// diagnostic and the fix it names are identical on each. Such a sprite still draws at its
315/// natural size, but its pixels land in neighbouring cells that go on painting their own
316/// background and glyph over it, which is a confusing thing to debug from the rendered output
317/// alone.
318///
319/// `sprite` and `cell` are `(width, height)` in unscaled pixels; a sprite fitting within `cell`
320/// on both axes is silent. `seen` is caller-owned state so a redraw loop reports each offending
321/// glyph once rather than every frame; entries are only ever added.
322///
323/// Returns whether a warning was emitted, which is always `false` in a build that compiles
324/// diagnostics out: the size comparison, the `seen` bookkeeping, and the message all sit inside
325/// [`dev_only!`], so a release build does none of them. See
326/// [`BuildMode`](retroglyph_core::dev::BuildMode).
327pub fn warn_sprite_needs_span(
328    seen: &mut BTreeSet<char>,
329    glyph: char,
330    sprite: (u32, u32),
331    cell: (u32, u32),
332) -> bool {
333    dev_only!({
334        let ((w, h), (cell_w, cell_h)) = (sprite, cell);
335        if w <= cell_w && h <= cell_h {
336            return false;
337        }
338        if !seen.insert(glyph) {
339            return false;
340        }
341        log::warn!(
342            "sprite for {glyph:?} is {w}x{h}px, larger than the {cell_w}x{cell_h}px cell, but was \
343             drawn without a span: neighbouring cells will paint over it. Reserve the cells it \
344             covers with `Surface::put_span`."
345        );
346        return true;
347    });
348    false
349}
350
351/// Warns, at most once per glyph, that `glyph` carries a tint but resolved to a bitmap font
352/// glyph rather than a sprite, so the tint was silently dropped.
353///
354/// This is #537's exact trap: a font glyph is `fg`-coloured, so a cell that falls back to one
355/// still visibly changes colour when a tint is set, and it is easy to conclude the tint took
356/// effect when in fact nothing read it. Both pixel backends call this from the branch that
357/// already knows the sprite cache missed for this glyph, so the diagnostic and the fix it names
358/// are identical on each.
359///
360/// `tint` is the cell's own tint; a tint whose [`is_identity`](Tint::is_identity) is `true`
361/// (including [`Tint::None`]) has nothing to drop and is silent. `seen` is caller-owned state so
362/// a redraw loop reports each offending glyph once rather than every frame; entries are only
363/// ever added.
364///
365/// Returns whether a warning was emitted, which is always `false` in a build that compiles
366/// diagnostics out: the identity check, the `seen` bookkeeping, and the message all sit inside
367/// [`dev_only!`], so a release build does none of them. See
368/// [`BuildMode`](retroglyph_core::dev::BuildMode).
369pub fn warn_tint_needs_sprite(seen: &mut BTreeSet<char>, glyph: char, tint: Tint) -> bool {
370    dev_only!({
371        if tint.is_identity() {
372            return false;
373        }
374        if !seen.insert(glyph) {
375            return false;
376        }
377        log::warn!(
378            "cell for {glyph:?} has a tint but no sprite is registered for it, so it renders as \
379             the bitmap font glyph and the tint has no effect. Register a sprite for that \
380             codepoint, or clear the tint."
381        );
382        return true;
383    });
384    false
385}
386
387// ── Tests ─────────────────────────────────────────────────────────────────
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::tileset::{Codepage, SpriteAlign, TilesetOptions};
393    use image::ImageEncoder;
394
395    /// Build a programmatic RGBA8 PNG for testing.
396    ///
397    /// Each tile is filled with a unique color derived from its column/row
398    /// position so that tests can verify tile extraction.
399    #[allow(clippy::cast_possible_truncation)]
400    fn make_test_png(tile_w: u32, tile_h: u32, cols: u32, rows: u32) -> Vec<u8> {
401        let img_w = tile_w * cols;
402        let img_h = tile_h * rows;
403        let mut pixels = vec![0u8; (img_w * img_h * 4) as usize];
404
405        for row in 0..rows {
406            for col in 0..cols {
407                let r = ((col * 20) % 256) as u8;
408                let g = ((row * 20) % 256) as u8;
409                for py in 0..tile_h {
410                    for px in 0..tile_w {
411                        let idx = ((row * tile_h + py) * img_w + col * tile_w + px) as usize * 4;
412                        pixels[idx] = r;
413                        pixels[idx + 1] = g;
414                        pixels[idx + 2] = 0;
415                        pixels[idx + 3] = 255;
416                    }
417                }
418            }
419        }
420
421        let mut out = std::io::Cursor::new(Vec::new());
422        let encoder = image::codecs::png::PngEncoder::new(&mut out);
423        encoder
424            .write_image(&pixels, img_w, img_h, image::ExtendedColorType::Rgba8)
425            .unwrap();
426        out.into_inner()
427    }
428
429    #[test]
430    fn sprite_cache_load_cp437_sheet() {
431        let png = make_test_png(16, 16, 16, 16); // 256 tiles
432        let opts = TilesetOptions::builder(png)
433            .tile_size(16, 16)
434            .codepage(Codepage::Cp437)
435            .build()
436            .unwrap();
437        let mut cache = SpriteCache::new();
438        cache.load(&opts).unwrap();
439        let sprite = cache.get('@').expect("'@' must be in CP437 cache");
440        assert_eq!(sprite.pixel_width, 16);
441        assert_eq!(sprite.pixel_height, 16);
442        assert_eq!(sprite.pixels.len(), 16 * 16 * 4);
443    }
444
445    #[test]
446    fn sprite_cache_rejects_bad_dimensions() {
447        let png = make_test_png(17, 16, 1, 1);
448        let opts = TilesetOptions::builder(png)
449            .tile_size(16, 16)
450            .build()
451            .unwrap();
452        let mut cache = SpriteCache::new();
453        let err = cache.load(&opts).unwrap_err();
454        assert!(matches!(
455            err,
456            TilesetError::InvalidDimensions(17, 16, 16, 16)
457        ));
458    }
459
460    #[test]
461    fn sprite_cache_rejects_columns_wider_than_the_image() {
462        // retroglyph#729: `.columns(8)` on a sheet that only actually has 4 columns used to read
463        // tile pixels from past the end of the decoded raw buffer instead of being rejected.
464        let png = make_test_png(8, 16, 4, 1);
465        let opts = TilesetOptions::builder(png)
466            .tile_size(8, 16)
467            .columns(8)
468            .build()
469            .unwrap();
470        let mut cache = SpriteCache::new();
471        let err = cache.load(&opts).unwrap_err();
472        assert!(matches!(err, TilesetError::TooManyColumns(8, 4)));
473    }
474
475    #[test]
476    fn sprite_cache_load_empty_bytes_errors() {
477        let opts = TilesetOptions::builder(vec![])
478            .tile_size(16, 16)
479            .build()
480            .unwrap();
481        let mut cache = SpriteCache::new();
482        assert!(matches!(
483            cache.load(&opts),
484            Err(TilesetError::ImageDecode(_))
485        ));
486    }
487
488    #[test]
489    fn sprite_cache_last_registration_wins_on_collision() {
490        let png1 = make_test_png(16, 16, 1, 1);
491        let png2 = make_test_png(8, 8, 1, 1);
492        let opts1 = TilesetOptions::builder(png1)
493            .tile_size(16, 16)
494            .start_codepoint('A')
495            .build()
496            .unwrap();
497        let opts2 = TilesetOptions::builder(png2)
498            .tile_size(8, 8)
499            .start_codepoint('A')
500            .build()
501            .unwrap();
502        let mut cache = SpriteCache::new();
503        cache.load(&opts1).unwrap();
504        cache.load(&opts2).unwrap();
505        let sprite = cache.get('A').unwrap();
506        assert_eq!(sprite.pixel_width, 8); // opts2 wins
507    }
508
509    #[test]
510    fn sprite_cache_load_identity_codepage() {
511        let png = make_test_png(16, 16, 4, 1); // 4 tiles: index 0..3
512        let opts = TilesetOptions::builder(png)
513            .tile_size(16, 16)
514            .codepage(Codepage::Identity)
515            .build()
516            .unwrap();
517        let mut cache = SpriteCache::new();
518        cache.load(&opts).unwrap();
519        // Tile 0 -> char '\0', tile 1 -> '\x01', etc.
520        assert!(cache.get('\0').is_some());
521        assert!(cache.get('\x01').is_some());
522        assert!(cache.get('\x03').is_some());
523        assert!(cache.get('\x04').is_none()); // only 4 tiles
524    }
525
526    #[test]
527    fn sprite_cache_surrogate_tile_index_stops_load_instead_of_skipping() {
528        // 2050 tiles starting at U+D7FF: tile 0 -> U+D7FF (valid), tiles 1..=2048 fall in the
529        // surrogate range U+D800..=U+DFFF (documented as skipped, not fatal), tile 2049 ->
530        // U+E000 (valid again, just past the surrogate range).
531        let png = make_test_png(1, 1, 2050, 1);
532        let opts = TilesetOptions::builder(png)
533            .tile_size(1, 1)
534            .columns(2050)
535            .start_codepoint('\u{D7FF}')
536            .build()
537            .unwrap();
538        let mut cache = SpriteCache::new();
539        cache.load(&opts).unwrap();
540        assert!(cache.get('\u{D7FF}').is_some());
541        assert!(cache.get('\u{E000}').is_some());
542    }
543
544    #[test]
545    fn sprite_cache_custom_codepage_stops_at_table_end() {
546        let png = make_test_png(16, 16, 4, 1); // 4 tiles
547        let opts = TilesetOptions::builder(png)
548            .tile_size(16, 16)
549            .codepage(Codepage::Custom(vec!['A', 'B'])) // only 2 entries
550            .build()
551            .unwrap();
552        let mut cache = SpriteCache::new();
553        cache.load(&opts).unwrap();
554        assert!(cache.get('A').is_some());
555        assert!(cache.get('B').is_some());
556        assert!(cache.get('C').is_none()); // tile index 2 unmapped
557    }
558
559    // ── Alignment inside a span's cell box ─────────────────────────────
560
561    /// Loads a single-tile `tile_w` x `tile_h` sheet mapped to `'A'` with the given alignment.
562    fn one_sprite(tile_w: u32, tile_h: u32, align: SpriteAlign) -> Sprite {
563        let png = make_test_png(tile_w, tile_h, 1, 1);
564        #[allow(clippy::cast_possible_truncation)]
565        let opts = TilesetOptions::builder(png)
566            .tile_size(tile_w as u16, tile_h as u16)
567            .codepage(Codepage::Custom(vec!['A']))
568            .align(align)
569            .build()
570            .unwrap();
571        let mut cache = SpriteCache::new();
572        cache.load(&opts).unwrap();
573        cache.get('A').unwrap().clone()
574    }
575
576    #[test]
577    fn sprite_align_offset_centres_art_in_a_multi_cell_box() {
578        // An 8x16 sprite in a 2x1 span of 8x16 cells: 8 pixels of horizontal slack, none vertical.
579        let sprite = one_sprite(8, 16, SpriteAlign::Center);
580        assert_eq!(sprite.align_offset(2, 1, 8, 16), (4, 0));
581        assert_eq!(sprite.align_offset(2, 2, 8, 16), (4, 8));
582    }
583
584    #[test]
585    fn sprite_align_offset_is_zero_when_the_art_fills_its_span() {
586        // A 16x32 sprite in the 2x2 span of 8x16 cells it was drawn for.
587        let sprite = one_sprite(16, 32, SpriteAlign::Center);
588        assert_eq!(sprite.align_offset(2, 2, 8, 16), (0, 0));
589    }
590
591    #[test]
592    fn sprite_align_offset_defaults_to_top_left() {
593        let sprite = one_sprite(8, 16, SpriteAlign::TopLeft);
594        assert_eq!(sprite.align_offset(4, 4, 8, 16), (0, 0));
595    }
596
597    #[test]
598    fn sprite_align_offset_tolerates_a_zero_cell_size() {
599        // A degenerate cell size must not offset the sprite off its anchor.
600        let sprite = one_sprite(8, 16, SpriteAlign::Center);
601        assert_eq!(sprite.align_offset(2, 2, 0, 0), (0, 0));
602    }
603
604    // ── source_over tests ────────────────────────────────────────────────
605
606    #[test]
607    fn source_over_opaque_overwrites_destination() {
608        let src = U8x4Rgba::new(0, 255, 0, 255); // opaque green
609        let dst = U8x4Rgba::new(255, 0, 0, 255); // opaque red
610        let result = src.source_over(dst);
611        assert_eq!(result, src);
612    }
613
614    #[test]
615    fn source_over_transparent_preserves_destination() {
616        let src = U8x4Rgba::TRANSPARENT;
617        let dst = U8x4Rgba::new(255, 0, 0, 255);
618        let result = src.source_over(dst);
619        assert_eq!(result, dst);
620    }
621
622    #[test]
623    fn source_over_half_alpha_blends() {
624        // Green at 50% over red at 100%.
625        let src = U8x4Rgba::new(0, 255, 0, 128);
626        let dst = U8x4Rgba::new(255, 0, 0, 255);
627        let result = src.source_over(dst);
628        // Green (0, 255, 0) at alpha 128 over an opaque red (255, 0, 0) destination.
629        // U8x4Rgba::source_over (alpha-blend 0.3.0) rounds to nearest, once, from an exact
630        // widened intermediate (retroglyph#547): r and g both land almost exactly halfway
631        // (127.5), and round to 127 and 128 respectively rather than both flooring to 127.
632        // A fully opaque destination always yields a fully opaque result.
633        //
634        // Before 0.3.0, source_over used the `(v + (v >> 8) + 1) >> 8` shift trick, which is
635        // exactly `floor`, and gave (127, 127, 0, 255) here: one LSB darker on the green
636        // channel. That downward bias, applied every frame a sprite is composited, is the bug
637        // this crate depends on alpha-blend 0.3.0 to fix.
638        assert_eq!(result, U8x4Rgba::new(127, 128, 0, 255));
639    }
640
641    // ── SpriteTint resolution ─────────────────────────────────────────
642
643    fn sprite_with(color: SheetColor) -> Sprite {
644        Sprite {
645            pixels: vec![255, 255, 255, 255],
646            pixel_width: 1,
647            pixel_height: 1,
648            align: SpriteAlign::TopLeft,
649            color,
650        }
651    }
652
653    const DEFAULT_FG: (u8, u8, u8) = (0xD4, 0xD4, 0xD4);
654
655    #[test]
656    fn art_sheet_ignores_fg_entirely() {
657        let art = sprite_with(SheetColor::Art);
658        let resolved = SpriteTint::resolve(art.color, Color::RED, Tint::None, DEFAULT_FG);
659
660        assert_eq!(resolved.mask, Tint::None);
661        assert!(resolved.is_identity());
662        // The whole point of #537: a full-colour sheet renders as authored, whatever fg says.
663        assert_eq!(resolved.apply((10, 200, 30)), (10, 200, 30));
664    }
665
666    #[test]
667    fn mask_sheet_takes_its_colour_from_fg() {
668        let mask = sprite_with(SheetColor::Mask);
669        let (r, g, b) = Color::RED.resolve_rgb(DEFAULT_FG);
670        let resolved = SpriteTint::resolve(mask.color, Color::RED, Tint::None, DEFAULT_FG);
671
672        assert_eq!(resolved.mask, Tint::multiply(r, g, b));
673        // A white mask pixel takes the foreground exactly.
674        assert_eq!(resolved.apply((255, 255, 255)), (r, g, b));
675    }
676
677    #[test]
678    fn mask_sheet_shades_a_grey_pixel_proportionally() {
679        let mask = sprite_with(SheetColor::Mask);
680        let resolved = SpriteTint::resolve(
681            mask.color,
682            Color::Rgb {
683                r: 200,
684                g: 100,
685                b: 50,
686            },
687            Tint::None,
688            DEFAULT_FG,
689        );
690
691        // Half-grey artwork lands on a proportionally darker shade of the foreground, which is
692        // how a libtcod/Dwarf Fortress style tileset is authored.
693        let (r, _, _) = resolved.apply((128, 128, 128));
694        assert!(r > 0 && r < 200, "expected a shade of the fg, got {r}");
695    }
696
697    #[test]
698    fn mask_sheet_resolves_default_fg_through_the_palette() {
699        let mask = sprite_with(SheetColor::Mask);
700        let resolved = SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG);
701
702        // `Color::Default` has no reading as a modulation value on its own, so it goes through
703        // the palette rather than being treated as white.
704        assert_eq!(resolved.mask, Tint::multiply(0xD4, 0xD4, 0xD4));
705    }
706
707    #[test]
708    fn the_cell_tint_applies_on_top_of_an_art_sheet() {
709        let art = sprite_with(SheetColor::Art);
710        let resolved = SpriteTint::resolve(
711            art.color,
712            Color::RED,
713            Tint::multiply(128, 128, 128),
714            DEFAULT_FG,
715        );
716
717        assert!(!resolved.is_identity());
718        assert_eq!(resolved.apply((200, 180, 60)), (100, 90, 30));
719    }
720
721    #[test]
722    fn both_stages_apply_in_order_on_a_mask_sheet() {
723        let mask = sprite_with(SheetColor::Mask);
724        let flash = Tint::mix(255, 255, 255, 255);
725        let resolved = SpriteTint::resolve(
726            mask.color,
727            Color::Rgb { r: 255, g: 0, b: 0 },
728            flash,
729            DEFAULT_FG,
730        );
731
732        // Mask first would give red; the flash then takes it all the way to white. The other
733        // order would give red, which is why the order is part of the contract.
734        assert_eq!(resolved.apply((255, 255, 255)), (255, 255, 255));
735    }
736
737    #[test]
738    fn an_untouched_art_cell_is_identity_so_renderers_can_skip_the_work() {
739        let art = sprite_with(SheetColor::Art);
740        assert!(
741            SpriteTint::resolve(art.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
742        );
743        // A mask sheet is never identity: its colour always comes from somewhere.
744        let mask = sprite_with(SheetColor::Mask);
745        assert!(
746            !SpriteTint::resolve(mask.color, Color::Default, Tint::None, DEFAULT_FG).is_identity()
747        );
748    }
749
750    // `warn_sprite_needs_span` reports only in a build that compiles diagnostics in, so every
751    // expectation below is written against `DEV` rather than a literal. Under `cargo test` that
752    // is `true`; the point of spelling it out is that a release-profile test run still passes.
753
754    #[test]
755    fn warn_sprite_needs_span_reports_an_oversized_sprite_once() {
756        let mut seen = BTreeSet::new();
757        assert_eq!(
758            warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)),
759            retroglyph_core::dev::DEV
760        );
761        // Second call for the same glyph is silent even in a reporting build.
762        assert!(!warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16)));
763    }
764
765    #[test]
766    fn warn_sprite_needs_span_is_silent_for_a_sprite_that_fits() {
767        let mut seen = BTreeSet::new();
768        assert!(!warn_sprite_needs_span(&mut seen, '@', (16, 16), (16, 16)));
769        assert!(seen.is_empty());
770    }
771
772    #[test]
773    fn warn_sprite_needs_span_touches_no_state_outside_a_reporting_build() {
774        let mut seen = BTreeSet::new();
775        warn_sprite_needs_span(&mut seen, '@', (32, 32), (16, 16));
776        // The dedup set is the allocation a release build should not be paying for.
777        assert_eq!(seen.is_empty(), !retroglyph_core::dev::DEV);
778    }
779
780    // `warn_tint_needs_sprite` reports only in a build that compiles diagnostics in, so every
781    // expectation below is written against `DEV` rather than a literal, matching
782    // `warn_sprite_needs_span`'s tests above.
783
784    #[test]
785    fn warn_tint_needs_sprite_reports_a_dropped_tint_once() {
786        let mut seen = BTreeSet::new();
787        let tint = Tint::multiply(128, 128, 128);
788        assert_eq!(
789            warn_tint_needs_sprite(&mut seen, '@', tint),
790            retroglyph_core::dev::DEV
791        );
792        // Second call for the same glyph is silent even in a reporting build.
793        assert!(!warn_tint_needs_sprite(&mut seen, '@', tint));
794    }
795
796    #[test]
797    fn warn_tint_needs_sprite_is_silent_for_tint_none() {
798        let mut seen = BTreeSet::new();
799        assert!(!warn_tint_needs_sprite(&mut seen, '@', Tint::None));
800        assert!(seen.is_empty());
801    }
802
803    #[test]
804    fn warn_tint_needs_sprite_is_silent_for_an_identity_tint() {
805        let mut seen = BTreeSet::new();
806        assert!(!warn_tint_needs_sprite(
807            &mut seen,
808            '@',
809            Tint::multiply(255, 255, 255)
810        ));
811        assert!(seen.is_empty());
812    }
813
814    #[test]
815    fn warn_tint_needs_sprite_touches_no_state_outside_a_reporting_build() {
816        let mut seen = BTreeSet::new();
817        warn_tint_needs_sprite(&mut seen, '@', Tint::multiply(128, 128, 128));
818        // The dedup set is the allocation a release build should not be paying for.
819        assert_eq!(seen.is_empty(), !retroglyph_core::dev::DEV);
820    }
821}