Skip to main content

retroglyph_gl/
lib.rs

1//! GPU rendering backend for retroglyph: native OpenGL 3.3 core and browser WebGL2, from a single
2//! codebase via [`glow`].
3//!
4//! # Architecture
5//!
6//! [`GlBackendBuilder`] holds configuration (fonts, grid size, integer scale) and
7//! [`build`](GlBackendBuilder::build)s a [`GlRenderer`]. The glyph source is a static
8//! [`FontChain`] (a single [`BitmapFont`] is a chain of one); every font in the chain is
9//! grid-packed into one `TEXTURE_2D_ARRAY` atlas and addressed by a flat slot id (issue #367's
10//! grid-packing half, lifting the 256-layer cap). The renderer maintains
11//! per-layer CPU-side instance arrays (one entry per cell: glyph slot + fg/bg RGB + flags) and a GL
12//! context that is created lazily when the windowing loop calls
13//! [`Presenter::init_surface`]:
14//!
15//! ```text
16//! GlBackendBuilder (font, grid size, scale)
17//!   |  .build()
18//!   v
19//! GlRenderer
20//!   implements retroglyph_window::Presenter (an Output supertrait)
21//!   wrapped by retroglyph_window::WindowBackend to become a full Backend
22//!   (WindowBackend owns the input event queue and the no-op Cursor)
23//!   |
24//!   |  init_surface(window) -> GlContext (glutin native / WebGL2 wasm) + GlResources
25//!   v
26//! two instanced passes (backgrounds, then coverage-blended glyphs) per grid layer per present():
27//! a unit quad instanced cols*rows times, sampling an R8 glyph atlas (TEXTURE_2D_ARRAY).
28//! ```
29//!
30//! This backend composites grid layers itself on the GPU
31//! ([`composites_layers`](retroglyph_core::backend::Output::composites_layers) returns `true`): it
32//! receives the raw layered stream from the core `Terminal` and draws each layer back-to-front, so
33//! an empty cell in a higher layer lets the layer beneath show through while an occupied cell is
34//! opaque (issue #368), matching `retroglyph-software`'s per-pixel occlusion. It requests full
35//! frames
36//! ([`needs_full_frame`](retroglyph_core::backend::Output::needs_full_frame) returns `true`) and
37//! redraws every cell of every layer each frame, so there is no orphaned-pixel problem from
38//! sub-cell glyph spill.
39//!
40//! # Platform split
41//!
42//! Native builds create the GL context from the window's raw handles via `glutin`
43//! (`context_native.rs`); wasm builds acquire a WebGL2 context from the winit `<canvas>`
44//! (`context_wasm.rs`). Both expose the same internal `GlContext` API, so the renderer body has no
45//! `cfg`.
46//!
47//! # Features
48//!
49//! <!-- gen-features:start -->
50//! This crate has no default features; every feature below is optional and off unless enabled.
51//!
52//! ### `default-font`
53//!
54//! ⚪ Optional.
55//!
56//! Embeds the Unscii 16 default font so a caller can build a renderer with no font of its own.
57//!
58//! Forwards to `retroglyph-window`'s `default-font` feature.
59//!
60//! ### `dev`
61//!
62//! ⚪ Optional.
63//!
64//! Forwards `retroglyph-core`'s `dev` feature, which forces development diagnostics on in a build
65//! that would otherwise compile them out (see [`retroglyph_core::dev`]).
66//!
67//! ### `tilesets`
68//!
69//! ⚪ Optional.
70//!
71//! PNG sprite/tileset support (issue #366): decodes sprite sheets into an RGBA `TEXTURE_2D_ARRAY`
72//! atlas and draws them in a second, source-over blended pass.
73//!
74//! Forwards to `retroglyph-window`'s shared tileset decode.
75//! <!-- gen-features:end -->
76
77#![cfg_attr(docsrs, feature(doc_cfg))]
78
79pub mod config;
80
81mod error;
82mod renderer;
83mod shaders;
84#[cfg(feature = "tilesets")]
85mod sprites;
86
87// Headless offscreen render tests: create an EGL surfaceless context, run the real pipeline into an
88// FBO, and read the pixels back to assert on them (issue #376). Linux/EGL only (see the module
89// docs) and gated to `default-font` since the tests build a renderer from the embedded atlas.
90#[cfg(all(test, target_os = "linux", feature = "default-font"))]
91mod headless;
92
93// Live WebGL2 render smoke test (issue #370): the browser sibling of `headless`, run under
94// `wasm-bindgen-test` in headless Chrome (`just test-wasm-gl`). Gated to wasm32 + `default-font`.
95#[cfg(all(test, target_arch = "wasm32", feature = "default-font"))]
96mod webgl_smoke;
97
98// Live WebGL2 context-loss recovery test (issue #373): forces a real lost/restored cycle via the
99// `WEBGL_lose_context` extension and asserts the pipeline rebuilds and renders again. Same gating
100// and `just test-wasm-gl` runner as `webgl_smoke`.
101#[cfg(all(test, target_arch = "wasm32", feature = "default-font"))]
102mod webgl_recovery;
103
104// Platform-specific GL context, swapped by target. Both expose the same `GlContext` API (see the
105// module docs), the same pattern `retroglyph-software` uses for its window surface.
106#[cfg(not(target_arch = "wasm32"))]
107#[path = "context_native.rs"]
108mod context;
109#[cfg(target_arch = "wasm32")]
110#[path = "context_wasm.rs"]
111mod context;
112
113pub use config::{GlBackendBuilder, GlBackendError};
114pub use error::SurfaceError;
115// Re-export the font types so a consumer can build a custom atlas without a separate dependency.
116pub use retroglyph_window::font::{self as font, BitmapFont, FontChain};
117
118use context::GlContext;
119use renderer::{FLAG_HAS_BG, FLAG_HAS_GLYPH, GlResources, Instance};
120use retroglyph_core::backend::DrawCell;
121use retroglyph_core::backend::Output;
122use retroglyph_core::color::Color;
123use retroglyph_core::grid::HasSize;
124use retroglyph_core::grid::Size;
125use retroglyph_core::tile::Tile;
126use retroglyph_window::atlas::GlyphAtlas;
127use retroglyph_window::palette::{DEFAULT_BG, DEFAULT_FG};
128#[cfg(feature = "tilesets")]
129use retroglyph_window::sprite_cache::SpriteTint;
130use retroglyph_window::{CellGeometry, Presenter, WindowHandle, cell_art_glyph};
131use shaders::GlslFlavor;
132#[cfg(feature = "tilesets")]
133use sprites::{SpriteInstance, SpriteSet, SpriteSlot};
134use std::sync::Arc;
135
136// Compile the crate README's code blocks as doctests so the quick start can't silently rot.
137#[cfg(doctest)]
138#[doc = include_str!("../README.md")]
139struct ReadmeDoctests;
140
141/// The live GL renderer: a [`Presenter`], wrapped in
142/// [`WindowBackend`](retroglyph_window::WindowBackend) to form a full
143/// [`Backend`](retroglyph_core::backend::Backend) for the windowing loop.
144///
145/// It does not implement [`Input`](retroglyph_core::backend::Input) or
146/// [`Cursor`](retroglyph_core::backend::Cursor) itself: a GL renderer cannot present without a
147/// live context, so there is no headless-with-input use for a bare `Terminal<GlRenderer>`. In
148/// windowed use `WindowBackend` owns the input queue (with its `Mouse(Moved)` coalescing) and the
149/// no-op cursor, so a duplicate queue here would only ever be dead. See the sub-cell offset note
150/// on [`Presenter`] for the shared rendering contract.
151///
152/// Build one with [`GlBackendBuilder`]. Before the windowing loop calls
153/// [`init_surface`](Presenter::init_surface) there is no GL context; drawing updates only the
154/// CPU-side instance array, and [`present`](Presenter::present) is a no-op. Once the surface
155/// exists, `present` uploads changed cells and issues the single instanced draw call.
156pub struct GlRenderer {
157    /// Character-to-atlas-slot map for the bitmap font (grid-packed, issue #367).
158    glyphs: GlyphAtlas,
159    cols: u16,
160    rows: u16,
161    /// Cell/surface pixel geometry (glyph size x scale); the single source of the `cell_size`
162    /// contract, delegated to by [`Presenter::cell_size`].
163    geometry: CellGeometry,
164    /// Atlas slot for the space glyph, used to initialize blank cells.
165    space_glyph: u16,
166    /// Per-layer instance arrays (index = grid layer id), each `cols * rows` in row-major cell
167    /// order. `layers[0]` is the always-opaque base; higher layers composite over it back-to-front
168    /// (see [`present`](Presenter::present)). Rebuilt each frame by [`Output::draw_layers`], since
169    /// this backend requests full frames. There is always at least the base layer.
170    layers: Vec<Vec<Instance>>,
171    /// The decoded sprite atlas (issue #366), if a tileset was loaded. Retained so the GPU atlas
172    /// can be rebuilt after a WebGL2 context loss.
173    #[cfg(feature = "tilesets")]
174    sprite_set: Option<SpriteSet>,
175    /// Per-layer sprite instances, parallel to `layers`, rebuilt each frame by
176    /// [`Output::draw_layers`]. Empty layers (or a renderer with no tileset) carry no sprites.
177    #[cfg(feature = "tilesets")]
178    sprite_layers: Vec<Vec<SpriteInstance>>,
179    /// Glyphs already reported as needing a span, so a redraw loop logs each one once instead of
180    /// every frame. See `retroglyph_window::sprite_cache::warn_sprite_needs_span`.
181    #[cfg(feature = "tilesets")]
182    warned_oversized: std::collections::BTreeSet<char>,
183    /// Glyphs already reported as having a dropped tint, so a redraw loop logs each one once
184    /// instead of every frame. See `retroglyph_window::sprite_cache::warn_tint_needs_sprite`.
185    #[cfg(feature = "tilesets")]
186    warned_dropped_tint: std::collections::BTreeSet<char>,
187    /// The current surface size in physical pixels (set by [`resize_surface`](Presenter::resize_surface)).
188    surface_size: (u32, u32),
189    /// GL context + resources. `None` until [`init_surface`](Presenter::init_surface).
190    gpu: Option<Gpu>,
191}
192
193/// The live GL context and its resources, present only after
194/// [`init_surface`](Presenter::init_surface).
195struct Gpu {
196    ctx: GlContext,
197    res: GlResources,
198}
199
200impl GlRenderer {
201    /// Builds a renderer for the given glyph cache, grid size, and scale. Called by
202    /// [`GlBackendBuilder::build`].
203    ///
204    /// Glyph cells wider or taller than 255 unscaled pixels are clamped to 255 (the
205    /// [`CellGeometry`] limit).
206    #[allow(clippy::cast_possible_truncation)]
207    pub(crate) fn new(glyphs: GlyphAtlas, cols: u16, rows: u16, scale: u16) -> Self {
208        let (cell_w, cell_h) = glyphs.cell_size();
209        let geometry = CellGeometry::new(cell_w.min(255) as u8, cell_h.min(255) as u8, scale);
210        let space_glyph = glyphs.space_slot();
211        let count = usize::from(cols) * usize::from(rows);
212        let base = base_blank(space_glyph);
213        let layers = vec![vec![base; count]];
214        Self {
215            glyphs,
216            cols,
217            rows,
218            geometry,
219            space_glyph,
220            layers,
221            #[cfg(feature = "tilesets")]
222            sprite_set: None,
223            #[cfg(feature = "tilesets")]
224            sprite_layers: Vec::new(),
225            #[cfg(feature = "tilesets")]
226            warned_oversized: std::collections::BTreeSet::new(),
227            #[cfg(feature = "tilesets")]
228            warned_dropped_tint: std::collections::BTreeSet::new(),
229            surface_size: geometry.surface_size(cols, rows),
230            gpu: None,
231        }
232    }
233
234    /// Attaches a decoded sprite atlas (issue #366). Called by [`GlBackendBuilder::build`] when a
235    /// tileset was registered; the GPU atlas is built later in [`build_resources`](Self::build_resources).
236    #[cfg(feature = "tilesets")]
237    pub(crate) fn set_sprites(&mut self, set: SpriteSet) {
238        self.sprite_set = Some(set);
239    }
240
241    /// The base-layer blank instance: space glyph, default colors, opaque default background, no
242    /// glyph drawn. Layer 0 always paints its background (the opaque base), so an untouched base
243    /// cell is the default background.
244    const fn base_blank(&self) -> Instance {
245        base_blank(self.space_glyph)
246    }
247
248    /// Reports a sprite drawn larger than one cell without a span to reserve the cells it covers.
249    ///
250    /// Shares `retroglyph-window`'s diagnostic with the software backend so both name the same
251    /// fix. A tile that already declares a span is fine and says nothing.
252    #[cfg(feature = "tilesets")]
253    fn warn_if_sprite_needs_span(&mut self, tile: &Tile, sprite: SpriteSlot) {
254        if tile.is_span_anchor() {
255            return;
256        }
257        retroglyph_window::sprite_cache::warn_sprite_needs_span(
258            &mut self.warned_oversized,
259            tile.glyph(),
260            (u32::from(sprite.w), u32::from(sprite.h)),
261            (
262                u32::from(self.geometry.glyph_w),
263                u32::from(self.geometry.glyph_h),
264            ),
265        );
266    }
267
268    /// Reports a tint set on a cell whose glyph resolved to a bitmap font rather than a sprite, so
269    /// the tint was silently dropped (retroglyph#564).
270    ///
271    /// Shares `retroglyph-window`'s diagnostic with the software backend so both name the same
272    /// fix. Called from the branch that already knows the sprite atlas has no slot for this
273    /// glyph; a tint on a cell that does resolve to a sprite is handled, not dropped, and says
274    /// nothing here.
275    #[cfg(feature = "tilesets")]
276    fn warn_if_tint_needs_sprite(&mut self, glyph: char, tint: retroglyph_core::color::Tint) {
277        retroglyph_window::sprite_cache::warn_tint_needs_sprite(
278            &mut self.warned_dropped_tint,
279            glyph,
280            tint,
281        );
282    }
283
284    /// Total cell count for the current grid.
285    fn cell_count(&self) -> usize {
286        usize::from(self.cols) * usize::from(self.rows)
287    }
288
289    /// Builds the GL resources for the current instance array on an already-current context:
290    /// compiles the program, uploads the glyph atlas and the full instance buffer, and sets the
291    /// glyph-size and projection uniforms.
292    ///
293    /// Shared by [`Presenter::init_surface`] (windowed) and the headless render-test path so both
294    /// exercise byte-for-byte the same setup: the point of the render tests is to catch a break
295    /// in exactly this pipeline, so it must not diverge from the real one.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`SurfaceError::Init`] if a shader fails to compile or the program fails to link.
300    #[allow(clippy::cast_precision_loss)]
301    pub(crate) fn build_resources(
302        &self,
303        gl: &glow::Context,
304        flavor: GlslFlavor,
305    ) -> Result<GlResources, SurfaceError> {
306        let (w, h) = self.surface_size;
307        let atlas = self.glyphs.data();
308        #[cfg_attr(not(feature = "tilesets"), allow(unused_mut))]
309        let mut res = GlResources::new(gl, flavor, &atlas, self.cell_count())?;
310        res.upload(gl, &self.layers[0]);
311        let (cw, ch) = self.glyphs.cell_size();
312        #[allow(clippy::cast_precision_loss)]
313        res.set_glyph_size(gl, cw as f32, ch as f32);
314        // Build the RGBA sprite atlas + program on the same context (issue #366).
315        #[cfg(feature = "tilesets")]
316        if let Some(set) = &self.sprite_set {
317            res.attach_sprites(gl, flavor, set)?;
318            #[allow(clippy::cast_precision_loss)]
319            res.set_sprite_glyph_size(gl, cw as f32, ch as f32);
320        }
321        let (cell_w, cell_h) = self.geometry.cell_size();
322        res.set_projection(
323            gl,
324            w as f32,
325            h as f32,
326            cell_w as f32,
327            cell_h as f32,
328            i32::from(self.cols),
329        );
330        Ok(res)
331    }
332}
333
334/// `(u8, u8, u8)` -> `[u8; 3]`, for packing resolved colors into an [`Instance`].
335const fn to_arr(rgb: (u8, u8, u8)) -> [u8; 3] {
336    [rgb.0, rgb.1, rgb.2]
337}
338
339/// The base-layer blank instance for `space_glyph`: opaque default background, no glyph. Free
340/// function so [`GlRenderer::new`] can build it before `self` exists.
341const fn base_blank(space_glyph: u16) -> Instance {
342    Instance::new(
343        space_glyph,
344        to_arr(DEFAULT_FG),
345        to_arr(DEFAULT_BG),
346        0,
347        0,
348        FLAG_HAS_BG,
349    )
350}
351
352/// Builds the base-layer (layer 0) [`Instance`] for `tile` at the already-resolved atlas `slot`:
353/// the background is always opaque (default-substituted), and the glyph is drawn only when
354/// [`cell_art_glyph`] says this tile draws art (see its docs for the blank/span-covered rules).
355///
356/// A `slot` of `None` is a character no font in the chain can draw, not even as the substituted
357/// solid block; the cell keeps its background and draws no glyph, matching `retroglyph-software`.
358const fn base_instance(slot: Option<u16>, tile: &Tile) -> Instance {
359    let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
360    let bg = to_arr(tile.style().background().resolve_rgb(DEFAULT_BG));
361    let (slot, drawable) = match slot {
362        Some(slot) => (slot, FLAG_HAS_GLYPH),
363        None => (0, 0),
364    };
365    let flags = FLAG_HAS_BG
366        | if cell_art_glyph(tile).is_none() {
367            0
368        } else {
369            drawable
370        };
371    Instance::new(slot, fg, bg, tile.dx(), tile.dy(), flags)
372}
373
374// ── Output ───────────────────────────────────────────────────────────────────
375
376impl Output for GlRenderer {
377    // Drawing only touches CPU memory (the instance array); it never fails. GL failures surface
378    // through `Presenter::present`'s `SurfaceError` instead.
379    type Error = core::convert::Infallible;
380
381    // No `draw` override: this backend always composites (`composites_layers` returns `true`
382    // below), so `Terminal::present` never calls single-layer `draw` and the default
383    // implementation (forwards to `draw_layers`) is exactly right. See retroglyph#561; this used
384    // to have its own `write_tile`-based body that wrote glyph instances only and silently never
385    // read a cell's tint, which is exactly the kind of drift a dead, hand-maintained second
386    // implementation invites.
387    #[allow(clippy::too_many_lines)]
388    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
389    where
390        I: Iterator<Item = DrawCell<'a>>,
391    {
392        // This backend requests full frames, so `content` is every cell of every allocated layer in
393        // layer-major (0..=max) then row-major order (see `Grid::layers`). Rebuild the per-layer
394        // arrays from scratch: reset the base to blanks and drop higher layers, growing them back
395        // as the stream references them. Cells a layer doesn't stream stay transparent (flags == 0).
396        let base = self.base_blank();
397        let cell_count = self.cell_count();
398        self.layers.truncate(1);
399        let base_layer = &mut self.layers[0];
400        if base_layer.len() == cell_count {
401            base_layer.fill(base);
402        } else {
403            *base_layer = vec![base; cell_count];
404        }
405
406        // Per-cell running background, updated bottom-up as layers are processed. An occupied
407        // higher-layer tile with a `Color::Default` background inherits this instead of being
408        // transparent: matching `retroglyph-software`'s `resolve_bg_fill`, an occupied tile is
409        // opaque and erases the glyph beneath it, repainting whichever background a lower layer
410        // last established (down to layer 0's default). This relies on the layer-major stream order
411        // above, so a layer's lower neighbours are always processed first.
412        let mut inherited_bg = vec![to_arr(DEFAULT_BG); cell_count];
413
414        // Per-cell record of whether the occupant drawn at that index dispatched to a sprite
415        // (issue #366), keyed the same way as `inherited_bg`. A span's covered cells hold only a
416        // text-fallback glyph that never has a sprite of its own, so the covered-cell branch below
417        // consults this at the *anchor's* index to answer "does this span dispatch to a sprite",
418        // matching `retroglyph-software`'s `resolve_cell_bg` (retroglyph#726). Reused across layers:
419        // a lower layer's `true` is always overwritten before a higher layer's covered cell can read
420        // it, because the anchor of any span is written before its covered cells (row-major stream).
421        let mut sprite_bg = vec![false; cell_count];
422
423        // Sprite instances are collected per layer in lockstep with `self.layers` (issue #366):
424        // reset to just the (empty) base layer; higher layers are grown alongside `self.layers`.
425        #[cfg(feature = "tilesets")]
426        {
427            self.sprite_layers.truncate(1);
428            if self.sprite_layers.is_empty() {
429                self.sprite_layers.push(Vec::new());
430            }
431            self.sprite_layers[0].clear();
432        }
433
434        let cols = usize::from(self.cols);
435        let rows = usize::from(self.rows);
436        for draw_cell in content {
437            let (layer_id, pos, tile) = (draw_cell.layer, draw_cell.pos, draw_cell.tile);
438            let (x, y) = (usize::from(pos.x), usize::from(pos.y));
439            if x >= cols || y >= rows {
440                continue;
441            }
442            let l = usize::from(layer_id);
443            while self.layers.len() <= l {
444                // Higher layers default to fully transparent cells (flags == 0).
445                self.layers.push(vec![
446                    Instance::new(self.space_glyph, [0; 3], [0; 3], 0, 0, 0);
447                    cell_count
448                ]);
449                #[cfg(feature = "tilesets")]
450                self.sprite_layers.push(Vec::new());
451            }
452            let idx = y * cols + x;
453
454            // A cell whose glyph has a sprite draws the sprite instead of a bitmap glyph (issue
455            // #366); the glyph instance keeps only the background (per `resolve_bg_fill`).
456            #[cfg(feature = "tilesets")]
457            #[allow(clippy::cast_possible_truncation)]
458            let (cx, cy) = (x as u16, y as u16);
459
460            // A cell covered by a multi-cell span (retroglyph#412) draws no glyph of its own: the
461            // span's anchor emitted one sprite across the whole footprint, and this cell's glyph
462            // is that sprite's text fallback, for backends that can't draw it. Every cell of a span
463            // shares one `Style` (see `Grid::write_span_cells`), so this cell's own tile already
464            // carries the same colours as the anchor; the anchor is consulted only to answer "does
465            // this span dispatch to a sprite" (via `sprite_bg`), the same split
466            // `retroglyph-software`'s `resolve_cell_bg` documents. Resolving the running inherited
467            // background at this cell's own index, not the anchor's, keeps a span from smearing one
468            // column's inheritance across the whole footprint (retroglyph#726). The stream is
469            // row-major within a layer, so the anchor is always already written.
470            if tile.span_offset().is_some() {
471                let anchor_idx = tile
472                    .span_anchor_index(idx, cols)
473                    .filter(|&anchor_idx| anchor_idx < cell_count);
474                if let Some(anchor_idx) = anchor_idx {
475                    let has_sprite = sprite_bg[anchor_idx];
476                    let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
477                    let bg_color = tile.style().background();
478                    let (bg, has_bg) = if l == 0 || bg_color != Color::Default {
479                        (to_arr(bg_color.resolve_rgb(DEFAULT_BG)), FLAG_HAS_BG)
480                    } else if has_sprite {
481                        (inherited_bg[idx], 0)
482                    } else {
483                        (inherited_bg[idx], FLAG_HAS_BG)
484                    };
485                    if has_bg != 0 {
486                        inherited_bg[idx] = bg;
487                    }
488                    self.layers[l][idx] = Instance::new(self.space_glyph, fg, bg, 0, 0, has_bg);
489                    continue;
490                }
491            }
492
493            if layer_id == 0 {
494                let slot = self.glyphs.resolve(tile.glyph());
495                let inst = base_instance(slot, tile);
496                // Sprite dispatch is gated on `cell_art_glyph`, not the raw `tile.glyph()`: a
497                // blank layer-0 cell (`is_empty()`, e.g. an untouched grid cell) draws no art at
498                // all, even if its glyph happens to have a registered sprite (retroglyph#762).
499                #[cfg(feature = "tilesets")]
500                {
501                    let art_glyph = cell_art_glyph(tile);
502                    if let Some(sprite) =
503                        art_glyph.and_then(|g| self.sprite_set.as_ref().and_then(|s| s.slot(g)))
504                    {
505                        // Keep layer 0's opaque background; drop the glyph, the sprite covers it.
506                        let sprite_inst = Instance::new(
507                            inst.glyph,
508                            inst.fg,
509                            inst.bg,
510                            0,
511                            0,
512                            inst.flags & FLAG_HAS_BG,
513                        );
514                        inherited_bg[idx] = sprite_inst.bg;
515                        sprite_bg[idx] = true;
516                        self.layers[0][idx] = sprite_inst;
517                        let (span_w, span_h) = tile.span();
518                        let align = sprite.align_offset(
519                            span_w,
520                            span_h,
521                            self.geometry.glyph_w,
522                            self.geometry.glyph_h,
523                        );
524                        self.warn_if_sprite_needs_span(tile, sprite);
525                        self.sprite_layers[0].push(SpriteInstance::new(
526                            cx,
527                            cy,
528                            sprite.layer,
529                            sprite.w,
530                            sprite.h,
531                            tile.dx() + align.0,
532                            tile.dy() + align.1,
533                            SpriteTint::resolve(
534                                sprite.color,
535                                tile.style().foreground(),
536                                draw_cell.tint,
537                                DEFAULT_FG,
538                            ),
539                        ));
540                        continue;
541                    }
542                    if let Some(g) = art_glyph {
543                        self.warn_if_tint_needs_sprite(g, draw_cell.tint);
544                    }
545                }
546                inherited_bg[idx] = inst.bg;
547                self.layers[0][idx] = inst;
548                continue;
549            }
550            if cell_art_glyph(tile).is_none() {
551                // Transparent: nothing drawn, and the running background is unchanged. This
552                // branch runs after the span-covered `continue` above, so a `None` here always
553                // means blank, never span-covered.
554                self.layers[l][idx] = Instance::new(self.space_glyph, [0; 3], [0; 3], 0, 0, 0);
555                continue;
556            }
557            // Occupied higher-layer tile: opaque background (own colour, or the inherited one when
558            // the tile's background is `Default`) plus its glyph, unless no font in the chain can
559            // draw that character at all (see `base_instance`).
560            let resolved = self.glyphs.resolve(tile.glyph());
561            let glyph = resolved.unwrap_or(0);
562            let has_glyph = if resolved.is_some() {
563                FLAG_HAS_GLYPH
564            } else {
565                0
566            };
567            let fg = to_arr(tile.style().foreground().resolve_rgb(DEFAULT_FG));
568            let bg_color = tile.style().background();
569            let bg = if bg_color == Color::Default {
570                inherited_bg[idx]
571            } else {
572                let resolved = to_arr(bg_color.resolve_rgb(DEFAULT_BG));
573                inherited_bg[idx] = resolved;
574                resolved
575            };
576            #[cfg(feature = "tilesets")]
577            if let Some(sprite) = self.sprite_set.as_ref().and_then(|s| s.slot(tile.glyph())) {
578                // No bitmap glyph. An occupied higher-layer sprite cell with a `Default` background
579                // paints no background (the sprite's own alpha provides coverage, so lower layers
580                // show through its transparent pixels), matching `resolve_bg_fill`'s has_sprite
581                // rule; an explicit background is still painted opaque.
582                let has_bg = if bg_color == Color::Default {
583                    0
584                } else {
585                    FLAG_HAS_BG
586                };
587                sprite_bg[idx] = true;
588                self.layers[l][idx] = Instance::new(glyph, fg, bg, 0, 0, has_bg);
589                let (span_w, span_h) = tile.span();
590                let align = sprite.align_offset(
591                    span_w,
592                    span_h,
593                    self.geometry.glyph_w,
594                    self.geometry.glyph_h,
595                );
596                self.warn_if_sprite_needs_span(tile, sprite);
597                self.sprite_layers[l].push(SpriteInstance::new(
598                    cx,
599                    cy,
600                    sprite.layer,
601                    sprite.w,
602                    sprite.h,
603                    tile.dx() + align.0,
604                    tile.dy() + align.1,
605                    SpriteTint::resolve(
606                        sprite.color,
607                        tile.style().foreground(),
608                        draw_cell.tint,
609                        DEFAULT_FG,
610                    ),
611                ));
612                continue;
613            }
614            #[cfg(feature = "tilesets")]
615            self.warn_if_tint_needs_sprite(tile.glyph(), draw_cell.tint);
616            sprite_bg[idx] = false;
617            self.layers[l][idx] =
618                Instance::new(glyph, fg, bg, tile.dx(), tile.dy(), FLAG_HAS_BG | has_glyph);
619        }
620        Ok(())
621    }
622
623    fn needs_full_frame(&self) -> bool {
624        // Composited layers plus sub-cell glyph spill mean a partial redraw could leave orphaned
625        // pixels; redraw every cell of every layer each frame.
626        true
627    }
628
629    fn composites_layers(&self) -> bool {
630        // Draw the raw layered stream back-to-front on the GPU (issue #368) instead of letting the
631        // core flatten it, so per-layer transparency works the same as on `retroglyph-software`.
632        true
633    }
634
635    fn flush(&mut self) -> Result<(), Self::Error> {
636        // Upload is deferred to `present`, which owns the GL context.
637        Ok(())
638    }
639
640    fn size(&self) -> Size {
641        Size::new(self.cols, self.rows)
642    }
643
644    fn clear(&mut self) -> Result<(), Self::Error> {
645        let base = self.base_blank();
646        self.layers.truncate(1);
647        for cell in &mut self.layers[0] {
648            *cell = base;
649        }
650        // Sprite instances are collected per layer in lockstep with `self.layers` (issue #366);
651        // a stale, larger `sprite_layers` would otherwise survive the clear and get redrawn by
652        // `present` (issue #727).
653        #[cfg(feature = "tilesets")]
654        {
655            self.sprite_layers.truncate(1);
656            if self.sprite_layers.is_empty() {
657                self.sprite_layers.push(Vec::new());
658            }
659            self.sprite_layers[0].clear();
660        }
661        Ok(())
662    }
663
664    fn resize(&mut self, size: Size) {
665        self.cols = size.width();
666        self.rows = size.height();
667        let base = self.base_blank();
668        self.layers = vec![vec![base; self.cell_count()]];
669        // See the comment in `clear`: `sprite_layers` must stay in lockstep with `layers` so
670        // `present` doesn't redraw sprites left over from before the resize (issue #727).
671        #[cfg(feature = "tilesets")]
672        {
673            self.sprite_layers = vec![Vec::new()];
674        }
675    }
676}
677
678// GlRenderer implements neither `Input` nor `Cursor`: `WindowBackend<GlRenderer>` supplies both
679// for windowed use (its input queue coalesces `Mouse(Moved)`; its cursor is a no-op), and a GL
680// renderer has no headless-with-input path that would need its own. See the type-level docs.
681
682// ── Presenter ────────────────────────────────────────────────────────────────
683
684impl Presenter for GlRenderer {
685    type SurfaceError = SurfaceError;
686
687    fn init_surface(&mut self, window: Arc<dyn WindowHandle>) -> Result<(), SurfaceError> {
688        // Re-entry (surface-loss recovery, issue #728): a previous `Gpu` may still be installed,
689        // e.g. from `try_recover_surface` re-calling this after repeated present failures. Delete
690        // its GL objects and drop its context before building the replacement, the same cleanup
691        // `impl Drop for GlRenderer` does, so nothing from the old context is orphaned.
692        if let Some(gpu) = self.gpu.take() {
693            gpu.res.delete(&gpu.ctx.gl);
694        }
695        let (w, h) = self.surface_size;
696        let ctx = GlContext::new(&window, w, h)?;
697        let res = self.build_resources(&ctx.gl, ctx.flavor())?;
698        self.gpu = Some(Gpu { ctx, res });
699        Ok(())
700    }
701
702    fn resize_surface(&mut self, width: u32, height: u32) {
703        self.surface_size = (width, height);
704        if let Some(gpu) = &self.gpu {
705            gpu.ctx.resize(width, height);
706        }
707    }
708
709    #[allow(clippy::cast_precision_loss)]
710    fn present(&mut self) -> Result<(), SurfaceError> {
711        let cell_count = self.cell_count();
712        let cols = i32::from(self.cols);
713        let (w, h) = self.surface_size;
714        let (cell_w, cell_h) = self.geometry.cell_size();
715        let (cell_w, cell_h) = (cell_w as f32, cell_h as f32);
716
717        // WebGL2 context-loss recovery (issue #373): if the context was lost and has since been
718        // restored, every GL object (program, buffers, atlas texture) was invalidated, so rebuild
719        // them on the now-live context before drawing. Always a no-op on native, where
720        // `take_needs_rebuild` is `const false`. Taken out of `self.gpu` for the rebuild so
721        // `build_resources`' `&self` borrow doesn't overlap the `&mut self.gpu` one.
722        if self
723            .gpu
724            .as_ref()
725            .is_some_and(|gpu| gpu.ctx.take_needs_rebuild())
726        {
727            let mut gpu = self.gpu.take().expect("is_some_and matched above");
728            match self.build_resources(&gpu.ctx.gl, gpu.ctx.flavor()) {
729                Ok(res) => {
730                    gpu.res = res;
731                    self.gpu = Some(gpu);
732                }
733                Err(e) => {
734                    self.gpu = Some(gpu);
735                    return Err(e);
736                }
737            }
738        }
739
740        // Split borrow: `gpu` borrows `self.gpu`, while `self.layers` is a disjoint field, so
741        // direct field access to it stays legal below.
742        let Some(gpu) = self.gpu.as_mut() else {
743            // No surface yet: nothing to present.
744            return Ok(());
745        };
746
747        // Keep the GPU instance buffer sized to the current grid (grid resizes arrive via
748        // `Output::resize`, out of band from surface resizes).
749        if gpu.res.capacity() != cell_count {
750            gpu.res.resize_instances(&gpu.ctx.gl, cell_count);
751        }
752
753        gpu.res
754            .set_projection(&gpu.ctx.gl, w as f32, h as f32, cell_w, cell_h, cols);
755        // Composite every layer back-to-front: clear once, then upload and draw each layer's
756        // instances in turn (issue #368). This backend requests full frames, so `self.layers`
757        // already holds the whole current frame.
758        gpu.res.clear(&gpu.ctx.gl);
759        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
760        for l in 0..self.layers.len() {
761            gpu.res.upload(&gpu.ctx.gl, &self.layers[l]);
762            gpu.res.draw_layer(&gpu.ctx.gl, cell_count as i32);
763            // Sprite pass for this layer, over its glyph passes and source-over blended (issue
764            // #366). Parallel to `self.layers`; a layer with no sprite cells draws nothing.
765            #[cfg(feature = "tilesets")]
766            if let Some(sprites) = self.sprite_layers.get(l) {
767                gpu.res.draw_sprites(&gpu.ctx.gl, sprites);
768            }
769        }
770        gpu.ctx.present()
771    }
772
773    fn cell_size(&self) -> (u32, u32) {
774        self.geometry.cell_size()
775    }
776
777    fn geometry(&self) -> CellGeometry {
778        self.geometry
779    }
780}
781
782impl Drop for GlRenderer {
783    fn drop(&mut self) {
784        if let Some(gpu) = &self.gpu {
785            gpu.res.delete(&gpu.ctx.gl);
786        }
787    }
788}
789
790#[cfg(all(test, feature = "default-font"))]
791mod compositing_tests {
792    use super::{FLAG_HAS_BG, FLAG_HAS_GLYPH};
793    use crate::GlBackendBuilder;
794    use retroglyph_core::backend::DrawCell;
795    use retroglyph_core::backend::Output;
796    use retroglyph_core::color::Color;
797    use retroglyph_core::color::Style;
798    use retroglyph_core::grid::Pos;
799    use retroglyph_core::tile::Tile;
800
801    const RED: Color = Color::Rgb { r: 255, g: 0, b: 0 };
802
803    #[test]
804    fn draw_records_sub_cell_offset_and_flags_in_the_base_layer() {
805        let mut r = GlBackendBuilder::new()
806            .grid_size(4, 2)
807            .build()
808            .expect("default-font builds");
809        let tile = Tile::new('A', Style::new()).with_offset(-3, 5);
810        // `Output::draw` has no override on this backend (retroglyph#561): this exercises the
811        // trait's default, which forwards to `draw_layers` tagged onto layer 0.
812        r.draw(core::iter::once(DrawCell::new(Pos::new(1, 0), &tile)))
813            .expect("draw is infallible");
814
815        let inst = r.layers[0][1];
816        assert_eq!(inst.dx, -3);
817        assert_eq!(inst.dy, 5);
818        let a_slot = r.glyphs.resolve('A').expect("'A' is in CP437");
819        assert_eq!(inst.glyph, a_slot);
820        // A non-empty tile on the base layer draws both its glyph and its (base) background.
821        assert_eq!(inst.flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
822    }
823
824    #[test]
825    fn build_rejects_a_grid_and_scale_that_overflow_the_surface_size() {
826        // retroglyph#729: `CellGeometry::surface_size` multiplied cols/rows/scale as plain `u32`,
827        // which overflows for a `u16` scale this large (unlike the software backend's `u8` scale).
828        let result = GlBackendBuilder::new()
829            .grid_size(u16::MAX, 1)
830            .scale(u16::MAX)
831            .build();
832        assert!(matches!(
833            result,
834            Err(crate::GlBackendError::SurfaceTooLarge)
835        ));
836    }
837
838    #[test]
839    fn base_layer_blank_cells_are_opaque_background_only() {
840        let r = GlBackendBuilder::new()
841            .grid_size(3, 3)
842            .build()
843            .expect("default-font builds");
844        // Untouched base cells: opaque default background, no glyph, no offset.
845        assert!(
846            r.layers[0]
847                .iter()
848                .all(|i| i.dx == 0 && i.dy == 0 && i.flags == FLAG_HAS_BG)
849        );
850    }
851
852    #[test]
853    fn composites_layers_and_requests_full_frames() {
854        let r = GlBackendBuilder::new()
855            .grid_size(2, 1)
856            .build()
857            .expect("default-font builds");
858        assert!(r.composites_layers());
859        assert!(r.needs_full_frame());
860    }
861
862    #[test]
863    fn draw_layers_encodes_the_occlusion_rule_per_layer() {
864        let mut r = GlBackendBuilder::new()
865            .grid_size(3, 1)
866            .build()
867            .expect("default-font builds");
868
869        // Layer 0: an opaque glyph with a real background at (0,0).
870        let base = Tile::new('X', Style::new().bg(RED));
871        // Layer 1: (0,0) empty (transparent), (1,0) glyph with default bg (transparent bg),
872        // (2,0) glyph with a real bg (opaque).
873        let empty = Tile::default();
874        let glyph_default_bg = Tile::new('Y', Style::new());
875        let glyph_real_bg = Tile::new('Z', Style::new().bg(RED));
876        let stream = [
877            DrawCell::on_layer(0, Pos::new(0, 0), &base),
878            DrawCell::on_layer(1, Pos::new(0, 0), &empty),
879            DrawCell::on_layer(1, Pos::new(1, 0), &glyph_default_bg),
880            DrawCell::on_layer(1, Pos::new(2, 0), &glyph_real_bg),
881        ];
882        r.draw_layers(stream.iter().copied())
883            .expect("draw_layers is infallible");
884
885        // Base layer cell 0 draws both.
886        assert_eq!(r.layers[0][0].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
887        // A second layer was allocated.
888        assert_eq!(r.layers.len(), 2);
889        // Higher-layer empty cell: fully transparent (nothing drawn -> lower layer shows).
890        assert_eq!(r.layers[1][0].flags, 0);
891        // Higher-layer occupied cell with a Default background is opaque (it erases the glyph
892        // beneath), inheriting the background from below: here the untouched base cell.
893        assert_eq!(r.layers[1][1].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
894        assert_eq!(r.layers[1][1].bg, r.layers[0][1].bg);
895        // Higher-layer glyph with a real background: both, with its own colour.
896        assert_eq!(r.layers[1][2].flags, FLAG_HAS_BG | FLAG_HAS_GLYPH);
897        assert_eq!(r.layers[1][2].bg, [255, 0, 0]);
898    }
899
900    #[test]
901    fn draw_layers_full_frame_drops_a_removed_higher_layer() {
902        let mut r = GlBackendBuilder::new()
903            .grid_size(2, 1)
904            .build()
905            .expect("default-font builds");
906        let tile = Tile::new('Q', Style::new());
907        // Frame 1: two layers.
908        r.draw_layers(core::iter::once(DrawCell::on_layer(
909            1,
910            Pos::new(0, 0),
911            &tile,
912        )))
913        .expect("draw_layers");
914        assert_eq!(r.layers.len(), 2);
915        // Frame 2: only the base layer is streamed, so the higher layer must not linger.
916        r.draw_layers(core::iter::once(DrawCell::on_layer(
917            0,
918            Pos::new(0, 0),
919            &tile,
920        )))
921        .expect("draw_layers");
922        assert_eq!(r.layers.len(), 1);
923    }
924
925    /// A span's covered cells draw no glyph of their own and take the anchor's background, so one
926    /// piece of artwork sits on one uniform backdrop (retroglyph#412).
927    #[test]
928    fn draw_layers_gives_span_covered_cells_the_anchors_background_and_no_glyph() {
929        use retroglyph_core::grid::Grid;
930
931        let mut r = GlBackendBuilder::new()
932            .grid_size(3, 1)
933            .build()
934            .expect("default-font builds");
935
936        let mut grid = Grid::new(3, 1);
937        grid.write_span(0, 0, 0, &["C="], Style::new().bg(RED))
938            .expect("2x1 span fits");
939        let tiles: Vec<(u8, Pos, Tile)> = (0..3)
940            .map(|x| (0u8, Pos::new(x, 0), *grid.tile(0, (x, 0)).unwrap()))
941            .collect();
942        r.draw_layers(
943            tiles
944                .iter()
945                .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
946        )
947        .expect("draw_layers is infallible");
948
949        let (anchor, covered, free) = (r.layers[0][0], r.layers[0][1], r.layers[0][2]);
950        assert_eq!(anchor.flags, FLAG_HAS_BG | FLAG_HAS_GLYPH, "anchor draws");
951        assert_eq!(covered.flags, FLAG_HAS_BG, "covered cell draws no glyph");
952        assert_eq!(
953            covered.bg, anchor.bg,
954            "covered cell inherits the anchor's bg"
955        );
956        assert_eq!(covered.bg, [255, 0, 0]);
957        // A cell outside the span is untouched by any of this.
958        assert_eq!(free.flags, FLAG_HAS_BG);
959        assert_ne!(free.bg, [255, 0, 0]);
960    }
961
962    /// retroglyph#726: a `Color::Default`-background span on a higher layer must not smear the
963    /// anchor's column across the whole footprint. Layer 0 has a different background under each
964    /// half of the span (red under the anchor, blue under the covered cell); the covered cell's
965    /// `Default` background must inherit from *its own* column (blue), matching
966    /// `retroglyph-software`'s `resolve_cell_bg`, not the anchor's (red).
967    #[test]
968    fn draw_layers_resolves_a_span_covered_cells_default_background_at_its_own_column() {
969        use retroglyph_core::grid::Grid;
970
971        const BLUE: Color = Color::Rgb { r: 0, g: 0, b: 255 };
972
973        let mut r = GlBackendBuilder::new()
974            .grid_size(2, 1)
975            .build()
976            .expect("default-font builds");
977
978        let mut grid = Grid::new(2, 1);
979        grid.put_tile(0, (0, 0), Tile::new(' ', Style::new().bg(RED)));
980        grid.put_tile(0, (1, 0), Tile::new(' ', Style::new().bg(BLUE)));
981        grid.write_span(1, 0, 0, &["C="], Style::new())
982            .expect("2x1 span fits");
983
984        let mut tiles: Vec<(u8, Pos, Tile)> = (0..2)
985            .map(|x| (0u8, Pos::new(x, 0), *grid.tile(0, (x, 0)).unwrap()))
986            .collect();
987        tiles.extend((0..2).map(|x| (1u8, Pos::new(x, 0), *grid.tile(1, (x, 0)).unwrap())));
988        r.draw_layers(
989            tiles
990                .iter()
991                .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
992        )
993        .expect("draw_layers is infallible");
994
995        let covered = r.layers[1][1];
996        assert_eq!(covered.flags, FLAG_HAS_BG, "covered cell draws no glyph");
997        assert_eq!(
998            covered.bg,
999            [0, 0, 255],
1000            "covered cell inherits its own column's background, not the anchor's"
1001        );
1002    }
1003
1004    /// Covered-cell suppression is grid state, not a tileset feature, so it holds with the
1005    /// `tilesets` feature off too: a span with no sprite behind it renders as its anchor glyph
1006    /// alone, the same on both pixel backends.
1007    #[test]
1008    fn draw_layers_suppresses_covered_glyphs_without_a_sprite() {
1009        use retroglyph_core::grid::Grid;
1010
1011        let mut r = GlBackendBuilder::new()
1012            .grid_size(2, 1)
1013            .build()
1014            .expect("default-font builds");
1015        let mut grid = Grid::new(2, 1);
1016        grid.write_span(0, 0, 0, &["AB"], Style::new()).unwrap();
1017        let tiles: Vec<(u8, Pos, Tile)> = (0..2)
1018            .map(|x| (0u8, Pos::new(x, 0), *grid.tile(0, (x, 0)).unwrap()))
1019            .collect();
1020        r.draw_layers(
1021            tiles
1022                .iter()
1023                .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
1024        )
1025        .expect("draw_layers is infallible");
1026
1027        assert_eq!(r.layers[0][0].flags & FLAG_HAS_GLYPH, FLAG_HAS_GLYPH);
1028        assert_eq!(r.layers[0][1].flags & FLAG_HAS_GLYPH, 0);
1029    }
1030
1031    /// A single 8x16 opaque tile mapped to `'S'`. See `dropped_tint_tests::one_tile_png`'s doc
1032    /// comment for why this is a hardcoded byte literal rather than built with the `image` crate.
1033    #[cfg(feature = "tilesets")]
1034    fn one_tile_png() -> Vec<u8> {
1035        vec![
1036            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1037            0x44, 0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x10, 0x08, 0x06, 0x00, 0x00,
1038            0x00, 0x2B, 0x8A, 0x3E, 0x7D, 0x00, 0x00, 0x00, 0x15, 0x49, 0x44, 0x41, 0x54, 0x78,
1039            0xDA, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x1F, 0x66, 0x18, 0x55, 0x30, 0x92, 0x14,
1040            0x00, 0x00, 0x09, 0x79, 0xFF, 0x01, 0x4F, 0x5C, 0x4F, 0x78, 0x00, 0x00, 0x00, 0x00,
1041            0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
1042        ]
1043    }
1044
1045    /// retroglyph#726, the `has_sprite` arm `resolve_cell_bg`/the covered-cell branch above share:
1046    /// a `Color::Default`-background span whose *anchor* dispatches to a sprite paints no
1047    /// background on its covered cells (the sprite's own alpha provides coverage), matching
1048    /// `resolve_bg_fill`'s `has_sprite` rule. Layer 0's default background stays outside
1049    /// `inherited_bg`'s influence here on purpose: this only asserts the covered cell is
1050    /// transparent, not what shows through it.
1051    #[cfg(feature = "tilesets")]
1052    #[test]
1053    fn draw_layers_paints_no_background_on_a_span_covered_cell_whose_anchor_has_a_sprite() {
1054        use retroglyph_core::grid::Grid;
1055        use retroglyph_window::tileset::{Codepage, TilesetOptions};
1056
1057        let opts = TilesetOptions::builder(one_tile_png())
1058            .tile_size(8, 16)
1059            .codepage(Codepage::Custom(vec!['S']))
1060            .build()
1061            .expect("valid single-tile tileset");
1062        let mut r = GlBackendBuilder::new()
1063            .grid_size(2, 1)
1064            .tileset(opts)
1065            .build()
1066            .expect("gl renderer with tileset");
1067
1068        let mut grid = Grid::new(2, 1);
1069        grid.write_span(1, 0, 0, &["S="], Style::new())
1070            .expect("2x1 span fits");
1071        let tiles: Vec<(u8, Pos, Tile)> = (0..2)
1072            .map(|x| (1u8, Pos::new(x, 0), *grid.tile(1, (x, 0)).unwrap()))
1073            .collect();
1074        r.draw_layers(
1075            tiles
1076                .iter()
1077                .map(|(l, pos, t)| DrawCell::on_layer(*l, *pos, t)),
1078        )
1079        .expect("draw_layers is infallible");
1080
1081        let covered = r.layers[1][1];
1082        assert_eq!(
1083            covered.flags & FLAG_HAS_BG,
1084            0,
1085            "a sprite anchor's covered cell paints no background"
1086        );
1087    }
1088}
1089
1090/// Dropped-tint diagnostic (retroglyph#564): a tint set on a cell whose glyph resolved to a
1091/// bitmap font rather than a sprite is silently dropped, the same trap retroglyph#537 fell into.
1092/// These exercise `GlRenderer::draw_layers` directly (no GL context needed: only the CPU-side
1093/// `warned_dropped_tint` set and instance arrays are inspected).
1094#[cfg(all(test, feature = "default-font", feature = "tilesets"))]
1095mod dropped_tint_tests {
1096    use crate::GlBackendBuilder;
1097    use retroglyph_core::backend::DrawCell;
1098    use retroglyph_core::backend::Output;
1099    use retroglyph_core::color::Style;
1100    use retroglyph_core::color::Tint;
1101    use retroglyph_core::grid::Pos;
1102    use retroglyph_core::tile::Tile;
1103    use retroglyph_window::tileset::{Codepage, TilesetOptions};
1104
1105    /// A single 8x16 opaque red tile mapped to `'S'`, the Unscii cell size.
1106    ///
1107    /// A hardcoded byte literal rather than built with the `image` crate: unlike
1108    /// `retroglyph-software`, this crate only pulls `image` in as a dev-dependency on Linux and
1109    /// wasm32 (see `headless.rs`/`webgl_smoke.rs`), so a test that must build on every platform
1110    /// (this one; it needs no GL context) cannot depend on it being present to encode a PNG.
1111    fn one_tile_png() -> Vec<u8> {
1112        vec![
1113            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
1114            0x44, 0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x10, 0x08, 0x06, 0x00, 0x00,
1115            0x00, 0x2B, 0x8A, 0x3E, 0x7D, 0x00, 0x00, 0x00, 0x15, 0x49, 0x44, 0x41, 0x54, 0x78,
1116            0xDA, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x1F, 0x66, 0x18, 0x55, 0x30, 0x92, 0x14,
1117            0x00, 0x00, 0x09, 0x79, 0xFF, 0x01, 0x4F, 0x5C, 0x4F, 0x78, 0x00, 0x00, 0x00, 0x00,
1118            0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
1119        ]
1120    }
1121
1122    fn renderer_with_sprite(cols: u16, rows: u16) -> crate::GlRenderer {
1123        let opts = TilesetOptions::builder(one_tile_png())
1124            .tile_size(8, 16)
1125            .codepage(Codepage::Custom(vec!['S']))
1126            .build()
1127            .expect("valid single-tile tileset");
1128        GlBackendBuilder::new()
1129            .grid_size(cols, rows)
1130            .tileset(opts)
1131            .build()
1132            .expect("default-font builds")
1133    }
1134
1135    #[test]
1136    fn layer_0_reports_a_tint_on_a_glyph_without_a_sprite() {
1137        // 'X' has no tileset entry, so it falls back to the bitmap font, and any tint on it is
1138        // dropped.
1139        let mut r = renderer_with_sprite(1, 1);
1140        let tile = Tile::new('X', Style::new());
1141        r.draw_layers(core::iter::once(
1142            DrawCell::on_layer(0, Pos::new(0, 0), &tile).with_tint(Tint::multiply(128, 128, 128)),
1143        ))
1144        .expect("draw_layers is infallible");
1145
1146        assert_eq!(
1147            r.warned_dropped_tint.contains(&'X'),
1148            retroglyph_core::dev::DEV
1149        );
1150    }
1151
1152    #[test]
1153    fn layer_0_does_not_report_a_tint_on_a_glyph_that_has_a_sprite() {
1154        let mut r = renderer_with_sprite(1, 1);
1155        let tile = Tile::new('S', Style::new());
1156        r.draw_layers(core::iter::once(
1157            DrawCell::on_layer(0, Pos::new(0, 0), &tile).with_tint(Tint::multiply(128, 128, 128)),
1158        ))
1159        .expect("draw_layers is infallible");
1160
1161        assert!(!r.warned_dropped_tint.contains(&'S'));
1162    }
1163
1164    #[test]
1165    fn a_higher_layer_reports_a_tint_on_a_glyph_without_a_sprite() {
1166        // The `layer_id != 0` branch is a separate code path from layer 0's; it must report the
1167        // same thing.
1168        let mut r = renderer_with_sprite(1, 1);
1169        let base = Tile::new(' ', Style::new());
1170        let tile = Tile::new('X', Style::new());
1171        r.draw_layers(
1172            [
1173                DrawCell::on_layer(0, Pos::new(0, 0), &base),
1174                DrawCell::on_layer(1, Pos::new(0, 0), &tile).with_tint(Tint::multiply(1, 1, 1)),
1175            ]
1176            .into_iter(),
1177        )
1178        .expect("draw_layers is infallible");
1179
1180        assert_eq!(
1181            r.warned_dropped_tint.contains(&'X'),
1182            retroglyph_core::dev::DEV
1183        );
1184    }
1185
1186    #[test]
1187    fn tint_none_is_never_reported() {
1188        let mut r = renderer_with_sprite(1, 1);
1189        let tile = Tile::new('X', Style::new());
1190        r.draw_layers(core::iter::once(DrawCell::on_layer(
1191            0,
1192            Pos::new(0, 0),
1193            &tile,
1194        )))
1195        .expect("draw_layers is infallible");
1196
1197        assert!(r.warned_dropped_tint.is_empty());
1198    }
1199
1200    /// Draws a sprite on two layers, so `sprite_layers` has more than the (always present) base
1201    /// layer entry to be reset (issue #727).
1202    fn renderer_with_a_sprite_on_two_layers() -> crate::GlRenderer {
1203        let mut r = renderer_with_sprite(1, 1);
1204        let sprite = Tile::new('S', Style::new());
1205        r.draw_layers(
1206            [
1207                DrawCell::on_layer(0, Pos::new(0, 0), &sprite),
1208                DrawCell::on_layer(1, Pos::new(0, 0), &sprite),
1209            ]
1210            .into_iter(),
1211        )
1212        .expect("draw_layers is infallible");
1213        r
1214    }
1215
1216    #[test]
1217    fn clear_resets_sprite_layers_to_a_single_empty_layer() {
1218        let mut r = renderer_with_a_sprite_on_two_layers();
1219        assert_eq!(r.sprite_layers.len(), 2);
1220        assert!(!r.sprite_layers[0].is_empty());
1221
1222        r.clear().expect("clear is infallible");
1223
1224        assert_eq!(r.sprite_layers.len(), 1);
1225        assert!(r.sprite_layers[0].is_empty());
1226    }
1227
1228    #[test]
1229    fn resize_resets_sprite_layers_to_a_single_empty_layer() {
1230        use retroglyph_core::grid::Size;
1231
1232        let mut r = renderer_with_a_sprite_on_two_layers();
1233        assert_eq!(r.sprite_layers.len(), 2);
1234        assert!(!r.sprite_layers[0].is_empty());
1235
1236        r.resize(Size::new(2, 2));
1237
1238        assert_eq!(r.sprite_layers.len(), 1);
1239        assert!(r.sprite_layers[0].is_empty());
1240    }
1241}
1242
1243// ── Output conformance (retroglyph#763) ─────────────────────────────────────────
1244
1245/// `GlRenderer` deliberately implements neither `Input` nor `Cursor` (see the type-level docs),
1246/// so only [`assert_output_contract`](retroglyph_core::testing::conformance::assert_output_contract)
1247/// applies here.
1248#[cfg(all(test, feature = "default-font"))]
1249mod output_conformance_tests {
1250    use crate::GlBackendBuilder;
1251    use crate::GlRenderer;
1252    use retroglyph_core::backend::Output;
1253    use retroglyph_core::grid::HasSize;
1254    use retroglyph_core::grid::Size;
1255    use retroglyph_core::testing::conformance::{Observable, fnv1a};
1256
1257    /// `Instance` has no `PartialEq` (it's a tightly-packed, `#[repr(C)]` upload buffer, not a
1258    /// value type elsewhere in the crate needs to compare), so this compares the fields directly.
1259    fn instances_equal(a: &crate::renderer::Instance, b: &crate::renderer::Instance) -> bool {
1260        a.glyph == b.glyph
1261            && a.flags == b.flags
1262            && a.fg == b.fg
1263            && a.bg == b.bg
1264            && a.dx == b.dx
1265            && a.dy == b.dy
1266    }
1267
1268    fn conformance_renderer(size: Size) -> GlRenderer {
1269        GlBackendBuilder::new()
1270            .grid_size(size.width(), size.height())
1271            .build()
1272            .expect("default-font build must not fail for a nonzero grid")
1273    }
1274
1275    /// [`Observable::snapshot`] hashes only the CPU-side instance data that changed since the
1276    /// previous call, per that trait's docs. `GlRenderer` has no CPU-readable framebuffer without
1277    /// a real GL context (see `headless.rs`'s Linux-only pixel-readback tests), but its `layers`
1278    /// field is the exact per-cell data every draw uploads verbatim to the GPU on the next
1279    /// present, so hashing it is equivalent to hashing the frame for everything this contract
1280    /// checks (clear/resize/out-of-range handling never touch the GPU at all).
1281    struct GlObserver {
1282        renderer: GlRenderer,
1283        previous: Vec<Vec<crate::renderer::Instance>>,
1284    }
1285
1286    impl GlObserver {
1287        fn new(size: Size) -> Self {
1288            let renderer = conformance_renderer(size);
1289            let previous = renderer.layers.clone();
1290            Self { renderer, previous }
1291        }
1292    }
1293
1294    impl Output for GlObserver {
1295        type Error = core::convert::Infallible;
1296
1297        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
1298        where
1299            I: Iterator<Item = retroglyph_core::backend::DrawCell<'a>>,
1300        {
1301            self.renderer.draw_layers(content)
1302        }
1303
1304        fn flush(&mut self) -> Result<(), Self::Error> {
1305            self.renderer.flush()
1306        }
1307
1308        fn size(&self) -> Size {
1309            Output::size(&self.renderer)
1310        }
1311
1312        fn clear(&mut self) -> Result<(), Self::Error> {
1313            Output::clear(&mut self.renderer)
1314        }
1315
1316        fn resize(&mut self, size: Size) {
1317            Output::resize(&mut self.renderer, size);
1318        }
1319    }
1320
1321    impl Observable for GlObserver {
1322        fn snapshot(&mut self) -> u64 {
1323            let current = &self.renderer.layers;
1324            let mut hash = fnv1a(b"gl-diff");
1325            for (layer, (was, now)) in self.previous.iter().zip(current.iter()).enumerate() {
1326                for (index, (was, now)) in was.iter().zip(now.iter()).enumerate() {
1327                    if !instances_equal(was, now) {
1328                        hash ^= fnv1a(&(layer as u64).to_ne_bytes());
1329                        hash ^= fnv1a(&(index as u64).to_ne_bytes());
1330                        hash ^= fnv1a(&now.glyph.to_ne_bytes());
1331                        hash ^= fnv1a(&[now.flags]);
1332                        hash ^= fnv1a(&now.fg);
1333                        hash ^= fnv1a(&now.bg);
1334                        hash ^= fnv1a(&now.dx.to_ne_bytes());
1335                        hash ^= fnv1a(&now.dy.to_ne_bytes());
1336                    }
1337                }
1338            }
1339            // A resize changes the number of layers/cells outright: fold that in too, or a
1340            // shrink-then-grow back to the same per-cell content would hash identically to no
1341            // change at all.
1342            hash ^= fnv1a(&(current.len() as u64).to_ne_bytes());
1343            for layer in current {
1344                hash ^= fnv1a(&(layer.len() as u64).to_ne_bytes());
1345            }
1346            self.previous = current.clone();
1347            hash
1348        }
1349    }
1350
1351    #[test]
1352    fn satisfies_the_output_contract() {
1353        retroglyph_core::testing::conformance::assert_output_contract(GlObserver::new);
1354    }
1355}