Skip to main content

shuyi_ui/frame/
snapshot.rs

1//! Text dump of a [`GlyphBuffer`] for snapshot tests (game ADR-0007):
2//!
3//! ```text
4//! <glyph rows, a char a cell, trailing spaces kept>
5//! --- colours ---
6//! <rows of single-char keys, one per cell>
7//! --- legend ---
8//! a = fg:text bg:panel
9//! b = fg:highlight bg:#1c3a79
10//! --- overlays ---
11//! over  18,46 11x2  hp_mid
12//! over  sprite 8,8 80x80  images/test_card.png 0,0 16x16
13//! ```
14//!
15//! Each distinct (fg, bg) pair gets a key in first-seen order (row-major):
16//! `a-z`, `A-Z`, `0-9`, then further Unicode letters should a screen ever
17//! use more than 62 pairs. Colours print as their palette name when one
18//! matches exactly, else as `#rrggbb`. Lines end in `\n` on every OS.
19//!
20//! A wide character's second cell (a continuation,
21//! [`Cell::is_continuation`]) prints as nothing in the glyph rows, so a row
22//! with wide text reads as it is drawn, the wide character taking the
23//! room of two: such a row has fewer chars than the buffer is wide. The
24//! colour rows still have a key a cell.
25//!
26//! The overlays section lists the buffer's items in drawing order, in
27//! console pixels; it is left out when there are none. A rectangle
28//! (game ADR-0018) is `layer  x,y wxh  colour`. A sprite (game ADR-0038) is
29//! `layer  sprite x,y wxh  image x,y wxh`: where it is drawn (`dest`), the
30//! image's path in the asset bundle, and the part of the image shown
31//! (`src`). After that come, only when they aren't the default, ` flip`,
32//! ` opacity=N` (below 255), ` bright=N` (below 255: darkened, game ADR-0075),
33//! ` solid=colour`, ` dimmed` or
34//! ` shade=grey:red,green,blue` (how its pixels are coloured, game ADR-0049; a
35//! shade is a light's numbers in thousandths, game ADR-0058) and
36//! ` clip=x,y wxh` (not all of `dest` is drawn). A painted picture
37//! (ADR-0078) is `layer  painted x,y wxh  id WxH kind rN hash`: where it is
38//! drawn (`dest`), the picture's id, its size, `rgba`, `indexed` or
39//! `one-bit`, its revision and the FNV hash of its pixels, palette and mask
40//! (sixteen hex digits), so a test sees it changed without reading it;
41//! `id missing` when the frame's table lacks it. After that come
42//! ` src=x,y wxh` (not the whole picture), ` bright=N` and ` clip=x,y wxh`,
43//! only when they aren't the default.
44//!
45//! A gizmo of the debug view (ADR-0085) is `over  gizmo channel role` and
46//! its shape: `rect x,y wxh`, `line x,y x,y` (from, to), `cross x,y n`
47//! (centre, arm), `circle x,y r` (centre, radius) or `label x,y "text"`
48//! (the pixel it was placed at, and its text). After that comes
49//! ` clip=x,y wxh` when not all of the shape is drawn (never for a label,
50//! whose glyphs are in the cells):
51//!
52//! ```text
53//! over  gizmo grid bounds line 0,0 0,159
54//! over  gizmo grid note rect 32,48 16x16
55//! over  gizmo grid note label 32,48 "0"
56//! ```
57//!
58//! A see-through cell (game ADR-0048) gets a key of its own, whose legend line
59//! says `bg:see-through`: in the colour rows a hole can't be taken for a
60//! blank cell. When the buffer has a backdrop, a line
61//! `--- backdrop: clip x,y wxh  origin x,y  zoom N ---` follows (the window
62//! in console cells, the scene pixel at its top-left to a tenth of a pixel,
63//! the zoom) and then the scene's own snapshot, in this same format.
64
65use crate::prelude::*;
66use alloc::collections::BTreeMap;
67use core::fmt::Write as _;
68
69use crate::frame::color::{Palette, Rgb};
70use crate::frame::gizmo::{GizmoItem, GizmoShape};
71use crate::frame::glyph_buffer::Cell;
72use crate::frame::glyph_buffer::{Backdrop, GlyphBuffer, Item, Paint, PxRect, Sprite};
73use crate::frame::painted::{PaintedSprite, PaintedTable};
74
75/// What a see-through cell's background is called in the legend.
76const SEE_THROUGH: &str = "see-through";
77
78/// Shown instead of control characters, which would break the row layout.
79const CONTROL_GLYPH: char = '\u{fffd}';
80
81impl GlyphBuffer {
82    /// Renders the buffer in the snapshot format described in [`crate::frame::snapshot`].
83    pub fn to_snapshot(&self, palette: &Palette) -> String {
84        self.snapshot_with(palette, self.pictures())
85    }
86
87    /// [`to_snapshot`](Self::to_snapshot), its painted items showing
88    /// `pictures`, the frame's (also for a backdrop's scene).
89    fn snapshot_with(&self, palette: &Palette, pictures: &PaintedTable) -> String {
90        let (w, h) = (i32::from(self.width()), i32::from(self.height()));
91        let cells = || (0..h).flat_map(move |y| (0..w).filter_map(move |x| self.get(x, y)));
92        // A see-through cell's `bg` isn't shown, so it isn't part of its key.
93        let pair = |c: &Cell| (c.fg, (!c.see_through).then_some(c.bg));
94        let mut keys: BTreeMap<(Rgb, Option<Rgb>), char> = BTreeMap::new();
95        let mut legend: Vec<(char, Rgb, Option<Rgb>)> = Vec::new();
96        for c in cells() {
97            let (fg, bg) = pair(c);
98            keys.entry((fg, bg)).or_insert_with(|| {
99                let k = key(legend.len());
100                legend.push((k, fg, bg));
101                k
102            });
103        }
104        let per_row = usize::from(self.width()).max(1);
105        let mut out = String::new();
106        for (i, c) in cells().enumerate() {
107            if c.is_continuation() {
108                // Drawn by the wide character before it.
109            } else if c.glyph.is_control() {
110                out.push(CONTROL_GLYPH);
111            } else {
112                out.push(c.glyph);
113            }
114            if (i + 1) % per_row == 0 {
115                out.push('\n');
116            }
117        }
118        out.push_str("--- colours ---\n");
119        for (i, c) in cells().enumerate() {
120            out.push(keys[&pair(c)]);
121            if (i + 1) % per_row == 0 {
122                out.push('\n');
123            }
124        }
125        out.push_str("--- legend ---\n");
126        let name = |rgb: Rgb| {
127            palette
128                .name_of(rgb)
129                .map_or_else(|| rgb.to_hex(), str::to_owned)
130        };
131        for (k, fg, bg) in legend {
132            let bg = bg.map_or_else(|| SEE_THROUGH.to_owned(), name);
133            let _ = writeln!(out, "{k} = fg:{} bg:{bg}", name(fg));
134        }
135        if !self.items().is_empty() {
136            out.push_str("--- overlays ---\n");
137        }
138        for item in self.items() {
139            let layer = item.layer().name();
140            match item {
141                Item::Rect(o) => {
142                    let _ = writeln!(out, "{layer:<5} {}  {}", px(o.rect), name(o.color));
143                }
144                Item::Sprite(s) => {
145                    let _ = writeln!(out, "{layer:<5} {}", sprite_line(s, &name));
146                }
147                Item::Painted(p) => {
148                    let _ = writeln!(out, "{layer:<5} {}", painted_line(p, pictures));
149                }
150                Item::Gizmo(g) => {
151                    let _ = writeln!(out, "{layer:<5} {}", gizmo_line(g));
152                }
153            }
154        }
155        if let Some(backdrop) = self.backdrop() {
156            let _ = writeln!(out, "--- backdrop: {} ---", backdrop_line(backdrop));
157            out.push_str(&backdrop.scene().snapshot_with(palette, pictures));
158        }
159        out
160    }
161}
162
163/// A backdrop's header: its window, origin and zoom.
164fn backdrop_line(backdrop: &Backdrop) -> String {
165    let (x, y) = backdrop.origin_px();
166    format!(
167        "clip {}  origin {},{}  zoom {}",
168        px(backdrop.clip()),
169        tenths(x),
170        tenths(y),
171        backdrop.zoom()
172    )
173}
174
175/// `v` to a tenth, never `-0.0`.
176fn tenths(v: f32) -> String {
177    let rounded = (v * 10.0).round() / 10.0;
178    // Adding zero turns a negative zero into a positive one.
179    format!("{:.1}", rounded + 0.0)
180}
181
182/// `x,y wxh`.
183fn px(r: PxRect) -> String {
184    format!("{},{} {}x{}", r.x, r.y, r.w, r.h)
185}
186
187/// A gizmo's snapshot line, after the layer.
188pub(crate) fn gizmo_line(g: &GizmoItem) -> String {
189    let point = |(x, y): (i32, i32)| format!("{x},{y}");
190    let shape = match g.shape {
191        GizmoShape::Rect { rect } => format!("rect {}", px(rect)),
192        GizmoShape::Line { from, to } => format!("line {} {}", point(from), point(to)),
193        GizmoShape::Cross { at, size } => format!("cross {} {size}", point(at)),
194        GizmoShape::Circle { center, radius } => format!("circle {} {radius}", point(center)),
195        GizmoShape::Label { at, text } => format!("label {} {:?}", point(at), text.as_str()),
196    };
197    let mut line = format!("gizmo {} {} {shape}", g.channel, g.role.name());
198    let label = matches!(g.shape, GizmoShape::Label { .. });
199    if !label && g.clip != g.shape.bounds() {
200        let _ = write!(line, " clip={}", px(g.clip));
201    }
202    line
203}
204
205/// A sprite's snapshot line, after the layer; `name` names a colour.
206fn sprite_line(s: &Sprite, name: &dyn Fn(Rgb) -> String) -> String {
207    let mut line = format!("sprite {}  {} {}", px(s.dest), s.image.path(), px(s.src));
208    if s.flip_x {
209        line.push_str(" flip");
210    }
211    if s.opacity != u8::MAX {
212        let _ = write!(line, " opacity={}", s.opacity);
213    }
214    if s.brightness != u8::MAX {
215        let _ = write!(line, " bright={}", s.brightness);
216    }
217    match s.paint {
218        Paint::Image => {}
219        Paint::Solid(color) => {
220            let _ = write!(line, " solid={}", name(color));
221        }
222        Paint::Dimmed => line.push_str(" dimmed"),
223        Paint::Shaded(light) => {
224            let [r, g, b] = light.tint;
225            let _ = write!(line, " shade={}:{r},{g},{b}", light.grey);
226        }
227    }
228    if s.clip != s.dest {
229        let _ = write!(line, " clip={}", px(s.clip));
230    }
231    line
232}
233
234/// A painted picture's snapshot line, after the layer, its picture from
235/// `pictures`.
236fn painted_line(p: &PaintedSprite, pictures: &PaintedTable) -> String {
237    let mut line = format!("painted {}  {}", px(p.dest), p.picture);
238    let picture = pictures.get(p.picture);
239    if let Some(picture) = picture {
240        let (w, h) = picture.size();
241        let _ = write!(
242            line,
243            " {w}x{h} {} r{} {:016x}",
244            picture.kind().name(),
245            picture.revision(),
246            picture.fingerprint()
247        );
248    } else {
249        line.push_str(" missing");
250    }
251    let whole = picture.map(|picture| {
252        let (w, h) = picture.size();
253        PxRect::new(0, 0, w.into(), h.into())
254    });
255    if whole != Some(p.src) {
256        let _ = write!(line, " src={}", px(p.src));
257    }
258    if p.brightness != u8::MAX {
259        let _ = write!(line, " bright={}", p.brightness);
260    }
261    if p.clip != p.dest {
262        let _ = write!(line, " clip={}", px(p.clip));
263    }
264    line
265}
266
267/// The `n`th legend key.
268fn key(n: usize) -> char {
269    const KEYS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
270    if let Some(&k) = KEYS.get(n) {
271        return char::from(k);
272    }
273    // Beyond 62 pairs: CJK ideographs, a contiguous run of 20k+ printable
274    // chars. Past that (never reached: a buffer holds at most 65535² cells
275    // but a real screen far fewer colours) the key repeats as '?'.
276    u32::try_from(n - KEYS.len())
277        .ok()
278        .and_then(|i| i.checked_add(0x4e00))
279        .filter(|&c| c <= 0x9fff)
280        .and_then(char::from_u32)
281        .unwrap_or('?')
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::frame::color::EngineColor;
288    use crate::frame::color::tests::engine_palette;
289    use crate::frame::glyph_buffer::{Cell, Rect};
290
291    #[test]
292    fn keys_sequence() {
293        assert_eq!(key(0), 'a');
294        assert_eq!(key(25), 'z');
295        assert_eq!(key(26), 'A');
296        assert_eq!(key(51), 'Z');
297        assert_eq!(key(52), '0');
298        assert_eq!(key(61), '9');
299        assert_eq!(key(62), '\u{4e00}');
300        assert_eq!(key(63), '\u{4e01}');
301        assert_eq!(key(62 + 0x9fff - 0x4e00), '\u{9fff}');
302        assert_eq!(key(62 + 0x9fff - 0x4e00 + 1), '?');
303        assert_eq!(key(usize::MAX), '?');
304    }
305
306    #[test]
307    fn exact_format_small() {
308        let p = engine_palette();
309        let text = p.get(EngineColor::Text);
310        let bg = p.get(EngineColor::Panel);
311        let odd = Rgb::new(1, 2, 3);
312        let mut b = GlyphBuffer::new(3, 2, Cell::new(' ', text, bg));
313        b.print(0, 0, "hi", odd, bg);
314        b.set(2, 1, Cell::new('\n', text, odd));
315        assert_eq!(
316            b.to_snapshot(&p),
317            "hi \n  \u{fffd}\n\
318             --- colours ---\n\
319             aab\nbbc\n\
320             --- legend ---\n\
321             a = fg:#010203 bg:panel\n\
322             b = fg:text bg:panel\n\
323             c = fg:text bg:#010203\n"
324        );
325    }
326
327    #[test]
328    fn a_continuation_prints_as_nothing() {
329        let p = engine_palette();
330        let text = p.get(EngineColor::Text);
331        let widths = shuyi_assets::font::CellWidths::wide(['あ']);
332        let mut b = GlyphBuffer::new(4, 1, Cell::new(' ', text, text)).with_widths(widths);
333        b.print(0, 0, "あA", text, text);
334        assert_eq!(
335            b.to_snapshot(&p),
336            "あA \n--- colours ---\naaaa\n--- legend ---\na = fg:text bg:text\n"
337        );
338    }
339
340    #[test]
341    fn overlays_are_listed_after_the_legend() {
342        use crate::frame::glyph_buffer::{Layer, Overlay};
343        let p = engine_palette();
344        let text = p.get(EngineColor::Text);
345        let mut b = GlyphBuffer::new(3, 1, Cell::new(' ', text, text));
346        b.add_overlay(Overlay::new(
347            Rect::new(18, 14, 5, 2),
348            p.get(EngineColor::Warning),
349            Layer::Over,
350        ));
351        b.add_overlay(Overlay::new(
352            Rect::new(0, 7, 24, 3),
353            Rgb::new(1, 2, 3),
354            Layer::Under,
355        ));
356        assert_eq!(
357            b.to_snapshot(&p),
358            "   \n\
359             --- colours ---\n\
360             aaa\n\
361             --- legend ---\n\
362             a = fg:text bg:text\n\
363             --- overlays ---\n\
364             over  18,14 5x2  warning\n\
365             under 0,7 24x3  #010203\n"
366        );
367    }
368
369    #[test]
370    fn sprites_are_listed_with_the_rectangles_in_drawing_order() {
371        use crate::frame::glyph_buffer::{Layer, Overlay};
372        let p = engine_palette();
373        let text = p.get(EngineColor::Text);
374        let images = shuyi_assets::image::ImageTable::new(
375            [(
376                "images/test_card.png",
377                shuyi_assets::image::ImageInfo::new(16, 16),
378            )]
379            .into(),
380        );
381        let card = images.id("images/test_card.png").unwrap();
382        let src = Rect::new(0, 0, 16, 16);
383        let mut b = GlyphBuffer::new(20, 6, Cell::new(' ', text, text));
384        let plain = Sprite::new(card, src, Rect::new(8, 8, 80, 80), Layer::Over);
385        b.add_sprite(plain);
386        b.add_overlay(Overlay::new(Rect::new(0, 7, 24, 3), text, Layer::Under));
387        b.add_sprite(Sprite {
388            flip_x: true,
389            ..Sprite::new(
390                card,
391                Rect::new(8, 0, 8, 4),
392                Rect::new(0, 0, 16, 8),
393                Layer::Under,
394            )
395        });
396        b.add_sprite(Sprite {
397            opacity: 128,
398            ..plain
399        });
400        b.add_sprite(Sprite {
401            clip: Rect::new(8, 8, 40, 80),
402            ..plain
403        });
404        b.add_sprite(Sprite {
405            flip_x: true,
406            opacity: 0,
407            clip: Rect::new(48, 40, 80, 80),
408            ..plain
409        });
410        b.add_sprite(plain.painted(Paint::Solid(p.get(EngineColor::Warning))));
411        b.add_sprite(plain.painted(Paint::Solid(Rgb::new(1, 2, 3))));
412        b.add_sprite(Sprite {
413            opacity: 128,
414            clip: Rect::new(8, 8, 40, 80),
415            ..plain.painted(Paint::Dimmed)
416        });
417        let dusk = shuyi_assets::lighting::Light::new(0.35, [0.62, 0.56, 0.46]);
418        b.add_sprite(plain.painted(Paint::Shaded(dusk)));
419        b.add_sprite(Sprite {
420            brightness: 102,
421            ..plain
422        });
423        assert_eq!(
424            b.to_snapshot(&p)
425                .split_once("--- overlays ---\n")
426                .unwrap()
427                .1,
428            "over  sprite 8,8 80x80  images/test_card.png 0,0 16x16\n\
429             under 0,7 24x3  text\n\
430             under sprite 0,0 16x8  images/test_card.png 8,0 8x4 flip\n\
431             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 opacity=128\n\
432             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 clip=8,8 40x80\n\
433             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 flip opacity=0 clip=48,40 40x48\n\
434             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 solid=warning\n\
435             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 solid=#010203\n\
436             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 opacity=128 dimmed clip=8,8 40x80\n\
437             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 shade=350:620,560,460\n\
438             over  sprite 8,8 80x80  images/test_card.png 0,0 16x16 bright=102\n"
439        );
440    }
441
442    #[test]
443    fn see_through_cells_get_their_own_key() {
444        let p = engine_palette();
445        let text = p.get(EngineColor::Text);
446        let black = p.get(EngineColor::Panel);
447        let mut b = GlyphBuffer::new(4, 1, Cell::new(' ', text, black));
448        b.set(1, 0, Cell::see_through(' ', text));
449        b.set(2, 0, Cell::see_through('x', text));
450        // The hidden background isn't part of the key.
451        let mut dimmed = Cell::see_through(' ', text);
452        dimmed.bg = text;
453        b.set(3, 0, dimmed);
454        assert_eq!(
455            b.to_snapshot(&p),
456            "  x \n\
457             --- colours ---\n\
458             abbb\n\
459             --- legend ---\n\
460             a = fg:text bg:panel\n\
461             b = fg:text bg:see-through\n"
462        );
463    }
464
465    #[test]
466    fn a_backdrop_is_a_header_line_and_the_scenes_own_snapshot() {
467        use alloc::rc::Rc;
468        let p = engine_palette();
469        let text = p.get(EngineColor::Text);
470        let black = p.get(EngineColor::Panel);
471        let mut scene = GlyphBuffer::new(2, 1, Cell::new('s', text, black));
472        scene.add_overlay(crate::frame::glyph_buffer::Overlay::new(
473            Rect::new(0, 0, 3, 2),
474            text,
475            crate::frame::glyph_buffer::Layer::Over,
476        ));
477        let scene = Rc::new(scene);
478        let mut b = GlyphBuffer::new(3, 2, Cell::new(' ', text, black));
479        b.set_backdrop(Rc::clone(&scene), Rect::new(1, 0, 5, 2), (12.34, -0.04), 3);
480        let snapshot = b.to_snapshot(&p);
481        let plain = GlyphBuffer::new(3, 2, Cell::new(' ', text, black)).to_snapshot(&p);
482        assert_eq!(
483            snapshot,
484            format!(
485                "{plain}--- backdrop: clip 1,0 2x2  origin 12.3,0.0  zoom 3 ---\n{}",
486                scene.to_snapshot(&p)
487            )
488        );
489        assert!(snapshot.ends_with("--- overlays ---\nover  0,0 3x2  text\n"));
490        assert!(!plain.contains("backdrop"));
491    }
492
493    #[test]
494    fn tenths_round_and_never_print_a_negative_zero() {
495        assert_eq!(tenths(0.0), "0.0");
496        assert_eq!(tenths(-0.0), "0.0");
497        assert_eq!(tenths(-0.04), "0.0");
498        assert_eq!(tenths(-0.06), "-0.1");
499        assert_eq!(tenths(12.34), "12.3");
500        assert_eq!(tenths(12.36), "12.4");
501        assert_eq!(tenths(-80.0), "-80.0");
502    }
503
504    #[test]
505    fn empty_buffers() {
506        let p = engine_palette();
507        let blank = Cell::new(' ', Rgb::new(0, 0, 0), Rgb::new(0, 0, 0));
508        let expected = "--- colours ---\n--- legend ---\n";
509        assert_eq!(GlyphBuffer::new(0, 0, blank).to_snapshot(&p), expected);
510        assert_eq!(GlyphBuffer::new(0, 3, blank).to_snapshot(&p), expected);
511        assert_eq!(GlyphBuffer::new(3, 0, blank).to_snapshot(&p), expected);
512    }
513
514    #[test]
515    fn many_pairs_get_distinct_keys() {
516        let p = engine_palette();
517        let mut b = GlyphBuffer::new(70, 1, Cell::new(' ', Rgb::new(0, 0, 0), Rgb::new(0, 0, 0)));
518        for x in 0..70u8 {
519            b.set(
520                i32::from(x),
521                0,
522                Cell::new('.', Rgb::new(x, 0, 7), Rgb::new(0, 0, 7)),
523            );
524        }
525        let snap = b.to_snapshot(&p);
526        let keys_row = snap.lines().nth(2).unwrap();
527        let keys: alloc::collections::BTreeSet<char> = keys_row.chars().collect();
528        assert_eq!(keys.len(), 70);
529        assert!(keys_row.starts_with("abc"));
530        assert_eq!(snap.lines().count(), 1 + 1 + 1 + 1 + 70);
531    }
532}