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}