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