Skip to main content

yog_cli/design/
images.rs

1//! `<Image>` in the designer: Yog image sources (`item:apple`,
2//! `sprite:button`, `mod:gui/logo`, `mymod:gui/logo` — see
3//! `yog_gfx::image`) resolved against the local game files and the mod's
4//! `assets/`, the way the Yog runtime resolves them in game: yog-ui's
5//! `Image` widget lays out and fits the picture, then calls `draw2d_image`
6//! / `image_size`, which the recording table (`gfx`) answers from here.
7//! Also the Properties window's `source` picker ([`candidates`],
8//! [`preview`]).
9
10use crate::content::refs::RefKind;
11use std::cell::RefCell;
12use std::path::{Path, PathBuf};
13use yog_sync::Arc;
14
15use yog_gfx::image::{
16    ImageSource, LegacyRegion, SPRITES, Sprite, SpriteSource, Stretch, atlas_texture, fit,
17    sprite as find_sprite,
18};
19use yog_gfx::{Color, Rect, Vec2};
20use yog_ui::widget::{self, Widget};
21
22use super::assets::{Image, Resources};
23use super::markup::{YuiNode, color};
24use super::raster::Canvas;
25
26struct Doc {
27    res: Resources,
28    mod_id: Option<String>,
29}
30
31thread_local! {
32    static DOC: RefCell<Option<Doc>> = const { RefCell::new(None) };
33}
34
35/// `[mod] id` from `<root>/yog.toml`.
36/// The `.yui` being laid out and drawn: its mod's resources (images, fonts)
37/// and id (for `mod:` sources). Set before every layout.
38pub fn set_document(mod_root: Option<&Path>) {
39    let doc = Doc {
40        res: Resources::for_mod(mod_root),
41        mod_id: mod_root.and_then(crate::design::project::mod_id),
42    };
43    DOC.with(|d| *d.borrow_mut() = Some(doc));
44}
45
46/// The current document's resources (vanilla only without one).
47pub fn resources() -> Resources {
48    DOC.with(|d| d.borrow().as_ref().map(|d| d.res.clone()))
49        .unwrap_or_else(|| Resources::for_mod(None))
50}
51
52fn with_doc<R>(f: impl FnOnce(Option<&Doc>) -> R) -> R {
53    DOC.with(|d| f(d.borrow().as_ref()))
54}
55
56/// `mod:<path>` → `<mod id>:<path>` (what `yog build` writes into the
57/// generated code); other sources unchanged.
58pub fn resolve_mod(source: &str) -> String {
59    let source = source.trim();
60    match source.strip_prefix("mod:") {
61        Some(path) => match with_doc(|d| d.and_then(|d| d.mod_id.clone())) {
62            Some(id) => format!("{id}:{path}"),
63            None => source.to_string(),
64        },
65        None => source.to_string(),
66    }
67}
68
69/// `stretch` attribute → [`Stretch`] (case-insensitive, like `yog build`).
70pub fn stretch(v: &str) -> Stretch {
71    Stretch::parse(v).unwrap_or_default()
72}
73
74/// `uv="u v w h"`.
75fn uv(v: &str) -> Option<[f32; 4]> {
76    let p: Vec<f32> = v
77        .split([' ', ','])
78        .filter(|s| !s.is_empty())
79        .map(|s| s.parse().ok())
80        .collect::<Option<_>>()?;
81    <[f32; 4]>::try_from(p)
82        .ok()
83        .filter(|r| r[2] > 0.0 && r[3] > 0.0)
84}
85
86fn rect([x, y, w, h]: [f32; 4]) -> Rect {
87    Rect::new(x, y, w, h)
88}
89
90/// The widget for an `<Image>` — what the generated code builds: `source`,
91/// `stretch`, `uv`, `tint`.
92pub fn widget(node: &YuiNode) -> Widget {
93    let a = |k: &str| node.attr(k);
94    let source = a(yog_ids::yui_attr::SOURCE).unwrap_or_default();
95    let mut w = widget::image(resolve_mod(source));
96    if let Some(s) = a(yog_ids::yui_attr::STRETCH) {
97        w = w.stretch(stretch(s));
98    }
99    if let Some(r) = a(yog_ids::yui_attr::UV).and_then(uv) {
100        w = w.region(rect(r));
101    }
102    if let Some(t) = a(yog_ids::yui_attr::TINT).and_then(color) {
103        w = w.tint(Color(t));
104    }
105    w
106}
107
108fn texture(id: &str) -> Option<Arc<Image>> {
109    if let Some(sheet) = book_sheet(id) {
110        return Some(sheet);
111    }
112    with_doc(|d| d.and_then(|d| d.res.texture(id)))
113}
114
115/// The book's sprite sheets — shipped in the loader's jars, embedded in
116/// yog-book (see `yog_book::BOOK_SHEET`).
117fn book_sheet(id: &str) -> Option<Arc<Image>> {
118    static SHEETS: yog_sync::OnceLock<[Option<Arc<Image>>; 2]> = yog_sync::OnceLock::new();
119    let [book, crafting] = SHEETS.get_or_init(|| {
120        [
121            super::assets::decode_png(yog_book::BOOK_PNG).map(Arc::new),
122            super::assets::decode_png(yog_book::CRAFTING_PNG).map(Arc::new),
123        ]
124    });
125    let texture = |sheet: &str| match ImageSource::parse(sheet) {
126        Some(ImageSource::Texture(t)) => t,
127        _ => String::new(),
128    };
129    match id {
130        _ if id == texture(yog_book::BOOK_SHEET) => book.clone(),
131        _ if id == texture(yog_book::CRAFTING_SHEET) => crafting.clone(),
132        _ => None,
133    }
134}
135
136/// A GUI-atlas sprite's picture (`minecraft:widget/button` → its png).
137fn atlas_image(id: &str) -> Option<Arc<Image>> {
138    let (ns, path) = yog_ids::split(id);
139    texture(&atlas_texture(ns, path))
140}
141
142/// The source of `sp` the game files (or Yog) have — the first, as the host picks it.
143fn resolve(sp: &Sprite) -> Option<SpriteSource<'_>> {
144    sp.sources().into_iter().find(|s| match s {
145        SpriteSource::Atlas(id) => atlas_image(id).is_some(),
146        SpriteSource::Region(r) => texture(&r.texture).is_some(),
147    })
148}
149
150/// `id` as the game files the designer reads call it: itself, or what
151/// that version renamed it to (`minecraft:chain` is `iron_chain` from 1.21.9).
152fn in_game_files(kind: RefKind, id: String) -> String {
153    let version = with_doc(|d| d.and_then(|d| d.res.vanilla.as_ref().map(|v| v.version.clone())));
154    version
155        .and_then(|v| crate::content::refs::equivalent(kind, &id, &v))
156        .unwrap_or(id)
157}
158
159/// The texture an item/block icon is drawn with (the designer doesn't run
160/// the 3D item renderer: block items show a face of the block).
161fn item_texture(id: &str, block: bool) -> Option<Arc<Image>> {
162    // Its item model's layers, when it has some.
163    if !block
164        && let Some(super::item_models::Look::Flat(img)) =
165            with_doc(|d| d.and_then(|d| super::item_models::look(&d.res, id)))
166    {
167        return Some(img);
168    }
169    let (ns, name) = yog_ids::split(id);
170    let item = format!("{ns}:textures/item/{name}.png");
171    let blocks =
172        ["", "_front", "_side", "_top"].map(|s| format!("{ns}:textures/block/{name}{s}.png"));
173    let mut order: Vec<&String> = blocks.iter().collect();
174    if block {
175        order.push(&item);
176    } else {
177        order.insert(0, &item);
178    }
179    order
180        .into_iter()
181        .find_map(|t| texture(t))
182        .or_else(|| content_icon(id))
183}
184
185/// A mod item's icon from its `content/` file (cached per id).
186fn content_icon(id: &str) -> Option<Arc<Image>> {
187    type Cache = yog_sync::Mutex<std::collections::HashMap<(PathBuf, String), Option<Arc<Image>>>>;
188    static CACHE: yog_sync::OnceLock<Cache> = yog_sync::OnceLock::new();
189    let root = with_doc(|d| d.and_then(|d| d.res.mod_root.clone()))?;
190    let key = (root.clone(), id.to_string());
191    let cache = CACHE.get_or_init(Default::default);
192    if let Some(hit) = cache.lock().get(&key) {
193        return hit.clone();
194    }
195    let img = crate::design::content::content_icon(&root, id)
196        .and_then(|b| super::assets::decode_png(&b))
197        .map(Arc::new);
198    cache.lock().insert(key, img.clone());
199    img
200}
201
202/// A block item as the game shows it in a slot (the block in 3D, turned
203/// 225°, tilted 30°), or a head (its mob's, a little smaller). `false`:
204/// a flat item. As in the game, the item's model decides: one drawn from
205/// texture layers is flat even when there's a block of the same id (a
206/// brewing stand, a door, a cauldron).
207fn item_3d(c: &mut Canvas, id: &str, [x, y, w, h]: [f32; 4]) -> bool {
208    match super::item_models::look(c.resources(), id) {
209        Some(super::item_models::Look::Flat(_)) => false,
210        Some(super::item_models::Look::Head(faces)) => {
211            let m = w.min(h) * HEAD_MARGIN;
212            super::blocks3d::draw_cube(
213                c,
214                faces,
215                [x + m, y + m, w - 2.0 * m, h - 2.0 * m],
216                SLOT_YAW,
217                SLOT_PITCH,
218            );
219            true
220        }
221        None if super::blocks3d::is_block(c.resources(), id) => {
222            super::blocks3d::draw(
223                c,
224                &format!("0 0 0 {id}"),
225                [x, y, w, h],
226                SLOT_YAW,
227                SLOT_PITCH,
228            );
229            true
230        }
231        None => false,
232    }
233}
234
235/// How a block is turned and tilted in a slot (the game's GUI display; the book's).
236use yog_book::renderer::{BLOCK_PITCH as SLOT_PITCH, SLOT_YAW};
237/// How much smaller than the slot a head is, on each side.
238const HEAD_MARGIN: f32 = 0.1;
239
240/// Natural size of `source` in GUI pixels (`image_size`); `None` when it
241/// can't be resolved.
242pub fn source_size(source: &str) -> Option<(f32, f32)> {
243    match ImageSource::parse(source)? {
244        ImageSource::Item(_) | ImageSource::Block(_) => {
245            Some((yog_ui::ICON_SIZE, yog_ui::ICON_SIZE))
246        }
247        ImageSource::Sprite(name) => find_sprite(&name).map(|s| match resolve(s) {
248            Some(SpriteSource::Region(r)) => (r.region.w, r.region.h),
249            _ => (s.size.x, s.size.y),
250        }),
251        ImageSource::Texture(id) if id.ends_with(".svg") => {
252            svg_document(&id).and_then(|d| yog_media::svg::size(&d))
253        }
254        ImageSource::Texture(id) => texture(&id).map(|i| (i.width as f32, i.height as f32)),
255        ImageSource::ModPath(_) => None,
256    }
257}
258
259/// `0xAARRGGBB` → RGBA multipliers.
260fn tint(c: u32) -> [f32; 4] {
261    Color(c).to_f32()
262}
263
264/// `img` pixels `src` (`u v w h`) into GUI `dest` (`x y w h`).
265fn blit_px(c: &mut Canvas, img: &Image, dest: [f32; 4], src: [f32; 4], t: u32) {
266    let [x, y, w, h] = dest;
267    let [u, v, uw, vh] = src;
268    if w <= 0.0 || h <= 0.0 || uw <= 0.0 || vh <= 0.0 {
269        return;
270    }
271    let (iw, ih) = (img.width as f32, img.height as f32);
272    c.blit(
273        img,
274        [x, y, w, h],
275        [u / iw, v / ih, (u + uw) / iw, (v + vh) / ih],
276        tint(t),
277    );
278}
279
280/// The inventory slot (drawn by Yog on every version).
281fn slot(c: &mut Canvas, [x, y, w, h]: [f32; 4]) {
282    for (r, color) in yog_ui::inv_slot_bevel(yog_gfx::Rect::new(x, y, w, h)) {
283        c.fill([r.x, r.y, r.x + r.w, r.y + r.h], color.0, color.0);
284    }
285}
286
287/// Sprite `sp` into `dest`: nine-sliced by its border, or — `part` a region
288/// of it in its own pixels (`w`, `h` > 0) — that part, scaled.
289fn sprite(c: &mut Canvas, sp: &Sprite, dest: [f32; 4], part: [f32; 4], t: u32) {
290    let whole = part[2] <= 0.0 || part[3] <= 0.0;
291    // `src` (a region of the image whose sprite is `natural` pixels wide) drawn whole or in part.
292    let mut put = |img: &Image, src: [f32; 4], natural: f32, border: f32| {
293        if whole {
294            for (to, from) in yog_gfx::image::nine_slice(dest, src, border) {
295                blit_px(c, img, to, from, t);
296            }
297        } else {
298            let k = src[2] / natural;
299            blit_px(
300                c,
301                img,
302                dest,
303                [
304                    src[0] + part[0] * k,
305                    src[1] + part[1] * k,
306                    part[2] * k,
307                    part[3] * k,
308                ],
309                t,
310            );
311        }
312    };
313    if sp.is_procedural() {
314        slot(c, dest);
315        return;
316    }
317    let source = resolve(sp);
318    if let Some(SpriteSource::Atlas(id)) = &source
319        && let Some(img) = atlas_image(id)
320    {
321        // Animated sprites stack their frames vertically: the first one.
322        let size = sp.size;
323        let frame_h = if img.height > img.width && size.y <= size.x {
324            img.width as f32 * size.y / size.x
325        } else {
326            img.height as f32
327        };
328        put(
329            &img,
330            [0.0, 0.0, img.width as f32, frame_h],
331            size.x,
332            sp.border,
333        );
334        return;
335    }
336    if let Some(SpriteSource::Region(LegacyRegion {
337        texture: tex,
338        region,
339        texture_size,
340    })) = source
341        && let Some(img) = texture(tex)
342    {
343        // Resource packs may ship the sheet at a higher resolution.
344        let k = img.width as f32 / texture_size.x;
345        put(
346            &img,
347            [region.x * k, region.y * k, region.w * k, region.h * k],
348            region.w,
349            sp.border * k,
350        );
351        return;
352    }
353    missing(c, dest);
354}
355
356fn missing(c: &mut Canvas, dest: [f32; 4]) {
357    blit_px(
358        c,
359        &super::assets::missing_image(),
360        dest,
361        [0.0, 0.0, yog_ui::ICON_SIZE, yog_ui::ICON_SIZE],
362        Color::WHITE.0,
363    );
364}
365
366/// Physical pixels an SVG is rasterized at per GUI pixel.
367const SVG_OVERSAMPLE: f32 = 4.0;
368
369/// An SVG document as an image, `w`×`h` — as the runtime rasterizes it in game.
370fn svg_image(data: &str, w: u32, h: u32) -> Option<Image> {
371    Some(Image {
372        width: w,
373        height: h,
374        rgba: yog_media::svg::rasterize(data, w, h)?,
375    })
376}
377
378/// The document of an `.svg` texture id (`ns:textures/x.svg`) of the mod.
379fn svg_document(id: &str) -> Option<String> {
380    let (ns, path) = yog_ids::split(id);
381    let bytes = with_doc(|d| d.and_then(|d| d.res.read(ns, path)))?;
382    String::from_utf8(bytes).ok()
383}
384
385/// `draw2d_svg`: `data` into `dest`.
386pub fn draw_svg(c: &mut Canvas, data: &str, dest: [f32; 4], t: u32) {
387    if dest[2] <= 0.0 || dest[3] <= 0.0 {
388        return;
389    }
390    let side = |v: f32| ((v * SVG_OVERSAMPLE).ceil() as u32).clamp(1, 2048);
391    match svg_image(data, side(dest[2]), side(dest[3])) {
392        Some(img) => blit_px(
393            c,
394            &img,
395            dest,
396            [0.0, 0.0, img.width as f32, img.height as f32],
397            t,
398        ),
399        None => missing(c, dest),
400    }
401}
402
403/// An entity's spawn egg is drawn at most this big (two icons).
404const ENTITY_EGG_MAX: f32 = 2.0 * yog_ui::ICON_SIZE;
405
406/// `draw2d_entity`: the designer has no entity models — the entity's spawn
407/// egg, in the middle of its box.
408pub fn draw_entity(c: &mut Canvas, entity: &str, [x, y, w, h]: [f32; 4]) {
409    let (ns, path) = yog_ids::split(entity);
410    let side = w.min(h).min(ENTITY_EGG_MAX);
411    let dest = [x + (w - side) / 2.0, y + (h - side) / 2.0, side, side];
412    let egg = yog_ids::join(ns, &format!("{path}{}", yog_ids::res::SPAWN_EGG_SUFFIX));
413    draw(
414        c,
415        &yog_ids::join(yog_ids::image_scheme::ITEM, &egg),
416        dest,
417        [0.0; 4],
418        Color::WHITE.0,
419    );
420}
421
422/// `draw2d_image`: `source` into `dest` (`x y w h`, already fitted by
423/// yog-ui); `region` `u v w h` in image pixels (`w <= 0`: whole image).
424pub fn draw(c: &mut Canvas, source: &str, dest: [f32; 4], region: [f32; 4], t: u32) {
425    if dest[2] <= 0.0 || dest[3] <= 0.0 {
426        return;
427    }
428    if let Some(ImageSource::Texture(id)) = ImageSource::parse(source)
429        && id.ends_with(".svg")
430    {
431        match svg_document(&id) {
432            Some(doc) => draw_svg(c, &doc, dest, t),
433            None => missing(c, dest),
434        }
435        return;
436    }
437    let first_frame = |img: &Image| [0.0, 0.0, img.width as f32, img.width.min(img.height) as f32];
438    // Content the game renamed is shown by its name in the game files at hand.
439    let source = match ImageSource::parse(source) {
440        Some(ImageSource::Item(id)) => Some(ImageSource::Item(in_game_files(RefKind::Item, id))),
441        Some(ImageSource::Block(id)) => Some(ImageSource::Block(in_game_files(RefKind::Block, id))),
442        other => other,
443    };
444    match source {
445        Some(ImageSource::Item(id)) if item_3d(c, &id, dest) => {}
446        Some(ImageSource::Item(id)) => match item_texture(&id, false) {
447            Some(img) => blit_px(c, &img, dest, first_frame(&img), Color::WHITE.0),
448            None => missing(c, dest),
449        },
450        Some(ImageSource::Block(id)) => match item_texture(&id, true) {
451            Some(img) => blit_px(c, &img, dest, first_frame(&img), Color::WHITE.0),
452            None => missing(c, dest),
453        },
454        Some(ImageSource::Sprite(name)) => match find_sprite(&name) {
455            Some(sp) => sprite(c, sp, dest, region, t),
456            None => match name
457                .split_once(':')
458                .and_then(|(ns, p)| texture(&atlas_texture(ns, p)))
459            {
460                Some(img) => blit_px(
461                    c,
462                    &img,
463                    dest,
464                    [0.0, 0.0, img.width as f32, img.height as f32],
465                    t,
466                ),
467                None => missing(c, dest),
468            },
469        },
470        Some(ImageSource::Texture(id)) => match texture(&id) {
471            Some(img) => {
472                let src = if region[2] > 0.0 && region[3] > 0.0 {
473                    region
474                } else {
475                    [0.0, 0.0, img.width as f32, img.height as f32]
476                };
477                blit_px(c, &img, dest, src, t);
478            }
479            None => missing(c, dest),
480        },
481        Some(ImageSource::ModPath(_)) | None => missing(c, dest),
482    }
483}
484
485/// An alias the Properties picker offers.
486#[derive(Debug, Clone, yog_data::Schema)]
487pub struct Candidate {
488    /// The source to write (`item:apple`, `sprite:button`, `mod:gui/logo`).
489    pub source: String,
490    /// `item`, `block`, `sprite` or `mod`.
491    pub group: String,
492    pub detail: String,
493}
494
495/// Everything the `source` picker offers for the `.yui` at `path`: the
496/// mod's own textures, the sprite table, then the game's items and blocks.
497pub fn candidates(path: &Path) -> Vec<Candidate> {
498    let root = super::mod_root(path);
499    let mut out = Vec::new();
500    if let Some(root) = &root {
501        out.extend(crate::media::pictures(root).into_iter().map(|p| Candidate {
502            source: ImageSource::ModPath(p.path()).to_string(),
503            group: "mod".into(),
504            detail: format!("@{}.{}", p.set, p.name),
505        }));
506    }
507    out.extend(SPRITES.iter().map(|s| Candidate {
508        source: format!("sprite:{}", s.name),
509        group: "sprite".into(),
510        detail: format!("{} ({}×{})", s.description, s.size.x, s.size.y),
511    }));
512    let res = Resources::for_mod(root.as_deref());
513    if let Some(v) = &res.vanilla {
514        let (items, blocks) = v.texture_names();
515        out.extend(items.into_iter().map(|n| Candidate {
516            source: yog_ids::join(yog_ids::image_scheme::ITEM, &n),
517            group: yog_ids::image_scheme::ITEM.into(),
518            detail: String::new(),
519        }));
520        out.extend(blocks.into_iter().map(|n| Candidate {
521            source: yog_ids::join(yog_ids::image_scheme::BLOCK, &n),
522            group: yog_ids::image_scheme::BLOCK.into(),
523            detail: String::new(),
524        }));
525    }
526    out
527}
528
529/// A preview of an image — the one way the IDE draws any image reference:
530/// its PNG data URL (`size` px, nearest-neighbour; `None` if it can't be
531/// drawn) and, for a raw resource path (`minecraft:textures/item/apple.png`),
532/// the alias to write instead (`item:apple`) — which is also what's drawn.
533#[derive(yog_data::Schema)]
534pub struct Preview {
535    pub url: Option<String>,
536    pub alias: Option<String>,
537}
538
539pub fn preview(path: &Path, source: &str, size: u32) -> Preview {
540    let alias = yog_gfx::image::suggest_alias(source);
541    let url = draw_preview(path, alias.as_deref().unwrap_or(source), size);
542    Preview { url, alias }
543}
544
545/// The smallest and largest square preview.
546const MIN_PREVIEW: u32 = 8;
547const MAX_PREVIEW: u32 = 256;
548/// An item icon's side, the size of a source that doesn't say its own.
549const ICON_SIDE: f32 = 16.0;
550
551fn draw_preview(path: &Path, source: &str, size: u32) -> Option<String> {
552    let root = super::mod_root(path);
553    set_document(root.as_deref());
554    let res = Resources::for_mod(root.as_deref());
555    let source = resolve_mod(source);
556    let (nw, nh) = source_size(&source).unwrap_or((ICON_SIDE, ICON_SIDE));
557    let size = size.clamp(MIN_PREVIEW, MAX_PREVIEW);
558    let (dest, _) = fit(
559        Stretch::Uniform,
560        Vec2::new(nw, nh),
561        Rect::new(0.0, 0.0, size as f32, size as f32),
562    );
563    let mut c = Canvas::new(size, size, 1, &res);
564    let [x, y, w, h] = [dest.x, dest.y, dest.w, dest.h];
565    draw(&mut c, &source, [x, y, w, h], [0.0; 4], Color::WHITE.0);
566    c.png_url()
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    /// `YOG_IMAGES_PNG=out.png cargo test --lib designer::images -- --ignored`:
574    /// every kind of source drawn with the local game files, for eyeballing.
575    #[test]
576    #[ignore]
577    fn sample_png() {
578        use base64::Engine as _;
579        let Ok(out) = std::env::var(yog_ids::env::IMAGES_PNG) else {
580            return;
581        };
582        let n = |uid: &str, attrs: &[(&str, &str)]| YuiNode {
583            uid: uid.into(),
584            kind: "Image".into(),
585            attrs: attrs
586                .iter()
587                .map(|(k, v)| (k.to_string(), v.to_string()))
588                .collect(),
589            children: vec![],
590        };
591        let mut root = n(
592            "s",
593            &[("width", "200"), ("height", "120"), ("bg", "#C0101010")],
594        );
595        root.kind = "Screen".into();
596        root.children = vec![
597            // w/h larger than the texture.
598            n(
599                "a",
600                &[
601                    ("x", "4"),
602                    ("y", "4"),
603                    ("w", "156"),
604                    ("h", "29"),
605                    ("source", "minecraft:item/apple"),
606                    ("bg", "#40FFFFFF"),
607                ],
608            ),
609            n(
610                "b",
611                &[
612                    ("x", "4"),
613                    ("y", "36"),
614                    ("w", "32"),
615                    ("h", "32"),
616                    ("source", "item:apple"),
617                ],
618            ),
619            n(
620                "c",
621                &[
622                    ("x", "40"),
623                    ("y", "36"),
624                    ("w", "32"),
625                    ("h", "32"),
626                    ("source", "block:crafting_table"),
627                ],
628            ),
629            n(
630                "d",
631                &[
632                    ("x", "76"),
633                    ("y", "36"),
634                    ("w", "100"),
635                    ("h", "20"),
636                    ("source", "sprite:button"),
637                    ("stretch", "Fill"),
638                ],
639            ),
640            n(
641                "e",
642                &[("x", "76"), ("y", "60"), ("source", "sprite:icon/heart")],
643            ),
644            n("f", &[("x", "90"), ("y", "60"), ("source", "sprite:slot")]),
645            n(
646                "g",
647                &[
648                    ("x", "4"),
649                    ("y", "72"),
650                    ("w", "64"),
651                    ("h", "40"),
652                    ("source", "item:diamond"),
653                    ("stretch", "Fill"),
654                    ("tint", "#FF80FF80"),
655                ],
656            ),
657        ];
658        let args = super::super::RenderArgs {
659            path: "/nowhere/src/x.yui".into(),
660            root,
661            screen_w: 200,
662            screen_h: 120,
663            scale: 4,
664            preview: false,
665        };
666        let r = super::super::render(&args).unwrap();
667        let b64 = r
668            .image
669            .trim_start_matches(super::super::raster::PNG_URL_PREFIX);
670        std::fs::write(
671            out,
672            base64::engine::general_purpose::STANDARD
673                .decode(b64)
674                .unwrap(),
675        )
676        .unwrap();
677        eprintln!("assets: {:?}", r.assets);
678    }
679}