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