Skip to main content

retroglyph_software/
lib.rs

1//! CPU rasterization backend: renders grid cells into a pixel buffer and
2//! blits it to a window surface via `softbuffer`.
3//!
4//! # Architecture
5//!
6//! [`SoftwareBackend`] holds configuration only (font chain, grid size, scale); it
7//! does not implement [`Backend`](retroglyph_core::backend::Backend). Call
8//! [`into_renderer`](SoftwareBackend::into_renderer) to build a
9//! [`SoftwareRenderer`], which does the actual rendering work:
10//!
11//! ```text
12//! SoftwareBackend (config: font chain, grid size, scale)
13//!   |  .into_renderer()
14//!   v
15//! SoftwareRenderer
16//!   implements retroglyph_core::{Output, Input, Cursor} (= Backend)
17//!   implements retroglyph_window::Presenter (an Output supertrait)
18//!   |                                |
19//!   |                                v
20//!   |                     wrapped in retroglyph_window::WindowBackend,
21//!   |                     driven by a windowing loop (retroglyph-window's
22//!   |                     winit integration, or any other source of
23//!   |                     raw window handles)
24//!   v                                |
25//! Terminal<SoftwareRenderer>          v
26//! (headless / pixel tests,   softbuffer::Surface -> OS window
27//!  inspect via .pixels())
28//! ```
29//!
30//! This crate does not depend on winit. [`SoftwareRenderer`] implements
31//! [`Presenter`](retroglyph_window::Presenter) against raw window handles
32//! ([`WindowHandle`]), so anything that produces those (winit via
33//! `retroglyph-window`, or another windowing library) can drive it. Because
34//! `Presenter` is an [`Output`] supertrait, `SoftwareRenderer`'s single
35//! `Output` implementation satisfies both `Backend`'s output half and `Presenter` directly, with
36//! no duplicated method bodies. `retroglyph-window`'s
37//! [`WindowBackend`](retroglyph_window::WindowBackend) wraps a `Presenter` to provide the full
38//! [`Backend`](retroglyph_core::backend::Backend) for windowed use, owning the input event queue that this
39//! crate does not.
40//!
41//! For headless use (in-memory rendering, pixel-level tests) skip windowing
42//! entirely: [`SoftwareRenderer`] implements [`Output`],
43//! [`Input`], and [`Cursor`] directly (bundled
44//! as [`Backend`](retroglyph_core::backend::Backend)), so `Terminal<SoftwareRenderer>` works without a
45//! window, and [`pixels`](SoftwareRenderer::pixels) gives direct access to the rendered
46//! buffer.
47//!
48//! # Features
49//!
50//! <!-- gen-features:start -->
51//! This crate has no default features; every feature below is optional and off unless enabled.
52//!
53//! ### `default-font`
54//!
55//! ⚪ Optional.
56//!
57//! Embeds the Unscii 16 bitmap font as a ready-to-use default `FontChain`.
58//!
59//! Forwards to `retroglyph-window`'s `default-font` feature.
60//!
61//! ### `dev`
62//!
63//! ⚪ Optional.
64//!
65//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
66//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
67//!
68//! ### `tilesets`
69//!
70//! ⚪ Optional.
71//!
72//! PNG sprite sheet tilesets with alpha-blended CPU blit support.
73//!
74//! Adds `alpha-blend` and forwards to `retroglyph-window`'s `tilesets` feature for decode/config.
75//! <!-- gen-features:end -->
76
77#![cfg_attr(docsrs, feature(doc_cfg))]
78
79pub mod config;
80
81// The sprite/tileset decode + config now lives in `retroglyph-window` (winit-free), shared with
82// `retroglyph-gl` the same way `BitmapFont` is. Re-exported here so the existing
83// `retroglyph_software::tileset` / `::sprite_cache` paths keep working.
84#[cfg(feature = "tilesets")]
85pub use retroglyph_window::{sprite_cache, tileset};
86
87// Platform-specific window surface. Both modules expose a `WindowSurface` with
88// the same `new`/`resize`/`present` API and their own `SurfaceError`, so the
89// renderer below drives either without `cfg` in its body. This is the same
90// module-swap pattern std uses for `std::sys`.
91#[cfg(not(target_arch = "wasm32"))]
92#[path = "surface_native.rs"]
93mod surface;
94#[cfg(target_arch = "wasm32")]
95#[path = "surface_wasm.rs"]
96mod surface;
97
98pub use surface::SurfaceError;
99use surface::WindowSurface;
100
101// Compile the code blocks in this crate's own README as doctests so its quick start is
102// type-checked on every test run and cannot silently rot. The `cfg(doctest)` gate keeps this out
103// of the rendered crate documentation: see `retroglyph-crossterm`'s matching include for the
104// same pattern applied to the workspace root README.
105#[cfg(doctest)]
106#[doc = include_str!("../README.md")]
107struct ReadmeDoctests;
108
109use retroglyph_core::backend::DrawCell;
110use retroglyph_core::backend::{Cursor, Input, Output};
111use retroglyph_core::color::Color;
112
113// The bitmap font lives in `retroglyph-window`'s winit-free `font` module (both graphical
114// backends already depend on that crate for `Presenter`), so `retroglyph-gl` shares the exact
115// same glyph source. Re-exported here for ergonomics (the builder's `font()` takes either);
116// `unscii16` etc. are reached through `retroglyph_window::font` directly.
117pub use config::{SoftwareBackend, SoftwareBackendBuilder, SoftwareBackendError};
118pub use retroglyph_window::font::{BitmapFont, FontChain};
119
120#[cfg(feature = "tilesets")]
121use alpha_blend::rgba::U8x4Rgba;
122use grixy::buf::GridBuf;
123use grixy::ops::GridWrite;
124use grixy::ops::layout::{LinearLayout, RowMajor};
125use retroglyph_core::color::Tint;
126use retroglyph_core::event::Event;
127use retroglyph_core::grid::HasSize;
128use retroglyph_core::grid::{Pos, Size};
129use retroglyph_core::tile::Tile;
130use retroglyph_window::WindowHandle;
131use retroglyph_window::cell_art_glyph;
132use retroglyph_window::geometry::CellGeometry;
133use retroglyph_window::palette::{DEFAULT_BG, DEFAULT_FG};
134#[cfg(feature = "tilesets")]
135use retroglyph_window::sprite_cache::{
136    Sprite, SpriteCache, SpriteTint, warn_sprite_needs_span, warn_tint_needs_sprite,
137};
138#[cfg(feature = "tilesets")]
139use std::collections::BTreeSet;
140use std::collections::VecDeque;
141use std::sync::Arc;
142use std::time::Duration;
143
144// ── Public types ──────────────────────────────────────────────────────────────
145
146/// A running software renderer, produced by [`SoftwareBackend::into_renderer`].
147///
148/// Unlike [`SoftwareBackend`] (which is just configuration), this type
149/// always has an active rendering context: its pixel buffer is always
150/// available, and the `ctx` field is never `None`, so [`Output`] methods
151/// never panic for missing initialization.
152///
153/// Call [`pixels`](Self::pixels) to inspect the rendered output, or use
154/// [`Output::draw`] and [`Output::draw_layers`] to render into it.
155///
156/// If the `tilesets` feature is enabled, the sprite tileset is loaded once, at
157/// [`into_renderer`](SoftwareBackend::into_renderer) time, into an internal
158/// [`SpriteCache`]. That cache has no reload/hot-swap support (see its
159/// docs); to pick up a changed tileset, rebuild the renderer via a fresh [`SoftwareBackend`]
160/// configuration rather than mutating this one.
161pub struct SoftwareRenderer {
162    options: SoftwareBackend,
163    /// The font chain glyphs are resolved through, extracted from `options.fonts` at construction
164    /// time. Always present; the `Option` wrapper in `SoftwareBackend` is only for the builder
165    /// validation step.
166    fonts: FontChain<'static>,
167    ctx: RenderContext,
168    #[cfg(feature = "tilesets")]
169    sprite_cache: Arc<SpriteCache>,
170}
171
172struct RenderContext {
173    event_buffer: VecDeque<Event>,
174    pixel_buf: GridBuf<u32, Vec<u32>, RowMajor>,
175    window_surface: Option<WindowSurface>,
176    /// Cell/surface pixel geometry (glyph size x scale); constant for the renderer's lifetime
177    /// (a grid resize changes cols/rows, never the cell size). The single source of the
178    /// `cell_size` contract, delegated to by [`Presenter::cell_size`].
179    geometry: CellGeometry,
180    /// Shadow copy of `pixel_buf` from the previous frame, used to compute
181    /// the damaged row range in [`present`](SoftwareRenderer::present).
182    /// Kept the same length as `pixel_buf`; resized (and the whole frame
183    /// marked damaged) whenever the buffer is resized.
184    prev_pixels: Vec<u32>,
185    /// Row range `[y0, y1)` changed since the last present, computed in
186    /// `draw_layers` by diffing against `prev_pixels` and unioned into any
187    /// existing band, since `draw_layers` may be called more than once
188    /// between two `present()` calls. `None` means no rows changed
189    /// (nothing to present).
190    damage_rows: Option<(u32, u32)>,
191    /// Shadow copy of every allocated layer's tiles from the last `draw_layers` call, one
192    /// `GridBuf` per layer indexed `[layer_id]`, each internally flat-indexed `[y * cols + x]`.
193    /// Used to find dirty cells without touching core's diff model: `draw_layers` already
194    /// receives every cell on every allocated layer every frame (see
195    /// [`Output::needs_full_frame`]), so comparing against this shadow copy in place is enough to
196    /// tell which cells actually changed, with no new core API needed. Each layer's `GridBuf` is
197    /// always replaced wholesale (via `GridBuf::new_filled`), never resized in place, whenever the
198    /// grid dimensions change, so a layer's buffer and its declared width/height can never drift
199    /// apart the way two parallel `Vec`s could (retroglyph#567); grown (never shrunk) as new layer
200    /// ids are seen.
201    prev_tiles: Vec<GridBuf<Tile, Vec<Tile>, RowMajor>>,
202    /// Per-cell tints from the last `draw_layers` call, indexed exactly as `prev_tiles`.
203    ///
204    /// A separate shadow copy because a `Tile` does not carry its tint (it lives in a side table
205    /// on `Grid`, see `retroglyph_core::grid::Grid::tint`). Without it a tint-only change would compare
206    /// equal on every `Tile` field and never mark the cell dirty, so recoloring a sprite in
207    /// place would silently not repaint.
208    prev_tints: Vec<GridBuf<Tint, Vec<Tint>, RowMajor>>,
209    /// Reusable per-cell dirty scratch buffer, `true` at index `y * cols + x` when any layer's
210    /// tile at that position changed this frame. Indexed the same way as each `prev_tiles` layer;
211    /// resized alongside it.
212    dirty_mask: Vec<bool>,
213    /// Number of layers (`max layer id + 1`) present in the last `draw_layers` call. A change in
214    /// this count between frames (a layer being newly allocated or fully deallocated) forces a
215    /// full repaint next frame, since the dirty-cell path can only compare cells within layers
216    /// present in both frames.
217    prev_layer_count: usize,
218    /// Whether any tile in the last `draw_layers` call had a nonzero sub-cell offset
219    /// (`Tile::dx`/`Tile::dy`). A frame that removes the last offset needs a full repaint just as
220    /// much as one that introduces one: the spilled pixels it painted into a neighbor cell live
221    /// outside that neighbor's own tile, so a neighbor whose own tile is unchanged is never
222    /// revisited by the dirty-cell path and the stale spill survives unless this frame's
223    /// `full_repaint` also accounts for what the *previous* frame offset.
224    prev_offset: bool,
225    /// Glyphs already reported by [`warn_oversized_sprite`] as needing a span, so a 60fps redraw
226    /// loop logs each one once instead of every frame.
227    #[cfg(feature = "tilesets")]
228    warned_oversized: BTreeSet<char>,
229    /// Glyphs already reported by [`warn_tint_needs_sprite`] as having a dropped tint, so a 60fps
230    /// redraw loop logs each one once instead of every frame.
231    #[cfg(feature = "tilesets")]
232    warned_dropped_tint: BTreeSet<char>,
233}
234
235impl SoftwareRenderer {
236    /// Creates a new renderer with the given buffer and cell dimensions.
237    pub(crate) fn create(
238        options: SoftwareBackend,
239        fonts: FontChain<'static>,
240        buf_w: usize,
241        buf_h: usize,
242        geometry: CellGeometry,
243        #[cfg(feature = "tilesets")] sprite_cache: Arc<SpriteCache>,
244    ) -> Self {
245        Self {
246            options,
247            fonts,
248            ctx: RenderContext {
249                event_buffer: VecDeque::new(),
250                pixel_buf: GridBuf::from_buffer(vec![0u32; buf_w * buf_h], buf_w),
251                window_surface: None,
252                geometry,
253                prev_pixels: vec![0u32; buf_w * buf_h],
254                damage_rows: None,
255                prev_tiles: Vec::new(),
256                prev_tints: Vec::new(),
257                dirty_mask: Vec::new(),
258                // Sentinel distinct from any real layer count (always < 256), so the very first
259                // `draw_layers` call is unconditionally treated as a layer-set change and takes
260                // the full-repaint path once, seeding `prev_tiles` for every subsequent frame.
261                prev_layer_count: usize::MAX,
262                prev_offset: false,
263                #[cfg(feature = "tilesets")]
264                warned_oversized: BTreeSet::new(),
265                #[cfg(feature = "tilesets")]
266                warned_dropped_tint: BTreeSet::new(),
267            },
268            #[cfg(feature = "tilesets")]
269            sprite_cache,
270        }
271    }
272
273    /// The rendered pixel buffer, row-major, one `u32` per physical pixel.
274    ///
275    /// Each pixel is `0x00RRGGBB`: eight bits per channel with the top byte unused (not an alpha
276    /// channel, and not premultiplied). Index a pixel as `y * width + x`, where `width = cols *
277    /// glyph_width * scale` and the buffer length is `width * rows * glyph_height * scale`. Both
278    /// dimensions come from this renderer's
279    /// [`CellGeometry`], so prefer deriving them from
280    /// [`surface_size`](CellGeometry::surface_size) over recomputing the
281    /// product by hand.
282    ///
283    /// The contents are whatever the last draw call left behind: [`Output::draw_layers`] writes
284    /// directly into this buffer rather than through an intermediate frame, so calling this
285    /// between draw calls (before the frame is complete) yields a partially drawn buffer rather
286    /// than an error. A [`resize`](Output::resize) reallocates the buffer, so the returned
287    /// slice's contents and length are only valid until the next resize.
288    ///
289    /// This is always available: there is no `Option` wrapper because
290    /// `SoftwareRenderer` is guaranteed to have an active rendering context.
291    #[must_use]
292    pub fn pixels(&self) -> &[u32] {
293        self.ctx.pixel_buf.as_ref()
294    }
295
296    /// Pushes an event into the internal buffer, to be drained by
297    /// [`Input::poll_event`].
298    pub fn push_event(&mut self, event: Event) {
299        self.ctx.event_buffer.push_back(event);
300    }
301
302    /// Initializes the window surface from a raw window/display handle.
303    ///
304    /// The concrete surface is platform-specific (softbuffer on native, a
305    /// `Canvas2D` context on wasm32); see the `surface` module.
306    ///
307    /// # Errors
308    ///
309    /// On native, returns `SurfaceError::Context`/`SurfaceError::Surface` if softbuffer
310    /// cannot create a graphics context or surface from `window` (for example, the handle's
311    /// display or window system connection is invalid). On wasm32, returns
312    /// `SurfaceError::Canvas` if winit's `<canvas>` element or its 2D rendering context
313    /// cannot be located in the DOM. Either way, `self` is left without a surface, so
314    /// [`present`](Self::present) keeps behaving as headless (a no-op) until `init_surface`
315    /// is called again successfully.
316    pub fn init_surface(&mut self, window: Arc<dyn WindowHandle>) -> Result<(), SurfaceError> {
317        self.ctx.window_surface = Some(WindowSurface::new(window)?);
318        Ok(())
319    }
320
321    /// Resizes the window surface to `width` x `height` pixels. No-op if the
322    /// surface has not been initialized via [`init_surface`](Self::init_surface).
323    pub fn resize_surface(&mut self, width: u32, height: u32) {
324        if let Some(surf) = &mut self.ctx.window_surface {
325            surf.resize(width, height);
326        }
327    }
328
329    /// Presents the pixel buffer to the window surface. No-op in headless
330    /// mode (no surface initialized).
331    ///
332    /// # Errors
333    ///
334    /// On native, returns `SurfaceError::Surface` if softbuffer cannot acquire or present
335    /// its buffer (for example, the window was destroyed or the platform surface was lost).
336    /// On wasm32, returns `SurfaceError::Canvas` if building the damaged-row `ImageData` or
337    /// the canvas 2D context's `put_image_data` call fails. The pixel buffer and damage
338    /// tracking are unaffected by a failed present, so the next successful present resends
339    /// the current frame rather than a stale one.
340    pub fn present(&mut self) -> Result<(), SurfaceError> {
341        let Some(surface) = self.ctx.window_surface.as_mut() else {
342            return Ok(()); // headless mode, nothing to present
343        };
344        // No damage since the last present: nothing changed, so skip the copy
345        // + upload round trip entirely.
346        let Some(damage) = self.ctx.damage_rows else {
347            return Ok(());
348        };
349        let result = surface.present(self.ctx.pixel_buf.as_ref(), damage);
350        // Only drop the damage once it's actually been presented, so a later
351        // present() with no new draw_layers() call is a no-op instead of
352        // re-presenting stale damage. On failure, leave it set so the next
353        // present() attempt retries the same band instead of losing it.
354        if result.is_ok() {
355            self.ctx.damage_rows = None;
356        }
357        result
358    }
359
360    /// Diffs `pixel_buf` against `prev_pixels` row by row to find the
361    /// smallest contiguous `[y0, y1)` band that changed and unions it into
362    /// `damage_rows`.
363    ///
364    /// The newly diffed band is unioned with (not overwritten onto) any
365    /// existing `damage_rows`, since `draw_layers` can be called more than
366    /// once between two `present()` calls: `present()` is the only place
367    /// that clears `damage_rows`, so a band from an earlier `draw_layers`
368    /// call in the same present cycle must survive a later call that
369    /// touches a disjoint set of rows, or those earlier rows would never
370    /// reach the window surface (see retroglyph#724).
371    ///
372    /// Only the `[y0, y1)` band (not the whole buffer) is copied from
373    /// `pixel_buf` into `prev_pixels` afterwards, since every other row is
374    /// already known to match; when nothing changed, the copy is skipped
375    /// entirely. `draw_layers` always repaints every cell (see
376    /// [`Output::needs_full_frame`]), so `prev_pixels` still has to hold a
377    /// full previous-frame pixel buffer to diff against: this only removes
378    /// the copy's cost from being proportional to the whole buffer instead of
379    /// the changed region, which is what actually dominates this function's
380    /// cost on an unchanged or near-unchanged frame.
381    ///
382    /// If the buffers differ in length (a resize raced with this call)
383    /// the whole frame is marked damaged and `prev_pixels` is resized to
384    /// match; that already covers any previously pending band, so it's an
385    /// overwrite rather than a union.
386    fn update_damage(&mut self, buf_w: usize) {
387        let pixels = self.ctx.pixel_buf.as_ref();
388        if self.ctx.prev_pixels.len() != pixels.len() {
389            self.ctx.prev_pixels.clear();
390            self.ctx.prev_pixels.extend_from_slice(pixels);
391            let rows = pixels.len().checked_div(buf_w).unwrap_or(0);
392            #[allow(clippy::cast_possible_truncation)]
393            let rows_u32 = rows as u32;
394            self.ctx.damage_rows = if rows == 0 { None } else { Some((0, rows_u32)) };
395            return;
396        }
397
398        if buf_w == 0 {
399            self.ctx.damage_rows = None;
400            return;
401        }
402
403        let rows = pixels.len() / buf_w;
404        let mut y0 = None;
405        let mut y1 = 0usize;
406        for row in 0..rows {
407            let start = row * buf_w;
408            let end = start + buf_w;
409            if pixels[start..end] != self.ctx.prev_pixels[start..end] {
410                if y0.is_none() {
411                    y0 = Some(row);
412                }
413                y1 = row + 1;
414            }
415        }
416
417        let new_band = y0.map(|y0| {
418            #[allow(clippy::cast_possible_truncation)]
419            (y0 as u32, y1 as u32)
420        });
421        self.ctx.damage_rows = match (self.ctx.damage_rows, new_band) {
422            (Some((py0, py1)), Some((ny0, ny1))) => Some((py0.min(ny0), py1.max(ny1))),
423            (existing, None) => existing,
424            (None, new) => new,
425        };
426
427        // Only the changed band needs copying: every row outside `[y0, y1)`
428        // already matched `pixels` in the loop above, so re-copying it would
429        // just repeat work for no effect. When `y0` is `None` (nothing
430        // changed), skip the copy entirely.
431        if let Some(y0) = y0 {
432            let start = y0 * buf_w;
433            let end = y1 * buf_w;
434            self.ctx.prev_pixels[start..end].copy_from_slice(&pixels[start..end]);
435        }
436    }
437
438    /// Whether `glyph` resolves to a registered sprite in the `tilesets` sprite cache.
439    ///
440    /// Sprites carry their own per-pixel alpha, so a tile that dispatches to one does not fit
441    /// [`resolve_bg_fill`]'s "an occupied tile is opaque" rule: see its doc comment. Without the
442    /// `tilesets` feature there is no sprite cache at all, so this always returns `false`.
443    #[cfg(feature = "tilesets")]
444    fn has_sprite(&self, glyph: char) -> bool {
445        self.sprite_cache.get(glyph).is_some()
446    }
447
448    // Signature has to match the `tilesets` arm above (both are called uniformly as
449    // `self.has_sprite(glyph)`), so `_glyph` and `self` stay unused here rather than becoming a
450    // `const fn` associated function: see retroglyph#954.
451    #[cfg(not(feature = "tilesets"))]
452    #[allow(clippy::unused_self, clippy::missing_const_for_fn)]
453    fn has_sprite(&self, _glyph: char) -> bool {
454        false
455    }
456
457    /// Grows `prev_tiles`/`prev_tints` to cover `layer_idx` if this is the first time it has been
458    /// seen, or replaces both layers' `GridBuf`s wholesale if a previous grid size left them
459    /// stale. Each replacement uses `GridBuf::new_filled`, never `resize_filled`: a stale layer's
460    /// content is never meaningful at the new dimensions, so there is nothing worth preserving
461    /// (unlike the two-parallel-`Vec` version this replaces, a `GridBuf`'s own width/height can't
462    /// independently drift from its contents; see retroglyph#567).
463    fn ensure_layer_shadow(&mut self, layer_idx: usize, cols: usize, rows: usize) {
464        if layer_idx >= self.ctx.prev_tiles.len() {
465            self.ctx.prev_tiles.resize_with(layer_idx + 1, || {
466                GridBuf::new_filled(cols, rows, Tile::default())
467            });
468            self.ctx.prev_tints.resize_with(layer_idx + 1, || {
469                GridBuf::new_filled(cols, rows, Tint::None)
470            });
471        } else if self.ctx.prev_tiles[layer_idx].as_ref().len() != cols * rows
472            || self.ctx.prev_tints[layer_idx].as_ref().len() != cols * rows
473        {
474            self.ctx.prev_tiles[layer_idx] = GridBuf::new_filled(cols, rows, Tile::default());
475            self.ctx.prev_tints[layer_idx] = GridBuf::new_filled(cols, rows, Tint::None);
476        }
477    }
478
479    /// Determines the background `layer_id`'s cell at flat index `idx` should paint, if any.
480    ///
481    /// Wraps [`resolve_bg_fill`] with the one thing that function cannot see on its own: whether
482    /// this cell is inside a multi-cell span whose *anchor* dispatches to a sprite. A covered
483    /// cell holds the span's text fallback glyph (`'='`, `'['`, ...), which has no sprite of its
484    /// own, so asking [`resolve_bg_fill`] about that glyph would make the covered cells paint an
485    /// opaque background while the anchor cell stays transparent: one sprite, drawn over two
486    /// different backdrops. Resolving the sprite question against the anchor keeps the whole
487    /// footprint consistent.
488    ///
489    /// The *position* stays this cell's own, so background inheritance from lower layers is still
490    /// resolved per cell rather than smeared from the anchor's column.
491    fn resolve_cell_bg(&self, layer_id: u8, idx: usize, cols: usize) -> Option<u32> {
492        let tile = self.ctx.prev_tiles[usize::from(layer_id)].as_ref()[idx];
493        let anchor_glyph = tile.span_anchor_index(idx, cols).map_or_else(
494            || tile.glyph(),
495            |anchor_idx| {
496                self.ctx.prev_tiles[usize::from(layer_id)]
497                    .as_ref()
498                    .get(anchor_idx)
499                    .map_or_else(|| tile.glyph(), Tile::glyph)
500            },
501        );
502        let has_sprite = self.has_sprite(anchor_glyph);
503        resolve_bg_fill(&self.ctx.prev_tiles, layer_id, idx, has_sprite)
504    }
505
506    /// Fills a cell's background rectangle when `bg_fill` is opaque. The rectangle is always the
507    /// full, unshifted cell: sub-cell `dx`/`dy` offsets move only the glyph, never the background.
508    fn fill_cell_bg(&mut self, cell_w: usize, cell_h: usize, pos: Pos, bg_fill: Option<u32>) {
509        if let Some(bg) = bg_fill {
510            let cell = ixy::Rect::new(usize::from(pos.x), usize::from(pos.y), 1, 1);
511            let rect = cell * ixy::Size::new(cell_w, cell_h);
512            self.ctx.pixel_buf.fill_rect_solid(rect, bg);
513        }
514    }
515
516    /// Blits a cell's glyph (a cached sprite if one matches, else the bitmap font), shifted by the
517    /// tile's sub-cell `dx`/`dy` offset. The glyph may spill past the cell edge into neighbors, so
518    /// callers that want that spill preserved must lay down every background first (see the
519    /// two-pass repaint in [`draw_layers`](Self::draw_layers)).
520    ///
521    /// A sprite is additionally shifted by its alignment inside the tile's span box (see
522    /// [`Sprite::align_offset`]), which is `(0, 0)` unless the span reserves more cells than the
523    /// artwork fills.
524    #[allow(clippy::too_many_arguments)]
525    fn blit_cell_glyph(
526        &mut self,
527        buf_w: usize,
528        cell_w: usize,
529        cell_h: usize,
530        scale: usize,
531        pos: Pos,
532        tile: Tile,
533        // Only ever read inside the `tilesets`-gated sprite path below: a bitmap-font glyph is
534        // always drawn in the cell's own foreground color, never tinted (tints apply to
535        // sprites only, per `Surface::with_tint`), so a `tilesets`-off build has no use for it.
536        tint: Tint,
537    ) {
538        #[cfg(not(feature = "tilesets"))]
539        let _ = tint;
540
541        // A span-covered or blank cell draws no art at all (see `cell_art_glyph`): neither a
542        // sprite nor a bitmap-font glyph. Deciding that once here, before either lookup, is the
543        // fix for retroglyph#762: the sprite lookup below used to run unconditionally, so a
544        // covered cell whose text-fallback glyph happened to have its own sprite painted that
545        // sprite over the span's artwork instead of drawing nothing.
546        let Some(art_glyph) = cell_art_glyph(&tile) else {
547            return;
548        };
549
550        let px_x = usize::from(pos.x) * cell_w;
551        let px_y = usize::from(pos.y) * cell_h;
552
553        // Sprite cache dispatch: sprite wins over bitmap font.
554        #[cfg(feature = "tilesets")]
555        {
556            let buf_h = self.ctx.pixel_buf.as_ref().len() / buf_w;
557            let (glyph_w, glyph_h) = (self.ctx.geometry.glyph_w, self.ctx.geometry.glyph_h);
558            if let Some(sprite) = self.sprite_cache.get(art_glyph) {
559                let (span_w, span_h) = tile.span();
560                let align = sprite.align_offset(span_w, span_h, glyph_w, glyph_h);
561                let recolor =
562                    SpriteTint::resolve(sprite.color, tile.style().foreground(), tint, DEFAULT_FG);
563                blit_sprite(
564                    self.ctx.pixel_buf.as_mut(),
565                    buf_w,
566                    buf_h,
567                    px_x,
568                    px_y,
569                    tile.dx() + align.0,
570                    tile.dy() + align.1,
571                    sprite,
572                    scale,
573                    recolor,
574                );
575                if !tile.is_span_anchor() {
576                    warn_sprite_needs_span(
577                        &mut self.ctx.warned_oversized,
578                        art_glyph,
579                        (sprite.pixel_width, sprite.pixel_height),
580                        (u32::from(glyph_w), u32::from(glyph_h)),
581                    );
582                }
583                return;
584            }
585            // No sprite for this glyph: it falls back to the bitmap font below, which is
586            // `fg`-colored, so a tint that would otherwise recolor a sprite silently has no
587            // effect here (retroglyph#564, #537's exact trap).
588            warn_tint_needs_sprite(&mut self.ctx.warned_dropped_tint, art_glyph, tint);
589        }
590
591        blit_glyph(
592            self.ctx.pixel_buf.as_mut(),
593            buf_w,
594            px_x,
595            px_y,
596            &tile,
597            art_glyph,
598            &self.fonts,
599            scale,
600        );
601    }
602}
603
604// ── Renderer construction ────────────────────────────────────────────────────────────
605
606impl SoftwareBackend {
607    /// Builds a [`SoftwareRenderer`] from this configuration.
608    ///
609    /// This does not block: it returns a [`SoftwareRenderer`] immediately.
610    /// The renderer's pixel buffer can be inspected via
611    /// [`SoftwareRenderer::pixels`] for headless / pixel-level use, or the renderer can be
612    /// handed to `retroglyph_window::winit::run_windowed` to drive a window. Flushing
613    /// is a no-op (the buffer stays in memory).
614    ///
615    /// # Examples
616    ///
617    /// ```
618    /// use retroglyph_core::backend::Output;
619    /// use retroglyph_core::tile::Tile;
620    /// use retroglyph_core::color::Style;
621    /// use retroglyph_core::grid::Pos;
622    /// use retroglyph_core::backend::DrawCell;
623    /// use retroglyph_core::color::Color;
624    /// use retroglyph_software::SoftwareBackendBuilder;
625    ///
626    /// let mut renderer = SoftwareBackendBuilder::new()
627    ///     .grid_size(1, 1)
628    ///     .scale(1)
629    ///     .build()
630    ///     .unwrap()
631    ///     .into_renderer()
632    ///     .unwrap();
633    ///
634    /// // Render a red cell on layer 0.
635    /// let tile = Tile::new(' ', Style::new().bg(Color::Rgb { r: 255, g: 0, b: 0 }));
636    /// renderer
637    ///     .draw_layers([DrawCell::on_layer(0, Pos::new(0, 0), &tile)].into_iter())
638    ///     .unwrap();
639    ///
640    /// assert!(renderer.pixels().iter().all(|&p| p == 0x00FF_0000));
641    /// ```
642    ///
643    /// # Errors
644    ///
645    /// Returns [`SoftwareBackendError::NoFont`] if no font is set, or
646    /// [`SoftwareBackendError::MixedGlyphSizes`] if the font chain's fonts disagree on their
647    /// glyph size (both only reachable if [`SoftwareBackendBuilder::build`] was bypassed),
648    /// [`SoftwareBackendError::ZeroScale`] if `scale` is `0` (likewise only reachable if `build`
649    /// was bypassed, since a caller mutated the field after construction),
650    /// [`SoftwareBackendError::ZeroGrid`] if `cols` or `rows` is `0`, and
651    /// [`SoftwareBackendError::Tileset`] if a registered tileset fails to load.
652    ///
653    /// # Panics
654    ///
655    /// Panics only on a `u32`-to-`usize` conversion that cannot fail on any target
656    /// this crate supports (`usize` is at least 32 bits on every 32- and 64-bit
657    /// platform), so this is not reachable in practice.
658    pub fn into_renderer(self) -> Result<SoftwareRenderer, SoftwareBackendError> {
659        let Some(fonts) = self.fonts else {
660            return Err(SoftwareBackendError::NoFont);
661        };
662        let Some((glyph_w, glyph_h)) = fonts.glyph_size() else {
663            return Err(SoftwareBackendError::MixedGlyphSizes);
664        };
665        if self.scale == 0 {
666            return Err(SoftwareBackendError::ZeroScale);
667        }
668        if self.cols == 0 || self.rows == 0 {
669            return Err(SoftwareBackendError::ZeroGrid);
670        }
671
672        let geometry = CellGeometry::new(glyph_w, glyph_h, u16::from(self.scale));
673        let (buf_w, buf_h) = geometry.surface_size(self.cols, self.rows);
674        // u32 always fits in usize (all targets: 32- and 64-bit).
675        let buf_w = usize::try_from(buf_w).expect("surface width fits usize");
676        let buf_h = usize::try_from(buf_h).expect("surface height fits usize");
677
678        #[cfg(feature = "tilesets")]
679        let sprite_cache = if self.tilesets.is_empty() {
680            Arc::new(SpriteCache::new())
681        } else {
682            Arc::new(
683                SpriteCache::from_tilesets(&self.tilesets)
684                    .map_err(SoftwareBackendError::Tileset)?,
685            )
686        };
687
688        Ok(SoftwareRenderer::create(
689            self,
690            fonts,
691            buf_w,
692            buf_h,
693            geometry,
694            #[cfg(feature = "tilesets")]
695            sprite_cache,
696        ))
697    }
698}
699
700// ── Output impl ─────────────────────────────────────────────────────────────────
701
702impl Output for SoftwareRenderer {
703    type Error = core::convert::Infallible;
704
705    // No `draw` override: this backend always composites (`composites_layers` returns `true`
706    // below), so `Terminal::present` never calls single-layer `draw` and the default
707    // implementation (forwards to `draw_layers`) is exactly right. See retroglyph#561.
708
709    /// Composite the raw layer stream into the pixel buffer.
710    ///
711    /// Layers arrive layer-major (0 first), so painting them in order gives the
712    /// correct z-order. Layer 0 always fills its cell background; a higher layer's
713    /// occupied (non-empty) tile always fills a background too, and an empty tile
714    /// never does: see the private `resolve_bg_fill` helper for the exact color each of those
715    /// cases paints (it is not always the tile's own background, to mirror
716    /// `Grid::flatten_into`'s background-inheritance rule exactly). The `is_empty`
717    /// guard matters because this receives the full frame (see
718    /// [`needs_full_frame`](Output::needs_full_frame)), including empty
719    /// higher-layer cells that must not overwrite layer 0.
720    ///
721    /// This matches cell backends (retroglyph#304): an occupied space with a
722    /// [`Color::Default`] background on a higher layer erases the glyph beneath it
723    /// when flattened, and this backend now does too, by repainting that cell's
724    /// background (see the private `resolve_bg_fill` helper) even though the occupied tile's own
725    /// background is the default one.
726    ///
727    /// A [`TileFlags::SPAN_COVERED`](retroglyph_core::tile::TileFlags::SPAN_COVERED) cell (retroglyph#412) paints its background but not its
728    /// glyph: the span's anchor already drew one sprite across the whole footprint, and the
729    /// covered cell's glyph is that sprite's text fallback, for backends that cannot draw it. The
730    /// sprite-transparency rule that decides whether a background is painted at all is resolved
731    /// against the *anchor* (see `resolve_cell_bg`), so one span never sits on two different
732    /// backdrops.
733    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
734    where
735        I: Iterator<Item = DrawCell<'a>>,
736    {
737        let cols = usize::from(self.options.cols);
738        let rows = usize::from(self.options.rows);
739        let scale = usize::from(self.options.scale);
740        let cell_w = usize::from(self.ctx.geometry.glyph_w) * scale;
741        let cell_h = usize::from(self.ctx.geometry.glyph_h) * scale;
742        let buf_w = cols * cell_w;
743        let cell_count = cols * rows;
744
745        // `needs_full_frame` always returns `true` for this backend, so this receives every cell
746        // on every allocated layer on every call: `Terminal::present`'s diff-only path (used when
747        // a backend's `needs_full_frame` is `false`) never applies here, and changing that would
748        // be a `retroglyph-core` API change (retroglyph#302). Instead, this method keeps its own
749        // per-cell shadow copy of the last frame's tiles (`RenderContext::prev_tiles`) and diffs
750        // incoming cells against it below, entirely internally: cells whose tile is unchanged
751        // since the last call are skipped instead of being cleared and repainted.
752        if self.ctx.dirty_mask.len() == cell_count {
753            self.ctx.dirty_mask.iter_mut().for_each(|d| *d = false);
754        } else {
755            self.ctx.dirty_mask.clear();
756            self.ctx.dirty_mask.resize(cell_count, false);
757        }
758
759        let mut any_offset = false;
760        let mut any_dirty = false;
761        let mut max_layer_seen: i32 = -1;
762
763        for draw_cell in content {
764            let (layer_id, pos, tile) = (draw_cell.layer, draw_cell.pos, draw_cell.tile);
765            // Silently drop cells positioned outside the grid, the same as `Headless`'s
766            // `put_tile` (which bounds-checks internally): a caller-supplied `pos` is not
767            // trusted input, and indexing it unchecked below would panic instead.
768            if usize::from(pos.x) >= cols || usize::from(pos.y) >= rows {
769                continue;
770            }
771            let layer_idx = usize::from(layer_id);
772            max_layer_seen = max_layer_seen.max(i32::from(layer_id));
773            self.ensure_layer_shadow(layer_idx, cols, rows);
774
775            let idx = usize::from(pos.y) * cols + usize::from(pos.x);
776            let slot = &mut self.ctx.prev_tiles[layer_idx].as_mut()[idx];
777            let tint_slot = &mut self.ctx.prev_tints[layer_idx].as_mut()[idx];
778            if *slot != *tile || *tint_slot != draw_cell.tint {
779                // `dirty_mask` is a single array shared across layers, not one per layer: marking
780                // an index dirty here forces every layer to repaint that cell below, even ones
781                // unchanged at this position, because a lower layer's background fill covers the
782                // whole cell rect and would otherwise erase an unchanged higher layer's
783                // already-composited glyph pixels on top of it.
784                self.ctx.dirty_mask[idx] = true;
785                any_dirty = true;
786                *slot = *tile;
787                *tint_slot = draw_cell.tint;
788            }
789            if tile.dx() != 0 || tile.dy() != 0 {
790                any_offset = true;
791            }
792        }
793
794        if any_dirty {
795            // Runs after the whole stream, so every layer's shadow copy is current: a span's
796            // footprint has to be read off an anchor this frame actually wrote. A covered cell's
797            // tile does not change when only the anchor's artwork does, so a dirty cell anywhere
798            // in a span has to dirty the whole span here; without that, the previous sprite's
799            // pixels would survive in cells the diff considers unchanged.
800            for layer in &self.ctx.prev_tiles {
801                expand_dirty_spans(&mut self.ctx.dirty_mask, layer.as_ref(), cols, rows);
802            }
803        }
804
805        #[allow(clippy::cast_sign_loss)]
806        let layer_count_now = (max_layer_seen + 1) as usize;
807        let layers_changed = layer_count_now != self.ctx.prev_layer_count;
808        self.ctx.prev_layer_count = layer_count_now;
809
810        // Falls back to a full clear-and-repaint of every cell when either:
811        // - any tile this frame or the last one has a nonzero sub-cell offset (`Tile::dx`/`dy`):
812        //   offsets can spill glyph pixels into neighboring cells by an amount `Tile` does not
813        //   bound, so containing the repaint to a neighborhood around the changed cells isn't
814        //   possible without a magnitude cap core doesn't provide. The previous frame's offsets
815        //   matter just as much as this frame's: a frame that removes the last offset can leave a
816        //   neighbor cell's own tile byte-identical to last frame, so the dirty-cell path never
817        //   revisits it to clear the spill that landed there, exactly like `layers_changed` below
818        //   already compares against last frame's state instead of only this frame's; or
819        // - the number of allocated layers changed since the last call: a layer's cells falling
820        //   out of (or into) the frame can't be diffed against a shadow copy that no longer
821        //   describes this frame's layer set.
822        let full_repaint = any_offset || self.ctx.prev_offset || layers_changed;
823        self.ctx.prev_offset = any_offset;
824
825        if full_repaint {
826            self.ctx.pixel_buf.clear();
827            for layer_id in 0..layer_count_now {
828                #[allow(clippy::cast_possible_truncation)]
829                let layer_id = layer_id as u8;
830                // Pass 1: lay down every cell's background on this layer first.
831                for idx in 0..cell_count {
832                    let bg_fill = self.resolve_cell_bg(layer_id, idx, cols);
833                    let (x, y) = flat_index_to_xy(idx, cols);
834                    let pos = Pos::new(x, y);
835                    self.fill_cell_bg(cell_w, cell_h, pos, bg_fill);
836                }
837                // Pass 2: blit every cell's glyph over those backgrounds. A glyph offset past its
838                // right/bottom edge now spills onto the neighbor's already-painted background
839                // instead of being clobbered by that neighbor's later background fill, so spill is
840                // uniform in all four directions: the two-pass mechanism of the sub-cell
841                // offset/spill contract on `retroglyph_window::Presenter` (see its rustdoc).
842                for idx in 0..cell_count {
843                    let tile = self.ctx.prev_tiles[layer_id as usize].as_ref()[idx];
844                    let tint = self.ctx.prev_tints[layer_id as usize].as_ref()[idx];
845                    let (x, y) = flat_index_to_xy(idx, cols);
846                    let pos = Pos::new(x, y);
847                    self.blit_cell_glyph(buf_w, cell_w, cell_h, scale, pos, tile, tint);
848                }
849            }
850        } else if any_dirty {
851            // The same two-pass split as the full repaint, restricted to the dirty cells: every
852            // dirty cell's background on a layer goes down before any of that layer's glyphs, so
853            // artwork that spills out of its own cell (a multi-cell span's sprite, or a glyph
854            // pushed out by a sub-cell offset) is not erased by the neighbour's background fill
855            // arriving after it.
856            for layer_id in 0..layer_count_now {
857                #[allow(clippy::cast_possible_truncation)]
858                let layer_id = layer_id as u8;
859                for idx in 0..cell_count {
860                    if !self.ctx.dirty_mask[idx] {
861                        continue;
862                    }
863                    let bg_fill = self.resolve_cell_bg(layer_id, idx, cols);
864                    let (x, y) = flat_index_to_xy(idx, cols);
865                    let pos = Pos::new(x, y);
866                    self.fill_cell_bg(cell_w, cell_h, pos, bg_fill);
867                }
868                for idx in 0..cell_count {
869                    if !self.ctx.dirty_mask[idx] {
870                        continue;
871                    }
872                    let tile = self.ctx.prev_tiles[usize::from(layer_id)].as_ref()[idx];
873                    let tint = self.ctx.prev_tints[usize::from(layer_id)].as_ref()[idx];
874                    let (x, y) = flat_index_to_xy(idx, cols);
875                    let pos = Pos::new(x, y);
876                    self.blit_cell_glyph(buf_w, cell_w, cell_h, scale, pos, tile, tint);
877                }
878            }
879        }
880
881        self.update_damage(buf_w);
882        Ok(())
883    }
884
885    fn flush(&mut self) -> Result<(), Self::Error> {
886        // No-op. Frame is presented via WindowedBackend::present() in windowed
887        // mode, or accessed directly via pixels() in headless/testing mode.
888        Ok(())
889    }
890
891    fn size(&self) -> Size {
892        Size::new(self.options.cols, self.options.rows)
893    }
894
895    fn resize(&mut self, size: Size) {
896        self.options.cols = size.width();
897        self.options.rows = size.height();
898        // Cell size is constant (glyph x scale); only the surface size changes with the grid.
899        let (new_w, new_h) = self.ctx.geometry.surface_size(size.width(), size.height());
900        let new_w = usize::try_from(new_w).expect("surface width fits usize");
901        let new_h = usize::try_from(new_h).expect("surface height fits usize");
902        self.ctx.pixel_buf.resize(new_w, new_h);
903        // Buffer dimensions changed: the shadow buffer no longer matches,
904        // so drop it and force a full-frame damage rect on the next present.
905        self.ctx.prev_pixels.clear();
906        self.ctx.prev_pixels.resize(new_w * new_h, 0);
907        // The per-cell tile and tint shadows are keyed by the old grid dimensions; drop every
908        // layer's `GridBuf` entirely rather than resizing them in place (`ensure_layer_shadow`
909        // lazily rebuilds each one with `GridBuf::new_filled` at the new dimensions on the next
910        // `draw_layers` call), so the next call can't misread stale entries against the new
911        // layout, and force that call onto the full-repaint path (`prev_layer_count` back to its
912        // initial value never matches a real frame's layer count).
913        self.ctx.prev_tiles.clear();
914        self.ctx.prev_tints.clear();
915        self.ctx.dirty_mask.clear();
916        self.ctx.prev_layer_count = usize::MAX;
917        self.ctx.prev_offset = false;
918        self.ctx.damage_rows = if new_h == 0 {
919            None
920        } else {
921            #[allow(clippy::cast_possible_truncation)]
922            Some((0, new_h as u32))
923        };
924    }
925
926    fn clear(&mut self) -> Result<(), Self::Error> {
927        self.ctx.pixel_buf.clear();
928        // The per-cell shadow is now stale versus what's actually on screen (blank); forget it,
929        // mirroring `resize` above, so the next `draw_layers` call can't diff against pre-clear
930        // state and takes the full-repaint path instead of painting nothing.
931        self.ctx.prev_tiles.clear();
932        self.ctx.prev_tints.clear();
933        self.ctx.dirty_mask.clear();
934        self.ctx.prev_layer_count = usize::MAX;
935        self.ctx.prev_offset = false;
936        Ok(())
937    }
938
939    fn needs_full_frame(&self) -> bool {
940        true
941    }
942
943    fn composites_layers(&self) -> bool {
944        true
945    }
946}
947
948// ── Input impl ───────────────────────────────────────────────────────────────────
949
950impl Input for SoftwareRenderer {
951    fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
952        // Non-blocking: the game loop is driven by `about_to_wait` →
953        // `request_redraw`, so there is no background thread to sleep on.
954        // All backends return immediately regardless of platform.
955        self.ctx.event_buffer.pop_front()
956    }
957
958    fn push_event(&mut self, event: Event) {
959        Self::push_event(self, event);
960    }
961}
962
963// ── Cursor impl ──────────────────────────────────────────────────────────────────
964
965impl Cursor for SoftwareRenderer {
966    fn set_cursor_visible(&mut self, _visible: bool) {
967        // No hardware cursor in software mode.
968    }
969
970    fn set_cursor_position(&mut self, _position: Pos) {
971        // No hardware cursor in software mode.
972    }
973}
974
975// ── Presenter impl ───────────────────────────────────────────────────────────
976
977// `SoftwareRenderer`'s own `Output` impl above already satisfies `Presenter: Output`, so this
978// only needs the surface lifecycle: no forwarding/duplication of draw/flush/size/clear/resize.
979impl retroglyph_window::Presenter for SoftwareRenderer {
980    type SurfaceError = SurfaceError;
981
982    fn init_surface(&mut self, window: Arc<dyn WindowHandle>) -> Result<(), SurfaceError> {
983        Self::init_surface(self, window)
984    }
985
986    fn resize_surface(&mut self, width: u32, height: u32) {
987        Self::resize_surface(self, width, height);
988    }
989
990    fn present(&mut self) -> Result<(), SurfaceError> {
991        Self::present(self)
992    }
993
994    fn cell_size(&self) -> (u32, u32) {
995        self.ctx.geometry.cell_size()
996    }
997
998    fn geometry(&self) -> CellGeometry {
999        self.ctx.geometry
1000    }
1001}
1002
1003// ── Grid compositing ──────────────────────────────────────────────────────────
1004
1005/// Paints the set ("on") pixels of glyph `glyph_index` in `font` into `buffer` as `color`, with
1006/// each source pixel scaled to a `scale x scale` destination block.
1007///
1008/// Set pixels come from [`BitmapFont::glyph_pixels`], the one place the 1-bit MSB-first bit layout
1009/// is decoded (shared with the GL atlas builder), so this backend and `retroglyph-gl` can't
1010/// disagree on which texels a glyph covers, and neither hardcodes an 8-pixel row.
1011///
1012/// The glyph's top-left destination corner is `(origin_x, origin_y)`
1013/// (already including any sub-cell `dx`/`dy` offset, scaled). When the whole
1014/// glyph's destination bounding box fits inside `buffer` (the overwhelmingly
1015/// common case, since it only fails for cells with a nonzero `dx`/`dy` that
1016/// pushes them past a buffer edge), this takes a fast path with no per-pixel
1017/// bounds check: it fills each `scale`-wide destination run in one slice
1018/// `fill` call. Otherwise it falls back to a row-clamped path that clips
1019/// each destination run to the buffer bounds once per row, rather than
1020/// checking every pixel.
1021/// Decodes a flat row-major cell index into `(x, y)`, given the grid's `cols`.
1022///
1023/// Delegates to [`RowMajor`]'s [`LinearLayout::index_to_pos`] instead of hand-rolling
1024/// `idx % cols` / `idx / cols` at each flat-buffer call site below.
1025fn flat_index_to_xy(idx: usize, cols: usize) -> (u16, u16) {
1026    let pos = RowMajor::index_to_pos(idx, cols);
1027    #[allow(clippy::cast_possible_truncation)]
1028    (pos.x as u16, pos.y as u16)
1029}
1030
1031#[allow(clippy::too_many_arguments, clippy::cast_possible_truncation)]
1032fn blit_glyph_mask(
1033    buffer: &mut [u32],
1034    buf_w: usize,
1035    buf_h: usize,
1036    origin_x: i64,
1037    origin_y: i64,
1038    font: &BitmapFont,
1039    glyph_index: u8,
1040    scale: usize,
1041    color: u32,
1042) {
1043    let glyph_w = usize::from(font.glyph_width()) * scale;
1044    let glyph_h = usize::from(font.glyph_height()) * scale;
1045
1046    #[allow(clippy::cast_sign_loss)]
1047    let in_bounds = origin_x >= 0
1048        && origin_y >= 0
1049        && origin_x as usize + glyph_w <= buf_w
1050        && origin_y as usize + glyph_h <= buf_h;
1051
1052    if in_bounds {
1053        #[allow(clippy::cast_sign_loss)]
1054        let ox = origin_x as usize;
1055        #[allow(clippy::cast_sign_loss)]
1056        let oy = origin_y as usize;
1057        for (src_x, src_y) in font.glyph_pixels(glyph_index) {
1058            let x0 = ox + usize::from(src_x) * scale;
1059            let y0 = oy + usize::from(src_y) * scale;
1060            for sdy in 0..scale {
1061                let row_start = (y0 + sdy) * buf_w + x0;
1062                buffer[row_start..row_start + scale].fill(color);
1063            }
1064        }
1065        return;
1066    }
1067
1068    #[allow(
1069        clippy::cast_possible_wrap,
1070        clippy::cast_sign_loss,
1071        clippy::similar_names
1072    )]
1073    for (src_x, src_y) in font.glyph_pixels(glyph_index) {
1074        for sdy in 0..scale {
1075            let y = origin_y + (usize::from(src_y) * scale + sdy) as i64;
1076            if y < 0 || y as usize >= buf_h {
1077                continue;
1078            }
1079            let y = y as usize;
1080            let x_start = origin_x + (usize::from(src_x) * scale) as i64;
1081            let x_end = x_start + scale as i64;
1082            let x0 = x_start.max(0);
1083            let x1 = x_end.min(buf_w as i64);
1084            if x0 >= x1 {
1085                continue;
1086            }
1087            let row_start = y * buf_w + x0 as usize;
1088            let row_end = y * buf_w + x1 as usize;
1089            buffer[row_start..row_end].fill(color);
1090        }
1091    }
1092}
1093
1094/// Blits a glyph's set bits into `buffer` at `(px_x, px_y)` plus sub-cell
1095/// offset from `tile.dx`/`tile.dy`. Only the foreground (glyph) pixels are
1096/// painted; background is left untouched.
1097///
1098/// `art_glyph` is the caller-resolved [`cell_art_glyph`] answer for `tile`: whether this cell
1099/// draws at all (span-covered and blank cells are filtered before this is ever called) is decided
1100/// once by the caller, not re-derived here.
1101#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
1102fn blit_glyph(
1103    buffer: &mut [u32],
1104    buf_w: usize,
1105    px_x: usize,
1106    px_y: usize,
1107    tile: &Tile,
1108    art_glyph: char,
1109    fonts: &FontChain<'static>,
1110    scale: usize,
1111) {
1112    let fg = resolve_color(tile.style().foreground(), DEFAULT_FG);
1113
1114    #[allow(clippy::cast_possible_wrap)]
1115    let origin_x = px_x as i64 + i64::from(tile.dx()) * scale as i64;
1116    #[allow(clippy::cast_possible_wrap)]
1117    let origin_y = px_y as i64 + i64::from(tile.dy()) * scale as i64;
1118
1119    // Nothing in the chain can draw this character, not even a substitute box: leave the cell
1120    // at its background rather than pointing at a glyph index some font doesn't have.
1121    let Some(glyph) = fonts.resolve(art_glyph) else {
1122        return;
1123    };
1124    let buf_h = buffer.len() / buf_w;
1125
1126    blit_glyph_mask(
1127        buffer,
1128        buf_w,
1129        buf_h,
1130        origin_x,
1131        origin_y,
1132        &glyph.font(),
1133        glyph.index(),
1134        scale,
1135        fg,
1136    );
1137}
1138
1139/// Blit a decoded RGBA8 sprite into `buffer` with alpha blending.
1140///
1141/// The sprite's top-left corner is at pixel `(cell_px_x + offset_x * scale,
1142/// cell_px_y + offset_y * scale)`, where the offset is in unscaled pixels and combines the
1143/// tile's own sub-cell `dx`/`dy` with the sprite's alignment inside its span box (see
1144/// [`Sprite::align_offset`]). A sprite larger than one cell extends past the anchor cell into
1145/// the cells its span covers.
1146///
1147/// Pixels outside `buffer` bounds are silently clipped.
1148///
1149/// Blending uses pure integer `U8x4Rgba::source_over`. Fully opaque pixels
1150/// (alpha == 255) skip blending entirely and write directly to the buffer.
1151///
1152/// That arithmetic runs on sRGB-encoded bytes rather than in linear light, which is deliberate:
1153/// pixel art is authored in editors that composite the same way, and this blit is the reference
1154/// the GPU backends are checked against pixel for pixel, so it defines the result for all three.
1155/// See `docs/references/core/color-space.md`.
1156#[cfg(feature = "tilesets")]
1157#[allow(
1158    clippy::cast_possible_truncation,
1159    clippy::cast_possible_wrap,
1160    clippy::cast_sign_loss,
1161    clippy::similar_names,
1162    clippy::too_many_arguments
1163)]
1164fn blit_sprite(
1165    buffer: &mut [u32],
1166    buf_w: usize,
1167    buf_h: usize,
1168    cell_px_x: usize,
1169    cell_px_y: usize,
1170    offset_x: i16,
1171    offset_y: i16,
1172    sprite: &Sprite,
1173    scale: usize,
1174    recolor: SpriteTint,
1175) {
1176    let origin_x = cell_px_x as i64 + i64::from(offset_x) * scale as i64;
1177    let origin_y = cell_px_y as i64 + i64::from(offset_y) * scale as i64;
1178
1179    let src_w = sprite.pixel_width as usize;
1180    let src_h = sprite.pixel_height as usize;
1181
1182    // Precompute once whether the sprite's whole destination bounding box
1183    // fits inside `buffer`: true for the overwhelmingly common case (no
1184    // sub-cell offset, sprite fully on-screen). When it does, the fast path
1185    // below skips the per-destination-pixel bounds check entirely; only
1186    // sprites clipped by a nonzero `dx`/`dy` or a screen edge fall back to
1187    // the clamped, per-row-checked slow path.
1188    let glyph_w = src_w * scale;
1189    let glyph_h = src_h * scale;
1190    let in_bounds = origin_x >= 0
1191        && origin_y >= 0
1192        && origin_x as usize + glyph_w <= buf_w
1193        && origin_y as usize + glyph_h <= buf_h;
1194    let identity = recolor.is_identity();
1195
1196    for src_y in 0..src_h {
1197        for src_x in 0..src_w {
1198            let src_idx = (src_y * src_w + src_x) * 4;
1199            let src = U8x4Rgba::new(
1200                sprite.pixels[src_idx],
1201                sprite.pixels[src_idx + 1],
1202                sprite.pixels[src_idx + 2],
1203                sprite.pixels[src_idx + 3],
1204            );
1205
1206            if src.is_transparent() {
1207                continue;
1208            }
1209
1210            // `identity` is loop-invariant, hoisted above by the compiler: an untinted sprite
1211            // (the overwhelming majority) takes exactly the arithmetic it took before this
1212            // branch existed. Alpha is untouched, so which pixels are opaque, and
1213            // therefore the blending below and the background showing through, are unaffected.
1214            let src = if identity {
1215                src
1216            } else {
1217                let (r, g, b) = recolor.apply((src.r, src.g, src.b));
1218                U8x4Rgba::new(r, g, b, src.a)
1219            };
1220
1221            // Fast path: fully opaque pixels write directly, no blending.
1222            // Most roguelike sprites are opaque, so this skips U8x4Rgba
1223            // construction + source_over for the common case.
1224            let rgb = src.to_rgb_u32();
1225            if src.alpha() == 255 {
1226                if in_bounds {
1227                    let x0 = origin_x as usize + src_x * scale;
1228                    let y0 = origin_y as usize + src_y * scale;
1229                    for dy in 0..scale {
1230                        let row_start = (y0 + dy) * buf_w + x0;
1231                        buffer[row_start..row_start + scale].fill(rgb);
1232                    }
1233                } else {
1234                    for dy in 0..scale {
1235                        let dst_y = origin_y + (src_y * scale + dy) as i64;
1236                        if dst_y < 0 || dst_y as usize >= buf_h {
1237                            continue;
1238                        }
1239                        let dst_y = dst_y as usize;
1240                        let x_start = origin_x + (src_x * scale) as i64;
1241                        let x_end = x_start + scale as i64;
1242                        let x0 = x_start.max(0);
1243                        let x1 = x_end.min(buf_w as i64);
1244                        if x0 >= x1 {
1245                            continue;
1246                        }
1247                        let row_start = dst_y * buf_w + x0 as usize;
1248                        let row_end = dst_y * buf_w + x1 as usize;
1249                        buffer[row_start..row_end].fill(rgb);
1250                    }
1251                }
1252                continue;
1253            }
1254
1255            // Each source pixel maps to `scale x scale` destination pixels.
1256            if in_bounds {
1257                let x0 = origin_x as usize + src_x * scale;
1258                let y0 = origin_y as usize + src_y * scale;
1259                for dy in 0..scale {
1260                    let row = (y0 + dy) * buf_w;
1261                    for dx in 0..scale {
1262                        let dst_idx = row + x0 + dx;
1263                        let dst = U8x4Rgba::from_rgb_u32(buffer[dst_idx]);
1264                        let blended = src.source_over(dst);
1265                        buffer[dst_idx] = blended.to_rgb_u32();
1266                    }
1267                }
1268                continue;
1269            }
1270
1271            for dy in 0..scale {
1272                let dst_y = origin_y + (src_y * scale + dy) as i64;
1273                if dst_y < 0 || dst_y as usize >= buf_h {
1274                    continue;
1275                }
1276                let dst_y = dst_y as usize;
1277
1278                for dx in 0..scale {
1279                    let dst_x = origin_x + (src_x * scale + dx) as i64;
1280                    if dst_x < 0 || dst_x as usize >= buf_w {
1281                        continue;
1282                    }
1283                    let dst_x = dst_x as usize;
1284
1285                    let dst_idx = dst_y * buf_w + dst_x;
1286
1287                    let dst = U8x4Rgba::from_rgb_u32(buffer[dst_idx]);
1288                    let blended = src.source_over(dst);
1289                    buffer[dst_idx] = blended.to_rgb_u32();
1290                }
1291            }
1292        }
1293    }
1294}
1295
1296/// Extends `dirty` so that whenever any cell of a multi-cell span is dirty, every cell of that
1297/// span is.
1298///
1299/// A span's artwork is drawn once, from its anchor, across the whole footprint, so repainting
1300/// part of one is never right: the anchor has to be redrawn, and every cell it paints over has to
1301/// have its background laid down again first. The diff that fills `dirty` cannot see this, because
1302/// a covered cell's tile holds only the fallback glyph and the offset back to the anchor: both
1303/// unchanged while the anchor's artwork changes underneath it.
1304///
1305/// Expansion always runs from the anchor outwards over its full declared footprint, so one pass
1306/// reaches every cell no matter which one of them was dirty to begin with. Cells past the grid
1307/// edge are skipped: a span cannot be written past the edge, but this reads a shadow buffer that
1308/// can lag a resize by a frame.
1309fn expand_dirty_spans(dirty: &mut [bool], layer: &[Tile], cols: usize, rows: usize) {
1310    if cols == 0 {
1311        return;
1312    }
1313    for idx in 0..layer.len().min(dirty.len()) {
1314        if !dirty[idx] {
1315            continue;
1316        }
1317        let tile = layer[idx];
1318        let anchor_idx = if tile.span_offset().is_some() {
1319            let Some(anchor_idx) = tile.span_anchor_index(idx, cols) else {
1320                continue;
1321            };
1322            anchor_idx
1323        } else if tile.is_span_anchor() {
1324            idx
1325        } else {
1326            continue;
1327        };
1328        let Some(anchor) = layer.get(anchor_idx) else {
1329            continue;
1330        };
1331        let (span_w, span_h) = anchor.span();
1332        let ixy_pos = RowMajor::index_to_pos(anchor_idx, cols);
1333        let (ax, ay) = (ixy_pos.x, ixy_pos.y);
1334        for row in ay..(ay + usize::from(span_h)).min(rows) {
1335            for col in ax..(ax + usize::from(span_w)).min(cols) {
1336                dirty[row * cols + col] = true;
1337            }
1338        }
1339    }
1340}
1341
1342// The canonical `Color::Default` fallbacks live in `retroglyph-window`'s `palette` module so the
1343// gl and software backends can't drift; imported above as `(u8, u8, u8)` triples.
1344
1345/// Determines the background this layer/tile should paint at `idx`, if any,
1346/// mirroring [`Grid::flatten_into`](retroglyph_core::grid::Grid)'s background-inheritance rule so
1347/// cell and pixel backends agree (retroglyph#304).
1348///
1349/// - Layer 0 always paints: its own background, substituting [`DEFAULT_BG`] for
1350///   [`Color::Default`].
1351/// - A higher layer's empty tile paints nothing (`None`): it doesn't contribute to the flattened
1352///   cell at all.
1353/// - A higher layer's occupied (non-empty) tile with a non-[`Color::Default`] background paints
1354///   that color.
1355/// - A higher layer's occupied tile with a [`Color::Default`] background still paints, *unless*
1356///   `has_sprite` is `true`: this is the fix for retroglyph#304, an occupied space is opaque and
1357///   erases whatever glyph a lower layer drew there, even though its own background is the
1358///   default one. What it paints with is *not* [`DEFAULT_BG`] though: matching `flatten_into`'s
1359///   `if tile.style.bg != Color::Default` guard, a `Color::Default` background never overwrites
1360///   the destination background, so this walks back down through the layers below `layer_id`
1361///   (down to and including layer 0) to find whichever one last established a background, and
1362///   repaints with that instead. `has_sprite` opts a tile out of this rule entirely: sprites
1363///   carry genuine per-pixel alpha (see [`SoftwareRenderer::has_sprite`]), so forcing an opaque
1364///   fill underneath one before it's blended would erase transparency the sprite's own pixels are
1365///   supposed to let show through: core's `Tile`/`Grid` model has no such per-pixel concept, so
1366///   the cell-backend-parity rule this function otherwise implements just doesn't apply to them.
1367fn resolve_bg_fill(
1368    prev_tiles: &[GridBuf<Tile, Vec<Tile>, RowMajor>],
1369    layer_id: u8,
1370    idx: usize,
1371    has_sprite: bool,
1372) -> Option<u32> {
1373    let layer_idx = usize::from(layer_id);
1374    let tile = prev_tiles[layer_idx].as_ref()[idx];
1375    if layer_idx == 0 {
1376        return Some(resolve_color(tile.style().background(), DEFAULT_BG));
1377    }
1378    if tile.is_empty() {
1379        return None;
1380    }
1381    if tile.style().background() != Color::Default {
1382        return Some(resolve_color(tile.style().background(), DEFAULT_BG));
1383    }
1384    if has_sprite {
1385        return None;
1386    }
1387    for below in (0..layer_idx).rev() {
1388        let bg = prev_tiles[below].as_ref()[idx].style().background();
1389        if below == 0 || bg != Color::Default {
1390            return Some(resolve_color(bg, DEFAULT_BG));
1391        }
1392    }
1393    unreachable!("the loop above always terminates at `below == 0`, which always returns")
1394}
1395
1396/// Resolve a [`Color`] to a packed `0x00RRGGBB` value, substituting `default` for
1397/// [`Color::Default`].
1398///
1399/// Delegates the actual palette resolution to `retroglyph-core`'s [`Color::resolve_rgb`], the
1400/// single canonical color-to-RGB path every graphical backend shares (so the CPU rasterizer and
1401/// `retroglyph-gl`'s GPU atlas agree on every pixel color); this only repacks core's `(r, g, b)`
1402/// triple into this backend's `0x00RRGGBB` `u32` pixel format. The `default` fallback is a triple
1403/// too (from [`retroglyph_window::palette`]), so no unpack step is needed.
1404fn resolve_color(color: Color, default: (u8, u8, u8)) -> u32 {
1405    let (r, g, b) = color.resolve_rgb(default);
1406    (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)
1407}
1408
1409// ── Tests ─────────────────────────────────────────────────────────────────────
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414    use retroglyph_core::color::Color;
1415    use retroglyph_core::color::Style;
1416    use retroglyph_core::grid::{Pos, Size};
1417
1418    fn test_renderer() -> SoftwareRenderer {
1419        SoftwareBackendBuilder::new()
1420            .font(retroglyph_window::font::unscii16::FONT)
1421            .grid_size(1, 1)
1422            .scale(1)
1423            .build()
1424            .unwrap()
1425            .into_renderer()
1426            .unwrap()
1427    }
1428
1429    #[test]
1430    fn presenter_geometry_and_cell_size_match_the_internal_geometry() {
1431        use retroglyph_window::Presenter as _;
1432
1433        // `test_renderer` builds an 8x16 unscii16 font at scale 1.
1434        let renderer = test_renderer();
1435        assert_eq!(renderer.cell_size(), (8, 16));
1436        assert_eq!(renderer.geometry(), CellGeometry::new(8, 16, 1));
1437    }
1438
1439    #[test]
1440    fn layer0_paints_background() {
1441        let mut renderer = test_renderer();
1442        let tile = Tile::new(' ', Style::new().bg(Color::Rgb { r: 255, g: 0, b: 0 }));
1443        let diff: Vec<DrawCell<'_>> = vec![DrawCell::on_layer(0, Pos::new(0, 0), &tile)];
1444        renderer.draw_layers(diff.into_iter());
1445
1446        let buf = renderer.pixels();
1447        assert_eq!(buf.len(), 8 * 16);
1448        assert!(
1449            buf.iter().all(|&p| p == 0x00FF_0000),
1450            "all pixels should be red"
1451        );
1452    }
1453
1454    #[test]
1455    fn layer1_does_not_paint_background() {
1456        let mut renderer = test_renderer();
1457
1458        let bg_tile = Tile::new(' ', Style::new().bg(Color::Rgb { r: 255, g: 0, b: 0 }));
1459        let space_tile = Tile::new(' ', Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }));
1460        // draw_layers clears buffer first, so pass all layers in one call.
1461        renderer.draw_layers(
1462            [
1463                DrawCell::on_layer(0, Pos::new(0, 0), &bg_tile),
1464                DrawCell::on_layer(1, Pos::new(0, 0), &space_tile),
1465            ]
1466            .into_iter(),
1467        );
1468
1469        let buf = renderer.pixels();
1470        assert_eq!(buf.len(), 8 * 16);
1471        // All pixels should be red (layer 0 bg).  Green fg from layer 1's
1472        // space tile is ignored because space has no set bits.
1473        assert!(
1474            buf.iter().all(|&p| p == 0x00FF_0000),
1475            "all pixels should be red, not green"
1476        );
1477    }
1478
1479    #[test]
1480    fn layer1_glyph_overwrites_layer0() {
1481        let mut renderer = test_renderer();
1482
1483        let bg = Tile::new(
1484            ' ',
1485            Style::new().bg(Color::Rgb {
1486                r: 10,
1487                g: 10,
1488                b: 10,
1489            }),
1490        );
1491        let fg = Tile::new('@', Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }));
1492        // draw_layers clears buffer first, so pass all layers in one call.
1493        renderer.draw_layers(
1494            [
1495                DrawCell::on_layer(0, Pos::new(0, 0), &bg),
1496                DrawCell::on_layer(1, Pos::new(0, 0), &fg),
1497            ]
1498            .into_iter(),
1499        );
1500
1501        let buf = renderer.pixels();
1502        assert!(buf.contains(&0x0000_FF00), "some pixels should be green");
1503        assert!(buf.iter().all(|&p| p == 0x0000_FF00 || p == 0x000A_0A0A));
1504    }
1505
1506    #[test]
1507    fn layer1_occupied_default_bg_erases_layer0_glyph() {
1508        // retroglyph#304: an occupied (non-empty) higher-layer tile with a
1509        // `Color::Default` background is opaque and erases whatever glyph a lower
1510        // layer drew underneath it, matching cell backends' `Grid::flatten_into`
1511        // (see the crate README's "Backend parity" section). The erased cell's
1512        // background is inherited from layer 0 (red here), not reset to this
1513        // renderer's own default background.
1514        let mut renderer = test_renderer();
1515
1516        let glyph_on_red = Tile::new(
1517            '@',
1518            Style::new()
1519                .fg(Color::Rgb { r: 0, g: 255, b: 0 })
1520                .bg(Color::Rgb { r: 255, g: 0, b: 0 }),
1521        );
1522        // Occupied (non-empty, via the `Tile::new` builder) space with no explicit
1523        // background: `Color::Default`.
1524        let occupied_default_bg = Tile::new(' ', Style::default());
1525        // draw_layers clears buffer first, so pass all layers in one call.
1526        renderer.draw_layers(
1527            [
1528                DrawCell::on_layer(0, Pos::new(0, 0), &glyph_on_red),
1529                DrawCell::on_layer(1, Pos::new(0, 0), &occupied_default_bg),
1530            ]
1531            .into_iter(),
1532        );
1533
1534        let buf = renderer.pixels();
1535        assert!(
1536            buf.iter().all(|&p| p == 0x00FF_0000),
1537            "layer 1's occupied default-bg space should erase layer 0's glyph, leaving only \
1538             the inherited red background, but got: {buf:?}"
1539        );
1540    }
1541
1542    #[test]
1543    fn sub_cell_offset_shifts_glyph() {
1544        let mut renderer = test_renderer();
1545
1546        let bg = Tile::new(
1547            ' ',
1548            Style::new().bg(Color::Rgb {
1549                r: 10,
1550                g: 10,
1551                b: 10,
1552            }),
1553        );
1554        let fg =
1555            Tile::new('@', Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 })).with_offset(1, 0);
1556        // draw_layers clears buffer first, so pass all layers in one call.
1557        renderer.draw_layers(
1558            [
1559                DrawCell::on_layer(0, Pos::new(0, 0), &bg),
1560                DrawCell::on_layer(1, Pos::new(0, 0), &fg),
1561            ]
1562            .into_iter(),
1563        );
1564
1565        let buf = renderer.pixels();
1566        let has_green = |col: usize| {
1567            buf.iter()
1568                .enumerate()
1569                .any(|(i, &p)| i % 8 == col && p == 0x0000_FF00)
1570        };
1571        assert!(!has_green(0), "x=0 should have no green pixels with dx=1");
1572        assert!(has_green(1), "x=1 should have green pixels with dx=1");
1573    }
1574
1575    #[test]
1576    fn output_draw_respects_sub_cell_offset_via_draw_layers() {
1577        // `Output::draw` has no body of its own (retroglyph#561): it forwards to `draw_layers`,
1578        // tagged onto layer 0. This exercises that forwarding end to end rather than a
1579        // separately-maintained single-layer blit path, which is what used to make it possible
1580        // for the two to silently disagree.
1581        let mut renderer = test_renderer();
1582
1583        let fg =
1584            Tile::new('@', Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 })).with_offset(1, 0);
1585        Output::draw(
1586            &mut renderer,
1587            [DrawCell::new(Pos::new(0, 0), &fg)].into_iter(),
1588        )
1589        .unwrap();
1590
1591        let buf = renderer.pixels();
1592        let has_green = |col: usize| {
1593            buf.iter()
1594                .enumerate()
1595                .any(|(i, &p)| i % 8 == col && p == 0x0000_FF00)
1596        };
1597        assert!(!has_green(0), "x=0 should have no green pixels with dx=1");
1598        assert!(has_green(1), "x=1 should have green pixels with dx=1");
1599    }
1600
1601    #[test]
1602    fn draw_layers_ignores_a_cell_positioned_outside_the_grid() {
1603        // retroglyph#729: a `DrawCell` positioned outside `size()` used to index the shadow
1604        // buffer out of bounds and panic; `Headless` already silently drops such cells, so this
1605        // backend should too instead of disagreeing on the input.
1606        let mut renderer = SoftwareBackendBuilder::new()
1607            .font(retroglyph_window::font::unscii16::FONT)
1608            .grid_size(2, 2)
1609            .scale(1)
1610            .build()
1611            .unwrap()
1612            .into_renderer()
1613            .unwrap();
1614
1615        let tile = Tile::new('X', Style::new());
1616        renderer
1617            .draw_layers(core::iter::once(DrawCell::on_layer(
1618                0,
1619                Pos::new(5, 5),
1620                &tile,
1621            )))
1622            .expect("out-of-range cells are silently dropped, not a panic");
1623    }
1624
1625    #[test]
1626    fn draw_layers_spills_glyph_right_into_the_neighbor_cell() {
1627        // Regression guard for uniform spill: a glyph offset past its right edge must land on the
1628        // neighbor's already-painted background, not be clobbered by the neighbor's background
1629        // fill. Before the two-pass repaint, only left/up spill survived; right/down was lost.
1630        let mut renderer = SoftwareBackendBuilder::new()
1631            .font(retroglyph_window::font::unscii16::FONT)
1632            .grid_size(2, 1)
1633            .scale(1)
1634            .build()
1635            .unwrap()
1636            .into_renderer()
1637            .unwrap();
1638
1639        // Full block (all 8x16 pixels set), green, shifted right by 4px (half a cell): its left
1640        // half stays in cell 0, its right half spills into cell 1.
1641        let block = Tile::new(
1642            '\u{2588}',
1643            Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }),
1644        )
1645        .with_offset(4, 0);
1646        // Neighbor cell (1, 0): blank with an opaque blue background, the fill that used to erase
1647        // the spill.
1648        let neighbor = Tile::new(' ', Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 }));
1649
1650        renderer.draw_layers(
1651            [
1652                DrawCell::on_layer(0, Pos::new(0, 0), &block),
1653                DrawCell::on_layer(0, Pos::new(1, 0), &neighbor),
1654            ]
1655            .into_iter(),
1656        );
1657
1658        // Buffer is 16x16 (2 cols * 8px, 1 row * 16px), so `i % 16` is the pixel's x coordinate.
1659        let buf = renderer.pixels();
1660        let green = 0x0000_FF00_u32;
1661        let has_green_at_x = |x: usize| {
1662            buf.iter()
1663                .enumerate()
1664                .any(|(i, &p)| i % 16 == x && p == green)
1665        };
1666
1667        // Cell 1 (x >= 8): left half is the spilled block, right half is blue background.
1668        assert!(
1669            has_green_at_x(8),
1670            "block must spill into the neighbor cell at x=8"
1671        );
1672        assert!(has_green_at_x(11), "spill must reach x=11");
1673        assert!(
1674            !has_green_at_x(12),
1675            "spill stops at half the neighbor cell (x=12 is bg)"
1676        );
1677        // Cell 0 (x < 8): left half is now empty (block shifted right), right half is green.
1678        assert!(
1679            !has_green_at_x(0),
1680            "block shifted right, so x=0 is background"
1681        );
1682        assert!(
1683            has_green_at_x(4),
1684            "block still occupies x=4 in its own cell"
1685        );
1686    }
1687
1688    #[test]
1689    fn removing_an_offset_leaves_stale_spill_in_the_neighbor_cell() {
1690        // retroglyph#717: `full_repaint` used to OR in only the *current* frame's offsets, so the
1691        // frame that removes the last offset took the incremental repaint path. That path only
1692        // repaints cells whose own tile changed, and the neighbor cell's tile here is untouched
1693        // (still blue background, byte-identical to the previous frame), so it was never
1694        // revisited to clear the spill the previous frame's offset glyph had painted into it.
1695        let mut renderer = SoftwareBackendBuilder::new()
1696            .font(retroglyph_window::font::unscii16::FONT)
1697            .grid_size(2, 1)
1698            .scale(1)
1699            .build()
1700            .unwrap()
1701            .into_renderer()
1702            .unwrap();
1703
1704        let green = 0x0000_FF00_u32;
1705        let blue = 0x0000_00FF_u32;
1706        let block_at = |dx: i16| {
1707            Tile::new(
1708                '\u{2588}',
1709                Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }),
1710            )
1711            .with_offset(dx, 0)
1712        };
1713        let neighbor = Tile::new(' ', Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 }));
1714
1715        // Frame 1: block offset right by half a cell, spills green into cell 1's left half.
1716        renderer.draw_layers(
1717            [
1718                DrawCell::on_layer(0, Pos::new(0, 0), &block_at(4)),
1719                DrawCell::on_layer(0, Pos::new(1, 0), &neighbor),
1720            ]
1721            .into_iter(),
1722        );
1723        let buf = renderer.pixels();
1724        assert_eq!(buf[8], green, "spill into cell 1 should be confirmed first");
1725
1726        // Frame 2: offset removed entirely; cell 1's tile is byte-identical to frame 1.
1727        renderer.draw_layers(
1728            [
1729                DrawCell::on_layer(0, Pos::new(0, 0), &block_at(0)),
1730                DrawCell::on_layer(0, Pos::new(1, 0), &neighbor),
1731            ]
1732            .into_iter(),
1733        );
1734        let buf = renderer.pixels();
1735        assert!(
1736            buf[8..16].iter().all(|&p| p == blue),
1737            "cell 1 must be fully blue again once the offset spilling into it is gone, but got: \
1738             {:?}",
1739            &buf[8..16]
1740        );
1741    }
1742
1743    #[test]
1744    fn pixel_snapshot_render_scene() {
1745        // Render a small multi-layer scene and snapshot the pixel output.
1746        let opts = SoftwareBackendBuilder::new()
1747            .grid_size(2, 2)
1748            .scale(1)
1749            .build()
1750            .unwrap();
1751        let mut renderer = opts.into_renderer().unwrap();
1752
1753        // Layer 0: dark background, ':' at (0,0) in dim blue, '.' at (1,0) in dim gray.
1754        let bg = Tile::new(
1755            ':',
1756            Style::new()
1757                .fg(Color::Rgb {
1758                    r: 60,
1759                    g: 60,
1760                    b: 80,
1761                })
1762                .bg(Color::Rgb {
1763                    r: 20,
1764                    g: 20,
1765                    b: 30,
1766                }),
1767        );
1768        let dot = Tile::new(
1769            '.',
1770            Style::new()
1771                .fg(Color::Rgb {
1772                    r: 40,
1773                    g: 40,
1774                    b: 50,
1775                })
1776                .bg(Color::Rgb {
1777                    r: 20,
1778                    g: 20,
1779                    b: 30,
1780                }),
1781        );
1782        let entity = Tile::new(
1783            '@',
1784            Style::new()
1785                .fg(Color::Rgb { r: 0, g: 255, b: 0 })
1786                .bg(Color::Rgb {
1787                    r: 10,
1788                    g: 10,
1789                    b: 10,
1790                }),
1791        )
1792        .with_offset(1, 0);
1793        // Single draw_layers call (clears buffer first).
1794        renderer.draw_layers(
1795            [
1796                DrawCell::on_layer(0, Pos::new(0, 0), &bg),
1797                DrawCell::on_layer(0, Pos::new(1, 0), &dot),
1798                DrawCell::on_layer(1, Pos::new(0, 0), &entity),
1799            ]
1800            .into_iter(),
1801        );
1802
1803        // Snapshot the pixel buffer.
1804        // The buffer is 2 cells wide (16px) x 2 cells tall (32px) = 512 u32s.
1805        let pixels = renderer.pixels();
1806        assert_eq!(pixels.len(), 2 * 8 * 2 * 16); // cols * glyph_w * rows * glyph_h
1807
1808        // Snapshot a debug representation: groups of 16 pixels per row (one pixel row across 2 cells).
1809        let row_strs: Vec<String> = pixels
1810            .chunks(16)
1811            .take(32)
1812            .map(|row| {
1813                row.iter()
1814                    .map(|p| format!("{p:08x}"))
1815                    .collect::<Vec<_>>()
1816                    .join(" ")
1817            })
1818            .collect();
1819        let snapshot = row_strs.join("\n");
1820
1821        insta::assert_snapshot!("pixel_snapshot_render_scene", snapshot);
1822    }
1823
1824    #[test]
1825    fn higher_layer_opaque_background_paints_but_empty_cell_does_not() {
1826        // 2x1 grid. Layer 0 is a plain dark background across both cells.
1827        // Layer 1 puts a colored background only at cell (0, 0); cell (1, 0)
1828        // on layer 1 is left empty and must not disturb layer 0.
1829        let opts = SoftwareBackendBuilder::new()
1830            .grid_size(2, 1)
1831            .scale(1)
1832            .build()
1833            .unwrap();
1834        let mut renderer = opts.into_renderer().unwrap();
1835
1836        let base = Tile::new(
1837            ' ',
1838            Style::new().bg(Color::Rgb {
1839                r: 20,
1840                g: 20,
1841                b: 20,
1842            }),
1843        );
1844        // Layer 1 overlay: an opaque space (non-empty) with a red background.
1845        let overlay = Tile::new(' ', Style::new().bg(Color::Rgb { r: 200, g: 0, b: 0 }));
1846        // Layer 1 empty cell (default tile) must be skipped.
1847        let empty = Tile::default();
1848
1849        renderer.draw_layers(
1850            [
1851                DrawCell::on_layer(0, Pos::new(0, 0), &base),
1852                DrawCell::on_layer(0, Pos::new(1, 0), &base),
1853                DrawCell::on_layer(1, Pos::new(0, 0), &overlay),
1854                DrawCell::on_layer(1, Pos::new(1, 0), &empty),
1855            ]
1856            .into_iter(),
1857        );
1858
1859        let pixels = renderer.pixels();
1860        let cell_w = 8usize; // glyph width at scale 1
1861        // Top-left pixel of cell (0, 0): the layer-1 red overlay wins.
1862        assert_eq!(pixels[0] & 0x00ff_ffff, 0x00c8_0000);
1863        // Top-left pixel of cell (1, 0): layer-1 cell was empty, so layer 0 shows.
1864        assert_eq!(pixels[cell_w] & 0x00ff_ffff, 0x0014_1414);
1865    }
1866
1867    // ── Damage tracking (the row band fed to present_with_damage) ─────────
1868    //
1869    // The windowed present() upload can't run in a headless test (no surface),
1870    // but the damage computation runs in draw_layers regardless, so the band
1871    // is exactly what these assert. At scale 1 with the unscii16 font each cell
1872    // is 8x16 px, so the pixel buffer is (cols*8) x (rows*16) and cell-row r
1873    // occupies pixel rows [r*16, r*16+16). Damage is reported in pixel rows.
1874
1875    /// Cell height in pixels at scale 1 (unscii16).
1876    const CELL_H_PX: u32 = 16;
1877
1878    fn damage_renderer(cols: u16, rows: u16) -> SoftwareRenderer {
1879        SoftwareBackendBuilder::new()
1880            .grid_size(cols, rows)
1881            .scale(1)
1882            .build()
1883            .unwrap()
1884            .into_renderer()
1885            .unwrap()
1886    }
1887
1888    /// Fill every layer-0 cell with `tile`, overriding cell `(ox, oy)` with
1889    /// `over` when given, then run `draw_layers` (which computes damage).
1890    fn draw_fill(
1891        r: &mut SoftwareRenderer,
1892        cols: u16,
1893        rows: u16,
1894        tile: &Tile,
1895        over: Option<(u16, u16, &Tile)>,
1896    ) {
1897        let mut items: Vec<DrawCell<'_>> = Vec::new();
1898        for y in 0..rows {
1899            for x in 0..cols {
1900                let t = match over {
1901                    Some((ox, oy, ot)) if ox == x && oy == y => ot,
1902                    _ => tile,
1903                };
1904                items.push(DrawCell::on_layer(0, Pos::new(x, y), t));
1905            }
1906        }
1907        r.draw_layers(items.into_iter()).unwrap();
1908    }
1909
1910    fn bg_tile(r: u8, g: u8, b: u8) -> Tile {
1911        Tile::new(' ', Style::new().bg(Color::Rgb { r, g, b }))
1912    }
1913
1914    #[test]
1915    fn damage_first_frame_covers_whole_buffer() {
1916        // prev_pixels starts zeroed, so a non-black first frame differs on
1917        // every row: the whole buffer is damaged.
1918        let mut r = damage_renderer(2, 3);
1919        draw_fill(&mut r, 2, 3, &bg_tile(200, 0, 0), None);
1920        assert_eq!(r.ctx.damage_rows, Some((0, 3 * CELL_H_PX)));
1921    }
1922
1923    #[test]
1924    fn damage_is_none_when_a_frame_is_unchanged() {
1925        let mut r = damage_renderer(2, 3);
1926        let red = bg_tile(200, 0, 0);
1927        draw_fill(&mut r, 2, 3, &red, None); // first frame: full damage
1928        // Headless present() is a no-op (nothing to upload without a window surface), so it
1929        // never touches damage_rows; a real present() is what would clear the pending band
1930        // here (retroglyph#724), so clear it directly to simulate that for this test.
1931        assert!(r.present().is_ok());
1932        r.ctx.damage_rows = None;
1933        draw_fill(&mut r, 2, 3, &red, None); // identical redraw: nothing changed
1934        assert_eq!(r.ctx.damage_rows, None);
1935    }
1936
1937    #[test]
1938    fn damage_band_is_tight_for_a_localized_change() {
1939        let mut r = damage_renderer(2, 3);
1940        let red = bg_tile(200, 0, 0);
1941        draw_fill(&mut r, 2, 3, &red, None); // baseline
1942        // Simulate the present() that would normally clear the baseline's damage before the
1943        // next frame (headless present() is a no-op with no surface to clear it through).
1944        r.ctx.damage_rows = None;
1945        // Change only cell (0, 1); its pixels live in rows [16, 32).
1946        draw_fill(&mut r, 2, 3, &red, Some((0, 1, &bg_tile(0, 0, 200))));
1947        assert_eq!(r.ctx.damage_rows, Some((CELL_H_PX, 2 * CELL_H_PX)));
1948    }
1949
1950    #[test]
1951    fn damage_band_spans_from_first_to_last_changed_row() {
1952        // Changes in cell-row 0 and cell-row 2 inflate the band to cover the
1953        // clean row 1 between them (single row band, documented limitation).
1954        let mut r = damage_renderer(2, 3);
1955        let red = bg_tile(200, 0, 0);
1956        draw_fill(&mut r, 2, 3, &red, None);
1957        let blue = bg_tile(0, 0, 200);
1958        // Two separate frames would each report one band; do it in one frame
1959        // by changing both corners relative to the baseline.
1960        let mut items: Vec<DrawCell<'_>> = Vec::new();
1961        for y in 0..3u16 {
1962            for x in 0..2u16 {
1963                let t = if (x, y) == (0, 0) || (x, y) == (1, 2) {
1964                    &blue
1965                } else {
1966                    &red
1967                };
1968                items.push(DrawCell::on_layer(0, Pos::new(x, y), t));
1969            }
1970        }
1971        r.draw_layers(items.into_iter()).unwrap();
1972        assert_eq!(r.ctx.damage_rows, Some((0, 3 * CELL_H_PX)));
1973    }
1974
1975    #[test]
1976    fn damage_bands_union_across_draw_layers_calls_before_present() {
1977        // Regression test for retroglyph#724: `present()` is the only thing that clears
1978        // `damage_rows`, so two `draw_layers` calls touching disjoint rows before a single
1979        // `present()` must union their bands rather than the second overwriting the first.
1980        let mut r = damage_renderer(2, 3);
1981        let red = bg_tile(200, 0, 0);
1982        draw_fill(&mut r, 2, 3, &red, None); // baseline frame
1983        // Simulate the present() that would normally clear this baseline's damage (headless
1984        // present() is a no-op with no surface to clear it through).
1985        assert!(r.present().is_ok());
1986        r.ctx.damage_rows = None;
1987
1988        let blue = bg_tile(0, 0, 200);
1989        // First draw_layers call: change only cell-row 0, pixel rows [0, 16).
1990        draw_fill(&mut r, 2, 3, &red, Some((0, 0, &blue)));
1991        assert_eq!(r.ctx.damage_rows, Some((0, CELL_H_PX)));
1992
1993        // Second draw_layers call, with no present() in between: keep (0, 0) as it already
1994        // is (still blue, so it does not itself register as changed) and change only cell
1995        // (1, 2), pixel rows [32, 48), disjoint from the first change.
1996        let mut items: Vec<DrawCell<'_>> = Vec::new();
1997        for y in 0..3u16 {
1998            for x in 0..2u16 {
1999                let t = match (x, y) {
2000                    (0, 0) | (1, 2) => &blue,
2001                    _ => &red,
2002                };
2003                items.push(DrawCell::on_layer(0, Pos::new(x, y), t));
2004            }
2005        }
2006        r.draw_layers(items.into_iter()).unwrap();
2007
2008        // Both bands must still be reflected: the first call's rows [0, 16) must not have
2009        // been dropped in favor of the second call's [32, 48).
2010        assert_eq!(r.ctx.damage_rows, Some((0, 3 * CELL_H_PX)));
2011    }
2012
2013    #[test]
2014    fn resize_marks_full_frame_damage() {
2015        let mut r = damage_renderer(2, 3);
2016        let red = bg_tile(200, 0, 0);
2017        draw_fill(&mut r, 2, 3, &red, None);
2018        // Simulate the present() that would normally clear this baseline's damage (headless
2019        // present() is a no-op with no surface to clear it through).
2020        r.ctx.damage_rows = None;
2021        draw_fill(&mut r, 2, 3, &red, None);
2022        assert_eq!(r.ctx.damage_rows, None);
2023        // A resize invalidates the shadow buffer and forces a full repaint so
2024        // no stale pixels survive at the new size.
2025        r.resize(Size::new(4, 5));
2026        assert_eq!(r.ctx.damage_rows, Some((0, 5 * CELL_H_PX)));
2027    }
2028
2029    #[test]
2030    fn resize_to_a_larger_grid_does_not_panic_on_the_next_draw() {
2031        // retroglyph#567: `resize` cleared `prev_tiles` but left the parallel `prev_tints`
2032        // shadow at its old (smaller) length, so the next `draw_layers` indexed it out of
2033        // bounds. Draw at the original size, resize larger, and draw again; this must not panic.
2034        let mut r = damage_renderer(2, 3);
2035        let red = bg_tile(200, 0, 0);
2036        draw_fill(&mut r, 2, 3, &red, None);
2037        r.resize(Size::new(4, 5));
2038        draw_fill(&mut r, 4, 5, &red, None);
2039        assert_eq!(r.ctx.damage_rows, Some((0, 5 * CELL_H_PX)));
2040    }
2041
2042    #[test]
2043    fn clear_then_redrawing_the_same_frame_leaves_the_buffer_blank() {
2044        // retroglyph#694: `clear` zeroed `pixel_buf` but left the per-cell shadow (`prev_tiles`,
2045        // `prev_tints`, `prev_layer_count`) untouched, so redrawing the exact same frame after a
2046        // `clear` diffed against stale-but-identical shadow state, found nothing changed, and
2047        // painted nothing: the buffer stayed all zero instead of showing the redrawn content.
2048        let mut r = damage_renderer(2, 1);
2049        let red = bg_tile(200, 0, 0);
2050        draw_fill(&mut r, 2, 1, &red, None);
2051        assert!(r.pixels().iter().all(|&p| p == 0x00C8_0000));
2052
2053        r.clear().unwrap();
2054        assert!(r.pixels().iter().all(|&p| p == 0));
2055
2056        draw_fill(&mut r, 2, 1, &red, None);
2057        assert!(
2058            r.pixels().iter().all(|&p| p == 0x00C8_0000),
2059            "redrawing the same frame after clear() must repaint, not stay blank"
2060        );
2061    }
2062
2063    // ── Dirty-cell repaint (retroglyph#302) ──────────────────────────────
2064    //
2065    // `draw_layers` always receives every cell (see `Output::needs_full_frame`), but internally
2066    // it should only actually repaint pixels for cells that changed since the last call, falling
2067    // back to a full clear + repaint when a sub-cell offset or a layer-count change is in play.
2068    // These assert on the rendered pixels (not on any private dirty-tracking state), so they
2069    // hold regardless of how the internal shadow copy is implemented.
2070
2071    /// Fills every layer-0 cell with `bg`, sets one cell's foreground glyph, and returns the tile
2072    /// list (index-stable across calls so a second call can flip one cell without rebuilding the
2073    /// rest).
2074    fn glyph_scene(
2075        cols: u16,
2076        rows: u16,
2077        bg: Color,
2078        glyph_pos: (u16, u16),
2079        glyph: char,
2080    ) -> Vec<Tile> {
2081        let mut out = Vec::with_capacity(usize::from(cols) * usize::from(rows));
2082        for y in 0..rows {
2083            for x in 0..cols {
2084                let style = if (x, y) == glyph_pos {
2085                    Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }).bg(bg)
2086                } else {
2087                    Style::new().bg(bg)
2088                };
2089                out.push(Tile::new(
2090                    if (x, y) == glyph_pos { glyph } else { ' ' },
2091                    style,
2092                ));
2093            }
2094        }
2095        out
2096    }
2097
2098    fn draw_scene(r: &mut SoftwareRenderer, cols: u16, tiles: &[Tile]) {
2099        let content = tiles.iter().enumerate().map(|(i, t)| {
2100            #[allow(clippy::cast_possible_truncation)]
2101            let pos = Pos::new(
2102                (i % usize::from(cols)) as u16,
2103                (i / usize::from(cols)) as u16,
2104            );
2105            DrawCell::on_layer(0, pos, t)
2106        });
2107        r.draw_layers(content).unwrap();
2108    }
2109
2110    #[test]
2111    fn dirty_cell_repaint_leaves_unchanged_cell_pixels_untouched() {
2112        // 3x1 grid, all cells the same dark background, distinct glyphs so a stray repaint of an
2113        // untouched cell would be visible. Change only the middle cell's glyph and re-draw; the
2114        // other two cells' pixels must be byte-for-byte identical to the first frame.
2115        let mut r = damage_renderer(3, 1);
2116        let base = glyph_scene(
2117            3,
2118            1,
2119            Color::Rgb {
2120                r: 10,
2121                g: 10,
2122                b: 10,
2123            },
2124            (1, 0),
2125            '@',
2126        );
2127        draw_scene(&mut r, 3, &base);
2128        let before = r.pixels().to_vec();
2129
2130        let mut changed = base.clone();
2131        changed[1] = Tile::new(
2132            '#',
2133            Style::new()
2134                .fg(Color::Rgb { r: 0, g: 0, b: 255 })
2135                .bg(Color::Rgb {
2136                    r: 10,
2137                    g: 10,
2138                    b: 10,
2139                }),
2140        );
2141        draw_scene(&mut r, 3, &changed);
2142        let after = r.pixels().to_vec();
2143
2144        let cell_w = 8usize; // unscii16 glyph width at scale 1.
2145        let cell_h = 16usize;
2146        let buf_w = 3 * cell_w;
2147        // Extracts cell `col`'s full pixel rect (all `cell_h` rows) out of a `buf_w`-wide buffer.
2148        let cell_pixels = |buf: &[u32], col: usize| -> Vec<u32> {
2149            (0..cell_h)
2150                .flat_map(|row| {
2151                    let start = row * buf_w + col * cell_w;
2152                    buf[start..start + cell_w].to_vec()
2153                })
2154                .collect()
2155        };
2156
2157        // Cell 0 and cell 2 (unchanged) must be pixel-identical across the two frames.
2158        assert_eq!(
2159            cell_pixels(&before, 0),
2160            cell_pixels(&after, 0),
2161            "cell 0 pixels changed"
2162        );
2163        assert_eq!(
2164            cell_pixels(&before, 2),
2165            cell_pixels(&after, 2),
2166            "cell 2 pixels changed"
2167        );
2168        // Cell 1 (changed) must actually differ: '@' vs '#' in different colors.
2169        assert_ne!(
2170            cell_pixels(&before, 1),
2171            cell_pixels(&after, 1),
2172            "cell 1 pixels should have changed"
2173        );
2174    }
2175
2176    #[test]
2177    fn dirty_cell_repaint_updates_changed_cell() {
2178        let mut r = damage_renderer(2, 1);
2179        let base = glyph_scene(2, 1, Color::Rgb { r: 0, g: 0, b: 0 }, (0, 0), ' ');
2180        draw_scene(&mut r, 2, &base);
2181
2182        let mut changed = base;
2183        changed[0] = Tile::new(' ', Style::new().bg(Color::Rgb { r: 200, g: 0, b: 0 }));
2184        draw_scene(&mut r, 2, &changed);
2185
2186        let cell_w = 8usize;
2187        assert!(
2188            r.pixels()[0..cell_w].iter().all(|&p| p == 0x00C8_0000),
2189            "changed cell should show its new red background"
2190        );
2191    }
2192
2193    #[test]
2194    fn sub_cell_offset_forces_full_frame_fallback_even_for_unchanged_cells() {
2195        // 2x1 grid. Frame 1: cell 0 has an offset glyph, cell 1 is plain. Frame 2: identical
2196        // content (nothing actually changed), but since a sub-cell offset is in play this frame,
2197        // the fallback repaint path runs regardless of the dirty set; assert the buffer is
2198        // still correct (not that any particular code path ran).
2199        let mut r = damage_renderer(2, 1);
2200        let bg = Tile::new(' ', Style::new().bg(Color::Rgb { r: 5, g: 5, b: 5 }));
2201        let offset_fg =
2202            Tile::new('@', Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 })).with_offset(1, 0);
2203
2204        let draw = |r: &mut SoftwareRenderer| {
2205            r.draw_layers(
2206                [
2207                    DrawCell::on_layer(0, Pos::new(0, 0), &bg),
2208                    DrawCell::on_layer(1, Pos::new(0, 0), &offset_fg),
2209                    DrawCell::on_layer(0, Pos::new(1, 0), &bg),
2210                ]
2211                .into_iter(),
2212            )
2213            .unwrap();
2214        };
2215
2216        draw(&mut r);
2217        let before = r.pixels().to_vec();
2218        draw(&mut r); // identical content; offsets are in play, so this takes the fallback path.
2219        let after = r.pixels().to_vec();
2220
2221        assert_eq!(
2222            before, after,
2223            "identical frames with an active offset must render identically"
2224        );
2225        let has_green = |col: usize| {
2226            after
2227                .iter()
2228                .enumerate()
2229                .any(|(i, &p)| i % 16 == col && p == 0x0000_FF00)
2230        };
2231        assert!(!has_green(0), "x=0 should have no green pixels with dx=1");
2232        assert!(has_green(1), "x=1 should have green pixels with dx=1");
2233    }
2234
2235    #[test]
2236    fn layer_count_change_forces_full_frame_repaint() {
2237        // 1x1 grid. Frame 1: only layer 0. Frame 2: layer 0 unchanged, but layer 1 newly
2238        // allocated with an opaque background: the layer-set change must not be missed by the
2239        // dirty-cell path (layer 0's own cell never changed, so a naive per-cell diff limited to
2240        // previously-seen layers would skip it).
2241        let mut r = damage_renderer(1, 1);
2242        let base = Tile::new(
2243            ' ',
2244            Style::new().bg(Color::Rgb {
2245                r: 10,
2246                g: 10,
2247                b: 10,
2248            }),
2249        );
2250        r.draw_layers(core::iter::once(DrawCell::on_layer(
2251            0,
2252            Pos::new(0, 0),
2253            &base,
2254        )))
2255        .unwrap();
2256
2257        let overlay = Tile::new(' ', Style::new().bg(Color::Rgb { r: 200, g: 0, b: 0 }));
2258        r.draw_layers(
2259            [
2260                DrawCell::on_layer(0, Pos::new(0, 0), &base),
2261                DrawCell::on_layer(1, Pos::new(0, 0), &overlay),
2262            ]
2263            .into_iter(),
2264        )
2265        .unwrap();
2266
2267        assert!(
2268            r.pixels().iter().all(|&p| p & 0x00ff_ffff == 0x00c8_0000),
2269            "newly-allocated layer 1's opaque background must be visible everywhere"
2270        );
2271    }
2272    //
2273    // `resolve_color` now delegates entirely to core's `Color::resolve_rgb`; this asserts the
2274    // packing is correct and that ANSI resolution agrees with core across the full 16-color
2275    // palette, so a regression in either the delegation or the packing is caught.
2276
2277    #[test]
2278    fn resolve_color_matches_core_for_all_ansi_variants() {
2279        use retroglyph_core::color::AnsiColor;
2280        for index in 0..16u8 {
2281            let ansi = AnsiColor::try_from(index).expect("0..16 are valid ANSI indices");
2282            let (r, g, b) = ansi.to_rgb();
2283            let core_rgb = (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b);
2284            assert_eq!(
2285                resolve_color(Color::Ansi(ansi), DEFAULT_BG),
2286                core_rgb,
2287                "{ansi:?}: resolve_color no longer matches retroglyph-core's Color::resolve_rgb"
2288            );
2289        }
2290    }
2291
2292    // ── Output/Cursor conformance (retroglyph#763) ──────────────────────────────────────────
2293
2294    fn conformance_renderer(size: Size) -> SoftwareRenderer {
2295        SoftwareBackendBuilder::new()
2296            .font(retroglyph_window::font::unscii16::FONT)
2297            .grid_size(size.width(), size.height())
2298            .scale(1)
2299            .build()
2300            .unwrap()
2301            .into_renderer()
2302            .unwrap()
2303    }
2304
2305    /// Wraps [`SoftwareRenderer`] so [`Observable::snapshot`] hashes only the pixels that changed
2306    /// since the previous call, per that trait's docs: this backend's observable state is its
2307    /// pixel buffer (framebuffer-shaped, not a log), so this remembers the previous call's pixels
2308    /// and hashes only the `(index, pixel)` pairs that differ from it.
2309    struct SoftwareObserver {
2310        renderer: SoftwareRenderer,
2311        previous: Vec<u32>,
2312    }
2313
2314    impl SoftwareObserver {
2315        fn new(size: Size) -> Self {
2316            let renderer = conformance_renderer(size);
2317            let previous = renderer.pixels().to_vec();
2318            Self { renderer, previous }
2319        }
2320    }
2321
2322    impl Output for SoftwareObserver {
2323        type Error = core::convert::Infallible;
2324
2325        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
2326        where
2327            I: Iterator<Item = DrawCell<'a>>,
2328        {
2329            self.renderer.draw_layers(content)
2330        }
2331
2332        fn flush(&mut self) -> Result<(), Self::Error> {
2333            self.renderer.flush()
2334        }
2335
2336        fn size(&self) -> Size {
2337            Output::size(&self.renderer)
2338        }
2339
2340        fn clear(&mut self) -> Result<(), Self::Error> {
2341            Output::clear(&mut self.renderer)?;
2342            self.settle()
2343        }
2344
2345        fn resize(&mut self, size: Size) {
2346            Output::resize(&mut self.renderer, size);
2347            // This backend always reports `needs_full_frame() == true` (see its `Output` impl):
2348            // its pixel buffer is only actually repainted (and so only meaningfully
2349            // observable) on the next `draw_layers` call, which `Terminal::present` always
2350            // supplies in real use. `snapshot()` reads pixels directly instead, so settle it
2351            // here with an empty full-repaint pass rather than let a caller observe whatever
2352            // `resize`'s own buffer growth left behind.
2353            let _ = self.settle();
2354        }
2355    }
2356
2357    impl SoftwareObserver {
2358        fn settle(&mut self) -> Result<(), core::convert::Infallible> {
2359            self.renderer.draw_layers(core::iter::empty())?;
2360            self.renderer.flush()
2361        }
2362    }
2363
2364    impl Cursor for SoftwareObserver {}
2365
2366    impl retroglyph_core::testing::conformance::Observable for SoftwareObserver {
2367        fn snapshot(&mut self) -> u64 {
2368            let current = self.renderer.pixels();
2369            let mut hash = retroglyph_core::testing::conformance::fnv1a(b"software-diff");
2370            for (index, (&was, &now)) in self.previous.iter().zip(current.iter()).enumerate() {
2371                if was != now {
2372                    hash ^=
2373                        retroglyph_core::testing::conformance::fnv1a(&(index as u64).to_ne_bytes());
2374                    hash ^= retroglyph_core::testing::conformance::fnv1a(&now.to_ne_bytes());
2375                }
2376            }
2377            self.previous = current.to_vec();
2378            hash
2379        }
2380    }
2381
2382    #[test]
2383    fn satisfies_the_output_contract() {
2384        retroglyph_core::testing::conformance::assert_output_contract(SoftwareObserver::new);
2385    }
2386
2387    #[test]
2388    fn satisfies_the_cursor_contract() {
2389        // `SoftwareRenderer`'s `Cursor` impl is a no-op (no hardware cursor in software mode),
2390        // so this is expected to pass trivially: included anyway so a future change that gives
2391        // this backend real cursor tracking is checked against the same contract as the terminal
2392        // backends are.
2393        retroglyph_core::testing::conformance::assert_cursor_contract(SoftwareObserver::new);
2394    }
2395
2396    /// `expand_dirty_spans` reads a shadow buffer that can lag a resize by a frame (see its doc
2397    /// comment), so a covered cell's stored `(dx, dy)` offset can point before the start of the
2398    /// buffer once reinterpreted against the new `cols` stride. This must be a no-op for that
2399    /// cell rather than panic on the underflowing subtraction.
2400    #[test]
2401    fn expand_dirty_spans_skips_a_covered_cell_whose_anchor_offset_underflows() {
2402        use retroglyph_core::grid::Grid;
2403        use retroglyph_core::grid::Pos;
2404
2405        let cols = 2;
2406        let rows = 3;
2407        let mut grid = Grid::new(cols, rows);
2408        grid.write_span_uniform(0, (0, 0), (1, 2), 'A', ' ', Style::default());
2409        let layer: Vec<Tile> = (0..rows)
2410            .flat_map(|y| (0..cols).map(move |x| (x, y)))
2411            .map(|(x, y)| grid.tile(0, Pos::new(x, y)).copied().unwrap_or_default())
2412            .collect();
2413
2414        // idx 2 is (0, 1), the covered cell with offset (dx, dy) = (0, 1). Calling with a much
2415        // wider `cols` than the layer was built with reproduces the stale-buffer case: dy * cols
2416        // now exceeds idx.
2417        let mut dirty = vec![false; layer.len()];
2418        dirty[2] = true;
2419        expand_dirty_spans(&mut dirty, &layer, 10, rows as usize);
2420
2421        assert_eq!(dirty, vec![false, false, true, false, false, false]);
2422    }
2423}
2424
2425// ── Multi-cell sprite tests (retroglyph#412) ────────────────────────────────
2426
2427#[cfg(all(test, feature = "tilesets"))]
2428mod span_tests {
2429    use super::*;
2430    use retroglyph_core::color::Color;
2431    use retroglyph_core::color::Style;
2432    use retroglyph_core::grid::Grid;
2433    use retroglyph_core::grid::Pos;
2434    use retroglyph_window::tileset::{Codepage, SheetColor, SpriteAlign, TilesetOptions};
2435
2436    const RED: u32 = 0x00FF_0000;
2437    const BLUE: u32 = 0x0000_00FF;
2438    const GREEN: u32 = 0x0000_FF00;
2439
2440    /// A one-tile PNG of `w` x `h` pixels, solid opaque red except for a fully transparent
2441    /// right-hand column band `transparent_from` pixels in, so a test can see the background
2442    /// through part of the sprite.
2443    fn sprite_png(w: u32, h: u32, transparent_from: u32) -> Vec<u8> {
2444        use image::ImageEncoder as _;
2445        let mut img = image::RgbaImage::new(w, h);
2446        for y in 0..h {
2447            for x in 0..w {
2448                let px = if x >= transparent_from {
2449                    [0, 0, 0, 0]
2450                } else {
2451                    [0xFF, 0, 0, 0xFF]
2452                };
2453                img.put_pixel(x, y, image::Rgba(px));
2454            }
2455        }
2456        let mut png = Vec::new();
2457        image::codecs::png::PngEncoder::new(&mut png)
2458            .write_image(img.as_raw(), w, h, image::ExtendedColorType::Rgba8)
2459            .expect("encode test tileset PNG");
2460        png
2461    }
2462
2463    /// A `cols` x `rows` renderer at scale 1 with `'S'` bound to a `w` x `h` sprite.
2464    fn renderer_with_sprite(
2465        cols: u16,
2466        rows: u16,
2467        w: u32,
2468        h: u32,
2469        transparent_from: u32,
2470        align: SpriteAlign,
2471    ) -> SoftwareRenderer {
2472        #[allow(clippy::cast_possible_truncation)]
2473        let opts = TilesetOptions::builder(sprite_png(w, h, transparent_from))
2474            .tile_size(w as u16, h as u16)
2475            .columns(1)
2476            .codepage(Codepage::Custom(vec!['S']))
2477            .align(align)
2478            .build()
2479            .expect("valid single-tile tileset");
2480        SoftwareBackendBuilder::new()
2481            .font(retroglyph_window::font::unscii16::FONT)
2482            .grid_size(cols, rows)
2483            .scale(1)
2484            .tileset(opts)
2485            .build()
2486            .unwrap()
2487            .into_renderer()
2488            .unwrap()
2489    }
2490
2491    /// A 2-tile 16x16 PNG: tile 0 (`'S'`) fully opaque red, tile 1 (`'T'`) red only in its left
2492    /// half and transparent in its right. Swapping between the two changes what the sprite paints
2493    /// in the *second* cell of a 2x1 span without changing that cell's tile at all.
2494    fn wide_and_narrow_png() -> Vec<u8> {
2495        use image::ImageEncoder as _;
2496        let (w, h) = (16u32, 16u32);
2497        let mut img = image::RgbaImage::new(w * 2, h);
2498        for y in 0..h {
2499            for x in 0..w * 2 {
2500                let opaque = x < w || x - w < 8;
2501                let px = if opaque {
2502                    [0xFF, 0, 0, 0xFF]
2503                } else {
2504                    [0, 0, 0, 0]
2505                };
2506                img.put_pixel(x, y, image::Rgba(px));
2507            }
2508        }
2509        let mut png = Vec::new();
2510        image::codecs::png::PngEncoder::new(&mut png)
2511            .write_image(img.as_raw(), w * 2, h, image::ExtendedColorType::Rgba8)
2512            .expect("encode test tileset PNG");
2513        png
2514    }
2515
2516    /// Streams every cell of `grid`'s layer 0 through `draw_layers`, the path the compositing
2517    /// backend actually uses.
2518    fn paint(renderer: &mut SoftwareRenderer, grid: &Grid) {
2519        let tiles: Vec<(u8, Pos, Tile)> = (0..grid.height())
2520            .flat_map(|y| (0..grid.width()).map(move |x| (x, y)))
2521            .map(|(x, y)| (0u8, Pos::new(x, y), *grid.tile(0, (x, y)).unwrap()))
2522            .collect();
2523        renderer
2524            .draw_layers(
2525                tiles
2526                    .iter()
2527                    .map(|(l, pos, tile)| DrawCell::on_layer(*l, *pos, tile)),
2528            )
2529            .unwrap();
2530    }
2531
2532    /// The buffer's pixel at `(x, y)`, given a `cols`-wide grid of 8x16 cells at scale 1.
2533    fn px(renderer: &SoftwareRenderer, cols: usize, x: usize, y: usize) -> u32 {
2534        renderer.pixels()[y * cols * 8 + x]
2535    }
2536
2537    /// The literal subject of retroglyph#412: a span reserves cells the artwork does not fill,
2538    /// and the reserved cells stop drawing their own glyph and take the anchor's background.
2539    #[test]
2540    fn span_reserves_cells_beyond_the_artwork() {
2541        // An 8x16 sprite (exactly one cell) declared as a 2x1 span, so cell 1 is reserved but no
2542        // sprite pixel ever reaches it.
2543        let mut r = renderer_with_sprite(2, 1, 8, 16, 8, SpriteAlign::TopLeft);
2544        let mut grid = Grid::new(2, 1);
2545        grid.write_span(
2546            0,
2547            0,
2548            0,
2549            &["S#"],
2550            Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 }),
2551        )
2552        .unwrap();
2553        paint(&mut r, &grid);
2554
2555        // Cell 0 is the sprite.
2556        assert_eq!(px(&r, 2, 0, 0), RED);
2557        // Cell 1 is the anchor's background, with no trace of the '#' fallback glyph.
2558        for y in 0..16 {
2559            for x in 8..16 {
2560                assert_eq!(px(&r, 2, x, y), BLUE, "covered cell pixel ({x}, {y})");
2561            }
2562        }
2563    }
2564
2565    #[test]
2566    fn covered_cell_glyph_is_not_drawn() {
2567        // A fully transparent sprite: anything visible in cell 1 can only be its fallback glyph.
2568        let mut r = renderer_with_sprite(2, 1, 8, 16, 0, SpriteAlign::TopLeft);
2569        let mut grid = Grid::new(2, 1);
2570        grid.write_span(
2571            0,
2572            0,
2573            0,
2574            &["S#"],
2575            Style::new()
2576                .fg(Color::Rgb { r: 0, g: 255, b: 0 })
2577                .bg(Color::Rgb { r: 0, g: 0, b: 255 }),
2578        )
2579        .unwrap();
2580        paint(&mut r, &grid);
2581
2582        assert!(
2583            !r.pixels().contains(&GREEN),
2584            "the covered cell's fallback glyph must not be drawn on a pixel backend"
2585        );
2586    }
2587
2588    #[test]
2589    fn span_background_fills_the_whole_footprint() {
2590        // A 16x16 sprite spanning 2x1 cells, transparent past x=8: the background must show
2591        // through the transparent half, which lives in the *covered* cell.
2592        let mut r = renderer_with_sprite(2, 1, 16, 16, 8, SpriteAlign::TopLeft);
2593        let mut grid = Grid::new(2, 1);
2594        grid.write_span(
2595            0,
2596            0,
2597            0,
2598            &["S#"],
2599            Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 }),
2600        )
2601        .unwrap();
2602        paint(&mut r, &grid);
2603
2604        assert_eq!(px(&r, 2, 0, 0), RED, "sprite's opaque half");
2605        // The transparent half lands in the covered cell, so what shows through there is the
2606        // background the anchor established, not the renderer's default, and not a fill of the
2607        // covered cell's own.
2608        for y in 0..16 {
2609            assert_eq!(px(&r, 2, 8, y), BLUE, "covered cell pixel (8, {y})");
2610        }
2611    }
2612
2613    /// `paint`, with `tint` applied to every cell's anchor.
2614    fn paint_tinted(renderer: &mut SoftwareRenderer, grid: &Grid, tint: Tint) {
2615        let tiles: Vec<(u8, Pos, Tile)> = (0..grid.height())
2616            .flat_map(|y| (0..grid.width()).map(move |x| (x, y)))
2617            .map(|(x, y)| (0u8, Pos::new(x, y), *grid.tile(0, (x, y)).unwrap()))
2618            .collect();
2619        renderer
2620            .draw_layers(
2621                tiles
2622                    .iter()
2623                    .map(|(l, pos, tile)| DrawCell::on_layer(*l, *pos, tile).with_tint(tint)),
2624            )
2625            .unwrap();
2626    }
2627
2628    /// A 1x1 grid holding the fully opaque red sprite `'S'`, drawn with `fg` and `tint`.
2629    ///
2630    /// `transparent_from` is the x the sprite goes transparent at, so passing the sprite's own
2631    /// width keeps every pixel opaque.
2632    fn sprite_pixel(fg: Color, tint: Tint) -> u32 {
2633        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2634        let mut grid = Grid::new(1, 1);
2635        grid.write_span(0, 0, 0, &["S"], Style::new().fg(fg))
2636            .unwrap();
2637        paint_tinted(&mut r, &grid, tint);
2638        px(&r, 1, 0, 0)
2639    }
2640
2641    #[test]
2642    fn an_art_sheet_ignores_fg_however_it_is_set() {
2643        // The #537 regression guard: a full-color sheet renders as authored, and a caller who
2644        // sets `fg` hoping to tint it gets no silent change.
2645        for fg in [
2646            Color::Default,
2647            Color::Rgb { r: 0, g: 255, b: 0 },
2648            Color::Rgb { r: 0, g: 0, b: 255 },
2649        ] {
2650            assert_eq!(
2651                sprite_pixel(fg, Tint::None),
2652                RED,
2653                "fg {fg:?} tinted an art sprite"
2654            );
2655        }
2656    }
2657
2658    #[test]
2659    fn multiply_darkens_the_sprite() {
2660        let half = sprite_pixel(Color::Default, Tint::multiply(128, 128, 128));
2661        assert_eq!(half, 0x0080_0000, "red scaled by 128/255 with rounding");
2662    }
2663
2664    #[test]
2665    fn mix_brightens_the_sprite_which_multiply_cannot() {
2666        // Toward white: the red channel stays saturated and the other two come up off zero,
2667        // which no multiply of an opaque red pixel can produce.
2668        let flashed = sprite_pixel(Color::Default, Tint::mix(255, 255, 255, 128));
2669        assert_eq!(flashed & 0x00FF_0000, 0x00FF_0000, "red stays saturated");
2670        assert!(flashed & 0x0000_FF00 > 0, "green lifted off zero");
2671        assert!(flashed & 0x0000_00FF > 0, "blue lifted off zero");
2672    }
2673
2674    #[test]
2675    fn the_software_blit_agrees_with_sprite_tint_apply() {
2676        // The anchor for cross-backend agreement: the CPU rasteriser must produce exactly what
2677        // `SpriteTint::apply` says, because the GL fragment shader mirrors that same function.
2678        // If this drifts, the two backends have diverged (which is how #537 happened).
2679        for tint in [
2680            Tint::None,
2681            Tint::multiply(128, 64, 32),
2682            Tint::multiply(255, 255, 255),
2683            Tint::mix(0, 255, 0, 200),
2684            Tint::mix(255, 255, 255, 255),
2685        ] {
2686            let expected = SpriteTint::resolve(SheetColor::Art, Color::Default, tint, DEFAULT_FG)
2687                .apply((255, 0, 0));
2688            let want =
2689                u32::from(expected.0) << 16 | u32::from(expected.1) << 8 | u32::from(expected.2);
2690            assert_eq!(sprite_pixel(Color::Default, tint), want, "tint {tint:?}");
2691        }
2692    }
2693
2694    #[test]
2695    fn a_tint_only_change_repaints_the_cell() {
2696        // The tile is byte-identical across both frames; only the tint moves. Damage tracking
2697        // compares `Tile`s, which cannot see a tint, so this would silently not repaint without
2698        // the parallel `prev_tints` shadow copy.
2699        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2700        let mut grid = Grid::new(1, 1);
2701        grid.write_span(0, 0, 0, &["S"], Style::new()).unwrap();
2702
2703        paint_tinted(&mut r, &grid, Tint::None);
2704        assert_eq!(px(&r, 1, 0, 0), RED);
2705
2706        paint_tinted(&mut r, &grid, Tint::multiply(128, 128, 128));
2707        assert_eq!(
2708            px(&r, 1, 0, 0),
2709            0x0080_0000,
2710            "a tint-only change must mark the cell dirty"
2711        );
2712    }
2713
2714    #[test]
2715    fn sprite_align_center_shifts_the_blit_within_the_span_box() {
2716        // An 8x16 sprite centered in a 2x1 span of 8x16 cells: 8px of slack, so it starts at x=4.
2717        let mut r = renderer_with_sprite(2, 1, 8, 16, 8, SpriteAlign::Center);
2718        let mut grid = Grid::new(2, 1);
2719        grid.write_span(
2720            0,
2721            0,
2722            0,
2723            &["S#"],
2724            Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 }),
2725        )
2726        .unwrap();
2727        paint(&mut r, &grid);
2728
2729        assert_eq!(px(&r, 2, 3, 0), BLUE, "left of the centered sprite");
2730        assert_eq!(px(&r, 2, 4, 0), RED, "centered sprite starts at x=4");
2731        assert_eq!(px(&r, 2, 11, 0), RED, "centered sprite ends at x=11");
2732        assert_eq!(px(&r, 2, 12, 0), BLUE, "right of the centered sprite");
2733    }
2734
2735    /// Regression guard for the stale-pixel half of retroglyph#412.
2736    ///
2737    /// The incremental repaint path only touches cells whose own tile changed, but a span's
2738    /// artwork is drawn from its anchor and paints across every cell the span covers. A covered
2739    /// cell's tile holds only the fallback glyph and the offset back to the anchor, so it stays
2740    /// byte-identical while the anchor's artwork changes underneath it, and its pixels go stale.
2741    ///
2742    /// Each direction of the swap below catches one half of the fix:
2743    ///
2744    /// - Opaque to transparent needs the covered cell to be marked dirty *from the anchor*, or
2745    ///   its background is never repainted and the old opaque pixels survive.
2746    /// - Transparent to opaque needs every dirty cell's background laid down *before* any glyph,
2747    ///   or the covered cell's own background fill erases the sprite the anchor spilled into it.
2748    #[test]
2749    fn changing_only_a_span_anchor_repaints_its_covered_cells() {
2750        let opts = TilesetOptions::builder(wide_and_narrow_png())
2751            .tile_size(16, 16)
2752            .columns(2)
2753            .codepage(Codepage::Custom(vec!['S', 'T']))
2754            .build()
2755            .expect("valid 2-tile tileset");
2756        let mut r = SoftwareBackendBuilder::new()
2757            .font(retroglyph_window::font::unscii16::FONT)
2758            .grid_size(2, 1)
2759            .scale(1)
2760            .tileset(opts)
2761            .build()
2762            .unwrap()
2763            .into_renderer()
2764            .unwrap();
2765
2766        let bg = Style::new().bg(Color::Rgb { r: 0, g: 0, b: 255 });
2767        let frame = |anchor: &str| {
2768            let mut grid = Grid::new(2, 1);
2769            grid.write_span(0, 0, 0, &[anchor], bg).unwrap();
2770            grid
2771        };
2772        let wide = frame("S#");
2773        let narrow = frame("T#");
2774        assert_eq!(
2775            wide.tile(0, (1, 0)),
2776            narrow.tile(0, (1, 0)),
2777            "this only bites while the covered cell's own tile is unchanged"
2778        );
2779
2780        // The right half of the span is the covered cell, at x >= 8.
2781        let covered_is = |r: &SoftwareRenderer, want: u32, what: &str| {
2782            for y in 0..16 {
2783                for x in 8..16 {
2784                    assert_eq!(px(r, 2, x, y), want, "{what}: covered pixel ({x}, {y})");
2785                }
2786            }
2787        };
2788
2789        paint(&mut r, &wide);
2790        covered_is(&r, RED, "sanity: the wide sprite reaches the covered cell");
2791
2792        paint(&mut r, &narrow);
2793        covered_is(&r, BLUE, "opaque to transparent left stale sprite pixels");
2794
2795        paint(&mut r, &wide);
2796        covered_is(&r, RED, "transparent to opaque had its spill erased");
2797    }
2798
2799    #[test]
2800    fn a_span_on_layer_0_does_not_suppress_layer_1() {
2801        // Coverage is per layer: a sprite on layer 0 must not blank a glyph drawn above it.
2802        let mut r = renderer_with_sprite(2, 1, 16, 16, 16, SpriteAlign::TopLeft);
2803        let mut grid = Grid::new(2, 1);
2804        grid.write_span(0, 0, 0, &["S#"], Style::default()).unwrap();
2805        grid.put_tile(
2806            1,
2807            (1, 0),
2808            Tile::new(
2809                '\u{2588}',
2810                Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }),
2811            ),
2812        );
2813
2814        let tiles: Vec<(u8, Pos, Tile)> = (0..=1u8)
2815            .flat_map(|layer| (0..2u16).map(move |x| (layer, x)))
2816            .map(|(layer, x)| (layer, Pos::new(x, 0), *grid.tile(layer, (x, 0)).unwrap()))
2817            .collect();
2818        r.draw_layers(
2819            tiles
2820                .iter()
2821                .map(|(l, pos, tile)| DrawCell::on_layer(*l, *pos, tile)),
2822        )
2823        .unwrap();
2824
2825        assert!(
2826            r.pixels().contains(&GREEN),
2827            "layer 1's glyph must still render over a layer 0 span"
2828        );
2829    }
2830
2831    #[test]
2832    fn a_span_with_no_sprite_draws_only_its_anchor_glyph() {
2833        // No tileset entry for 'X', so the anchor falls back to the bitmap font, and the
2834        // covered cells stay blank rather than each drawing their own fallback glyph.
2835        let mut r = renderer_with_sprite(2, 1, 8, 16, 8, SpriteAlign::TopLeft);
2836        let mut grid = Grid::new(2, 1);
2837        grid.write_span(
2838            0,
2839            0,
2840            0,
2841            &["\u{2588}\u{2588}"],
2842            Style::new().fg(Color::Rgb { r: 0, g: 255, b: 0 }),
2843        )
2844        .unwrap();
2845        paint(&mut r, &grid);
2846
2847        assert_eq!(px(&r, 2, 0, 0), GREEN, "the anchor's own glyph still draws");
2848        assert_ne!(px(&r, 2, 8, 0), GREEN, "the covered cell's glyph does not");
2849    }
2850
2851    /// A 2-tile `8x16` PNG in two columns: tile 0 solid red, tile 1 solid green. Both tiles are
2852    /// exactly one cell, so a sprite drawn from either fills its cell with no alignment ambiguity.
2853    fn two_solid_tiles_png() -> Vec<u8> {
2854        use image::ImageEncoder as _;
2855        let (tile_w, tile_h) = (8u32, 16u32);
2856        let img_w = tile_w * 2;
2857        let mut img = image::RgbaImage::new(img_w, tile_h);
2858        for y in 0..tile_h {
2859            for x in 0..img_w {
2860                let px = if x < tile_w {
2861                    [0xFF, 0x00, 0x00, 0xFF]
2862                } else {
2863                    [0x00, 0xFF, 0x00, 0xFF]
2864                };
2865                img.put_pixel(x, y, image::Rgba(px));
2866            }
2867        }
2868        let mut png = Vec::new();
2869        image::codecs::png::PngEncoder::new(&mut png)
2870            .write_image(img.as_raw(), img_w, tile_h, image::ExtendedColorType::Rgba8)
2871            .expect("encode test tileset PNG");
2872        png
2873    }
2874
2875    /// Regression guard for retroglyph#762: a span's text fallback glyph can have its own
2876    /// registered sprite (the default `Codepage::Cp437` registers one for every codepoint, so
2877    /// this is the common case, not an edge case), and the covered cell must still draw nothing
2878    /// of its own, not that sprite.
2879    #[test]
2880    fn a_span_covered_cells_fallback_sprite_does_not_paint_over_the_anchor() {
2881        // 'S' -> tile 0 (red), the anchor's own sprite, exactly one cell (so it reserves but
2882        // does not fill cell 1, per `span_reserves_cells_beyond_the_artwork`). '#' -> tile 1
2883        // (green) is the covered cell's own text-fallback glyph, also registered as a sprite:
2884        // before the fix, the covered cell's sprite lookup ran unconditionally and painted this
2885        // sprite into cell 1 instead of leaving it reserved.
2886        let opts = TilesetOptions::builder(two_solid_tiles_png())
2887            .tile_size(8, 16)
2888            .columns(2)
2889            .codepage(Codepage::Custom(vec!['S', '#']))
2890            .build()
2891            .expect("valid 2-tile tileset");
2892        let mut r = SoftwareBackendBuilder::new()
2893            .font(retroglyph_window::font::unscii16::FONT)
2894            .grid_size(2, 1)
2895            .scale(1)
2896            .tileset(opts)
2897            .build()
2898            .unwrap()
2899            .into_renderer()
2900            .unwrap();
2901
2902        let mut grid = Grid::new(2, 1);
2903        grid.write_span(0, 0, 0, &["S#"], Style::default()).unwrap();
2904        paint(&mut r, &grid);
2905
2906        assert_eq!(px(&r, 2, 0, 0), RED, "the anchor's own sprite still draws");
2907        assert_ne!(
2908            px(&r, 2, 8, 0),
2909            GREEN,
2910            "the covered cell must not draw '#''s own sprite"
2911        );
2912    }
2913
2914    // ── Dropped-tint diagnostic (retroglyph#564) ───────────────────────────
2915
2916    #[test]
2917    fn draw_layers_reports_a_tint_on_a_glyph_without_a_sprite() {
2918        // 'X' has no tileset entry, so it falls back to the bitmap font and any tint on it is
2919        // silently dropped: exactly the trap retroglyph#537 fell into.
2920        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2921        let mut grid = Grid::new(1, 1);
2922        grid.write_span(0, 0, 0, &["X"], Style::new()).unwrap();
2923        paint_tinted(&mut r, &grid, Tint::multiply(128, 128, 128));
2924
2925        assert_eq!(
2926            r.ctx.warned_dropped_tint.contains(&'X'),
2927            retroglyph_core::dev::DEV
2928        );
2929    }
2930
2931    #[test]
2932    fn draw_layers_does_not_report_a_tint_on_a_glyph_that_has_a_sprite() {
2933        // 'S' does resolve to a sprite, so its tint is applied, not dropped, and must not be
2934        // reported.
2935        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2936        let mut grid = Grid::new(1, 1);
2937        grid.write_span(0, 0, 0, &["S"], Style::new()).unwrap();
2938        paint_tinted(&mut r, &grid, Tint::multiply(128, 128, 128));
2939
2940        assert!(!r.ctx.warned_dropped_tint.contains(&'S'));
2941    }
2942
2943    #[test]
2944    fn draw_layers_does_not_report_tint_none() {
2945        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2946        let mut grid = Grid::new(1, 1);
2947        grid.write_span(0, 0, 0, &["X"], Style::new()).unwrap();
2948        paint(&mut r, &grid);
2949
2950        assert!(r.ctx.warned_dropped_tint.is_empty());
2951    }
2952
2953    #[test]
2954    fn draw_reports_a_tint_on_a_glyph_without_a_sprite() {
2955        // `Output::draw` forwards to `draw_layers` (retroglyph#561), so this is really exercising
2956        // that the forwarding preserves per-cell tint data rather than dropping it along the way.
2957        let mut r = renderer_with_sprite(1, 1, 8, 16, 8, SpriteAlign::TopLeft);
2958        let tile = Tile::new('X', Style::new());
2959        r.draw(core::iter::once(
2960            DrawCell::new(Pos::new(0, 0), &tile).with_tint(Tint::multiply(128, 128, 128)),
2961        ))
2962        .unwrap();
2963
2964        assert_eq!(
2965            r.ctx.warned_dropped_tint.contains(&'X'),
2966            retroglyph_core::dev::DEV
2967        );
2968    }
2969}
2970
2971// ── Font chain tests (retroglyph#539) ───────────────────────────────────────
2972
2973#[cfg(all(test, feature = "default-font"))]
2974mod font_chain_tests {
2975    use super::*;
2976    use retroglyph_core::color::Color;
2977    use retroglyph_core::color::Style;
2978    use retroglyph_core::grid::Pos;
2979    use retroglyph_window::font::{BitmapFont, FontChain, unscii16};
2980
2981    const RED: Color = Color::Rgb { r: 255, g: 0, b: 0 };
2982    const BLUE: Color = Color::Rgb { r: 0, g: 0, b: 255 };
2983    const BLACK: Color = Color::Rgb { r: 0, g: 0, b: 0 };
2984
2985    const RED_PX: u32 = 0x00FF_0000;
2986    const BLUE_PX: u32 = 0x0000_00FF;
2987    const BLACK_PX: u32 = 0x0000_0000;
2988
2989    /// One 8x16 glyph with the upper-left quadrant filled: U+2598, which CP437 has no mapping for
2990    /// at all, so it is only reachable through the charset table below.
2991    static QUADRANT_DATA: [u8; 16] = [
2992        0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0, 0, 0, 0, 0, 0, 0, 0,
2993    ];
2994    const QUADRANT_CHARSET: [(char, u8); 1] = [('▘', 0)];
2995    const QUADRANT_FONT: BitmapFont =
2996        BitmapFont::with_charset(&QUADRANT_DATA, 8, 16, 1, &QUADRANT_CHARSET);
2997    static FALLBACKS: [BitmapFont; 1] = [QUADRANT_FONT];
2998
2999    /// Renders `ch` in `fg` on black, in a 1x1 grid drawn through `fonts`.
3000    fn render(fonts: FontChain<'static>, ch: char, fg: Color) -> Vec<u32> {
3001        let mut renderer = SoftwareBackendBuilder::new()
3002            .font(fonts)
3003            .grid_size(1, 1)
3004            .scale(1)
3005            .build()
3006            .expect("chain builds")
3007            .into_renderer()
3008            .expect("renderer builds");
3009        let tile = Tile::new(ch, Style::new().fg(fg).bg(BLACK));
3010        renderer
3011            .draw_layers(core::iter::once(DrawCell::on_layer(
3012                0,
3013                Pos::new(0, 0),
3014                &tile,
3015            )))
3016            .unwrap();
3017        renderer.pixels().to_vec()
3018    }
3019
3020    /// The bug: both pixel backends resolved every glyph through a CP437-only mapping, so a
3021    /// `with_charset` fallback font's glyphs were unreachable and rendered as the solid block.
3022    #[test]
3023    fn charset_fallback_glyph_renders_its_own_shape() {
3024        let pixels = render(FontChain::new(unscii16::FONT, &FALLBACKS), '▘', RED);
3025
3026        for y in 0..16 {
3027            for x in 0..8 {
3028                let expected = if x < 4 && y < 8 { RED_PX } else { BLACK_PX };
3029                assert_eq!(pixels[y * 8 + x], expected, "pixel ({x},{y})");
3030            }
3031        }
3032    }
3033
3034    /// The point of routing sub-cell glyphs through a font rather than a tileset: a font glyph is
3035    /// a 1-bit mask painted in the cell's own foreground, so the same glyph serves every color,
3036    /// while a tileset sprite carries the colors it was authored in (#537).
3037    #[test]
3038    fn charset_fallback_glyph_takes_the_cells_foreground_color() {
3039        let chain = FontChain::new(unscii16::FONT, &FALLBACKS);
3040        let red = render(chain, '▘', RED);
3041        let blue = render(chain, '▘', BLUE);
3042
3043        assert_eq!(red[0], RED_PX);
3044        assert_eq!(blue[0], BLUE_PX);
3045    }
3046
3047    /// A character no font in the chain covers still gets the solid-block substitute, so the
3048    /// chain is not a way to silently lose glyphs.
3049    #[test]
3050    fn uncovered_char_still_renders_the_solid_block() {
3051        let pixels = render(FontChain::new(unscii16::FONT, &FALLBACKS), 'あ', RED);
3052        assert!(
3053            pixels.iter().all(|&p| p == RED_PX),
3054            "every pixel of the solid block is foreground"
3055        );
3056    }
3057
3058    /// The panic reported in #539 (`glyph index 219 out of range (2)`): the CP437 solid block is
3059    /// glyph 0xDB, which a small `with_charset` font doesn't have, so substituting it by index
3060    /// indexed past the end of that font's data. A chain with nothing drawable for a character
3061    /// now leaves the cell at its background instead.
3062    #[test]
3063    fn char_no_font_can_draw_leaves_the_cell_blank() {
3064        let pixels = render(FontChain::from(QUADRANT_FONT), 'A', RED);
3065        assert!(
3066            pixels.iter().all(|&p| p == BLACK_PX),
3067            "an undrawable character paints no foreground at all"
3068        );
3069    }
3070}