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}