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