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