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}