Skip to main content

yog_cli/design/
content.rs

1//! The block/item model editor's backend (`.yblock` / `.yitem`, see
2//! `docs/blocks-items.md`): textures as the editor names them, the
3//! texture picker's list, vanilla/parent models for preview, and
4//! `yog import`.
5
6use yog_ids::Format;
7use crate::design::assets::{Resources};
8use crate::design::project::mod_root;
9use base64::Engine as _;
10use serde::Serialize;
11use serde_json::{Map, Value};
12use std::path::{Path, PathBuf};
13
14/// `[mod] id` of the mod at `root`.
15/// The id a content file's texture names: `@texture:<ns>:<path>`, or an id
16/// (`<ns>:<path>`; a bare path is the game's).
17fn texture_id(texture: &str) -> String {
18    yog_ids::qualify(texture.strip_prefix(yog_ids::reference::TEXTURE).unwrap_or(texture))
19}
20
21/// The png bytes of a texture as a content file names it: `@Set.name`,
22/// `path.png` (in `resources/`), or a texture id (`ns:block/x`).
23fn texture_bytes(file: &Path, texture: &str) -> Result<Vec<u8>, String> {
24    match texture_file(file, texture, "png") {
25        Ok(bytes) => Ok(bytes),
26        Err(original) => {
27            // A 1.20.2+ GUI sprite (`minecraft:gui/sprites/<atlas>`) on a game without the atlas: its 1.20.1 region.
28            let (ns, path) = yog_ids::split(texture);
29            let Some(legacy) = path
30                .strip_prefix(yog_ids::res::GUI_SPRITES)
31                .and_then(yog_gfx::image::sprite_by_atlas)
32                .and_then(|s| s.legacy.as_ref())
33                .filter(|_| ns == yog_ids::ns::MINECRAFT)
34            else {
35                return Err(original);
36            };
37            let sheet = texture_file(file, &yog_gfx::image::short_source(&legacy.texture), "png")?;
38            let r = legacy.region;
39            crop_png(&sheet, r.x as u32, r.y as u32, r.w as u32, r.h as u32).map_err(|e| format!("{original}; the 1.20.1 sprite: {e}"))
40        }
41    }
42}
43
44fn crop_png(bytes: &[u8], x: u32, y: u32, width: u32, height: u32) -> Result<Vec<u8>, String> {
45    let image = crate::design::assets::decode_png(bytes).ok_or("invalid PNG")?;
46    if x + width > image.width || y + height > image.height {
47        return Err(format!("crop {x},{y} {width}x{height} exceeds {}x{}", image.width, image.height));
48    }
49    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
50    for row in y..y + height {
51        let start = ((row * image.width + x) * 4) as usize;
52        rgba.extend_from_slice(&image.rgba[start..start + (width * 4) as usize]);
53    }
54    let mut output = Vec::new();
55    let mut encoder = png::Encoder::new(&mut output, width, height);
56    encoder.set_color(png::ColorType::Rgba);
57    encoder.set_depth(png::BitDepth::Eight);
58    let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
59    writer.write_image_data(&rgba).map_err(|e| e.to_string())?;
60    drop(writer);
61    Ok(output)
62}
63
64/// A file of texture `texture` — its `png`, or its `png.mcmeta` — as the
65/// build makes it (the mod's own, through the CLI service) or the game has it.
66fn texture_file(file: &Path, texture: &str, ext: &str) -> Result<Vec<u8>, String> {
67    let id = texture_id(texture);
68    let (ns, path) = yog_ids::split(&id);
69    Resources::for_mod(mod_root(file).as_deref())
70        .read(ns, &format!("{}{path}.{ext}", yog_ids::res::TEXTURES))
71        .ok_or_else(|| format!("texture {id} not found (the mod's .{}, or the Minecraft client jar)", yog_ids::Format::Texture.ext()))
72}
73
74/// The mod's `content/` file defining item or block `id` (`kind`:
75/// `yitem` or `yblock`), parsed.
76fn content_file(root: &Path, id: &str, formats: &[Format]) -> Option<(PathBuf, Value)> {
77    let ns = crate::design::project::mod_id(root)?;
78    let (want_ns, want) = yog_ids::split(id);
79    if want_ns != ns {
80        return None;
81    }
82    for format in formats {
83        for file in crate::design::files::of(&root.join("content"), *format) {
84            let Some(doc) = std::fs::read_to_string(&file).ok().and_then(|t| crate::design::data::read_object(&t)).map(Value::Object) else {
85                continue;
86            };
87            let fid = doc.get(yog_ids::content_field::ID).and_then(Value::as_str).map(str::to_string)
88                .or_else(|| file.file_stem().map(|s| s.to_string_lossy().into_owned()))?;
89            if fid.split_once(':').map_or(fid.as_str(), |(_, p)| p) == want {
90                return Some((file, doc));
91            }
92        }
93    }
94    None
95}
96
97/// The textures of a content file's model (`field`) as the build takes them:
98/// its material's (`.ymaterial`) under its own, as `@texture:` text.
99fn model_textures(root: &Path, doc: &Value, field: &str) -> serde_json::Map<String, Value> {
100    let Some(model) = doc.get(field) else { return serde_json::Map::new() };
101    let read = yog_data::read_value::<yog_formats::content::Model>(&yog_data::json::from_json(model));
102    let (Ok(mut model), Some(ns)) = (read, crate::design::project::mod_id(root)) else {
103        // Not a model the build would take: its textures as written.
104        return model.get(yog_ids::model::TEXTURES).and_then(Value::as_object).cloned().unwrap_or_default();
105    };
106    if let Some(material) = model.material.clone()
107        && let Ok(m) = crate::content::material::load(root, &ns, &material)
108    {
109        crate::content::material::apply(&mut model, &m);
110    }
111    model.textures.0.iter().map(|(slot, t)| (slot.clone(), yog_data::json::to_json(&yog_data::Schema::write(t)))).collect()
112}
113
114/// The textures of the mod's block `id` (its `.yblock` model's), per face:
115/// up, down, north, south, west, east — as png bytes. `None`: not a block
116/// of the mod.
117pub fn content_block_faces(root: &Path, id: &str) -> Option<[Option<Vec<u8>>; 6]> {
118    let (file, doc) = content_file(root, id, &[Format::Block])?;
119    let textures = model_textures(root, &doc, yog_ids::content_field::MODEL);
120    let pick = |keys: &[&str]| {
121        keys.iter()
122            .find_map(|k| textures.get(*k).and_then(Value::as_str))
123            .or_else(|| textures.values().find_map(Value::as_str))
124            .and_then(|t| texture_bytes(&file, t).ok())
125    };
126    Some(super::blocks3d::FACE_TEXTURES.map(pick))
127}
128
129/// The model of the mod's block `id` as its `.yblock` gives it: its
130/// `elements` and its textures by variable (png bytes). `None`: not a
131/// block of the mod, or its model has no elements of its own.
132pub fn content_block_model(root: &Path, id: &str) -> Option<(Vec<Value>, std::collections::HashMap<String, Vec<u8>>)> {
133    let (file, doc) = content_file(root, id, &[Format::Block])?;
134    let elements = match super::data::at(&doc, &[yog_ids::content_field::MODEL, yog_ids::model::ELEMENTS]).and_then(Value::as_array) {
135        Some(elements) => elements.clone(),
136        None if doc.get(yog_ids::content_field::KIND).and_then(Value::as_str) == Some("stairs") => vec![
137            stair_element([0, 0, 0], [16, 8, 16]),
138            stair_element([0, 8, 8], [16, 16, 16]),
139        ],
140        None => return None,
141    };
142    let textures = model_textures(root, &doc, yog_ids::content_field::MODEL)
143        .iter()
144        .filter_map(|(k, v)| Some((k.clone(), texture_bytes(&file, v.as_str()?).ok()?)))
145        .collect();
146    Some((elements, textures))
147}
148
149fn stair_element(from: [u8; 3], to: [u8; 3]) -> Value {
150    let face = |texture: &str| serde_json::json!({ yog_ids::model::TEXTURE: format!("{}{texture}", yog_formats::content::SLOT_MARK) });
151    serde_json::json!({
152        "from": from,
153        "to": to,
154        "faces": {
155            "up": face("top"),
156            "down": face("bottom"),
157            "north": face("side"),
158            "south": face("side"),
159            "west": face("side"),
160            "east": face("side")
161        }
162    })
163}
164
165/// A mod item's definition and, for potions, its effect-derived color.
166pub fn content_item_appearance(root: &Path, id: &str) -> Option<(Value, Option<u32>)> {
167    let ns = crate::design::project::mod_id(root)?;
168    let (want_ns, want) = yog_ids::split(id);
169    let want = if want_ns == yog_ids::ns::YOG {
170        want.strip_prefix(yog_ids::split(yog_ids::yog::POTION_ALIAS).1)?
171    } else if want_ns == ns {
172        want
173    } else {
174        return None;
175    };
176    for file in crate::design::files::of(&root.join("content/items"), Format::Item) {
177        let Ok(text) = std::fs::read_to_string(&file) else {
178            continue;
179        };
180        let Some(doc) = crate::design::data::read_object(&text) else {
181            continue;
182        };
183        let Some(fid) = doc
184            .get(yog_ids::content_field::ID)
185            .and_then(Value::as_str)
186            .or_else(|| file.file_stem().and_then(|s| s.to_str()))
187        else {
188            continue;
189        };
190        if fid.split_once(':').map_or(fid, |(_, p)| p) != want {
191            continue;
192        }
193        let definition = Value::Object(doc);
194        let color = potion_effect_color(root, &definition);
195        return Some((definition, color));
196    }
197    None
198}
199
200fn potion_effect_color(root: &Path, item: &Value) -> Option<u32> {
201    let effects = super::data::at(item, &[yog_ids::content_field::ITEM, yog_ids::content_field::POTION, yog_ids::content_field::EFFECTS])?.as_array()?;
202    let mut channels = [0u64; 3];
203    let mut count = 0u64;
204    for effect in effects {
205        let Some(id) = effect.get(yog_ids::content_field::EFFECT).and_then(Value::as_str) else {
206            continue;
207        };
208        let Some(color) = content_effect_color(root, id) else {
209            continue;
210        };
211        channels[0] += u64::from((color >> 16) & 0xFF);
212        channels[1] += u64::from((color >> 8) & 0xFF);
213        channels[2] += u64::from(color & 0xFF);
214        count += 1;
215    }
216    (count > 0).then(|| {
217        (((channels[0] / count) as u32) << 16)
218            | (((channels[1] / count) as u32) << 8)
219            | (channels[2] / count) as u32
220    })
221}
222
223/// The color of the potion effect(s) defined by this `.yitem`.
224pub fn item_potion_color(file: &Path) -> Option<u32> {
225    let root = mod_root(file)?;
226    let text = std::fs::read_to_string(file).ok()?;
227    let item = Value::Object(crate::design::data::read_object(&text)?);
228    potion_effect_color(&root, &item)
229}
230
231fn content_effect_color(root: &Path, reference: &str) -> Option<u32> {
232    let mod_ns = crate::design::project::mod_id(root)?;
233    let id = reference.strip_prefix("@effect:").unwrap_or(reference);
234    let (ns, want) = yog_ids::split(id);
235    if ns != mod_ns {
236        return None;
237    }
238    for file in crate::design::files::of(&root.join("content/effects"), Format::Def) {
239        let Ok(text) = std::fs::read_to_string(&file) else {
240            continue;
241        };
242        let Some(doc) = crate::design::data::read_object(&text) else {
243            continue;
244        };
245        let Some(id) = doc
246            .get(yog_ids::content_field::ID)
247            .and_then(Value::as_str)
248            .or_else(|| file.file_stem().and_then(|s| s.to_str()))
249        else {
250            continue;
251        };
252        if id.split_once(':').map_or(id, |(_, p)| p) == want {
253            return doc
254                .get(yog_ids::content_field::COLOR)
255                .and_then(Value::as_u64)
256                .and_then(|v| u32::try_from(v).ok());
257        }
258    }
259    None
260}
261
262/// The icon of the mod's item or block `id` as its `content/` file draws
263/// it: the model's `layer0` (else its first texture), as png bytes.
264pub fn content_icon(root: &Path, id: &str) -> Option<Vec<u8>> {
265    let ns = crate::design::project::mod_id(root)?;
266    let (want_ns, want) = yog_ids::split(id);
267    if want_ns != ns {
268        return None;
269    }
270    let files = [crate::design::files::of(&root.join("content"), Format::Item), crate::design::files::of(&root.join("content"), Format::Block)].concat();
271    for file in files {
272        let Ok(text) = std::fs::read_to_string(&file) else { continue };
273        let Some(doc) = crate::design::data::read_object(&text).map(Value::Object) else { continue };
274        let fid = doc.get(yog_ids::content_field::ID).and_then(Value::as_str).map(str::to_string)
275            .or_else(|| file.file_stem().map(|s| s.to_string_lossy().into_owned()))?;
276        let fid = fid.split_once(':').map_or(fid.as_str(), |(_, p)| p).to_string();
277        if fid != want {
278            continue;
279        }
280        let textures = model_textures(root, &doc, yog_ids::content_field::MODEL);
281        let tex = textures
282            .get(yog_ids::model::LAYER0)
283            .or_else(|| ["all", "side", "top", "texture"].iter().find_map(|k| textures.get(*k)))
284            .or_else(|| textures.values().next())?
285            .as_str()?;
286        return texture_bytes(&file, tex).ok();
287    }
288    None
289}
290
291#[derive(Serialize)]
292pub struct Texture {
293    /// `data:image/png;base64,...`
294    pub url: String,
295    /// Its `.png.mcmeta` animation, if it has one.
296    pub animation: Option<Animation>,
297    /// The texture is the mod's own file — the editor can paint it in place
298    /// (otherwise painting copies it into the mod first).
299    pub writable: bool,
300}
301
302/// A texture's animation (`.png.mcmeta`), as the game plays it: frames
303/// `frame_width`×`frame_height` laid row by row over the image, shown in
304/// `frames` order, each for its `time` in ticks.
305#[derive(Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
306#[serde(rename_all = "camelCase")]
307pub struct Animation {
308    pub frame_width: u32,
309    pub frame_height: u32,
310    pub frames: Vec<AnimationFrame>,
311    /// Blends each frame into the next (the game's `interpolate`).
312    pub interpolate: bool,
313}
314
315#[derive(Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
316pub struct AnimationFrame {
317    pub index: u32,
318    /// Ticks (1/20 s).
319    pub time: u32,
320}
321
322impl Animation {
323    /// The animation of a `size` image from its `.mcmeta` text; `None` when it has none.
324    pub fn read(meta: &str, size: (u32, u32)) -> Option<Animation> {
325        let doc: Value = serde_json::from_str(meta).ok()?;
326        Self::of(&yog_formats::texture::Animation::from_mcmeta(&doc)?, size)
327    }
328
329    /// `a` of a `size` image, every frame resolved: its size (the game's
330    /// default a square of the image's smaller side) and each frame's time.
331    pub fn of(a: &yog_formats::texture::Animation, (w, h): (u32, u32)) -> Option<Animation> {
332        let side = w.min(h);
333        let fw = a.width.unwrap_or(side).max(1);
334        let fh = a.height.unwrap_or(side).max(1);
335        let count = (w / fw) * (h / fh);
336        if count == 0 {
337            return None;
338        }
339        let frame_time = a.frame_time().max(1);
340        let frames = if a.frames.is_empty() {
341            (0..count).map(|index| AnimationFrame { index, time: frame_time }).collect()
342        } else {
343            a.frames
344                .iter()
345                .filter(|f| f.index < count)
346                .map(|f| AnimationFrame { index: f.index, time: f.time.map_or(frame_time, |t| t.max(1)) })
347                .collect()
348        };
349        Some(Animation { frame_width: fw, frame_height: fh, frames, interpolate: a.interpolate })
350    }
351
352    /// It as a `.ytexture` keeps it, for a `size` image: every frame listed with its time.
353    pub fn to_format(&self, (w, h): (u32, u32)) -> yog_formats::texture::Animation {
354        let side = w.min(h);
355        yog_formats::texture::Animation {
356            frame_time: None,
357            interpolate: self.interpolate,
358            frames: self.frames.iter().map(|f| yog_formats::texture::Frame { index: f.index, time: Some(f.time) }).collect(),
359            width: (self.frame_width != side).then_some(self.frame_width),
360            height: (self.frame_height != side).then_some(self.frame_height),
361        }
362    }
363}
364
365/// Sets the animation of texture `texture` of the mod (`None`: still) in
366/// its `.ytexture`; the file written.
367pub fn write_animation(file: &Path, texture: &str, animation: Option<&Animation>) -> Result<PathBuf, String> {
368    let root = mod_root(file).ok_or("not in a mod")?;
369    let id = texture_id(texture);
370    let animation = match animation {
371        Some(a) => {
372            let png = texture_bytes(file, texture)?;
373            let size = image_size(&png).ok_or_else(|| format!("{id} isn't a picture"))?;
374            Some(a.to_format(size))
375        }
376        None => None,
377    };
378    crate::media::save_texture_animation(&root, &id, animation)
379}
380
381/// A body model's texture (`.ymodel`): `@texture:<ns>:<path>`, as a content file's.
382pub fn body_texture(file: &Path, texture: &str) -> Result<Texture, String> {
383    self::texture(file, texture)
384}
385
386pub fn texture(file: &Path, texture: &str) -> Result<Texture, String> {
387    let bytes = texture_bytes(file, texture)?;
388    let animation = image_size(&bytes).and_then(|size| {
389        let meta = texture_file(file, texture, "png.mcmeta").ok()?;
390        Animation::read(&String::from_utf8_lossy(&meta), size)
391    });
392    Ok(Texture {
393        url: format!(
394            "data:image/png;base64,{}",
395            base64::engine::general_purpose::STANDARD.encode(&bytes)
396        ),
397        animation,
398        writable: own_texture(file, texture).is_some(),
399    })
400}
401
402/// The mod and the id of texture `texture` when it's the mod's own (it
403/// can be painted in place, or added); `None` for the game's, another
404/// mod's, a `#slot`.
405pub fn own_texture(file: &Path, texture: &str) -> Option<(PathBuf, String)> {
406    if texture.starts_with(yog_formats::content::SLOT_MARK) {
407        return None;
408    }
409    let root = mod_root(file)?;
410    let id = texture_id(texture);
411    crate::media::is_own_texture(&root, &id).then_some((root, id))
412}
413
414/// Writes `png` as the picture of the mod's texture `texture`; the `.yres` written.
415pub fn write_texture(file: &Path, texture: &str, png: Vec<u8>) -> Result<PathBuf, String> {
416    if image_size(&png).is_none() {
417        return Err("not a PNG image".into());
418    }
419    let (root, id) = own_texture(file, texture).ok_or_else(|| format!("{texture} isn't a texture of this mod"))?;
420    crate::media::save_texture_picture(&root, &id, png)
421}
422
423/// Width and height from a PNG header.
424fn image_size(png: &[u8]) -> Option<(u32, u32)> {
425    if png.len() < 24 || &png[1..4] != b"PNG" {
426        return None;
427    }
428    let w = u32::from_be_bytes(png[16..20].try_into().ok()?);
429    let h = u32::from_be_bytes(png[20..24].try_into().ok()?);
430    Some((w, h))
431}
432
433#[derive(Serialize)]
434pub struct TextureChoice {
435    /// What goes in the content file.
436    pub value: String,
437    /// "Resources", "Resource sets", "Mod assets", "Minecraft blocks", "Minecraft items".
438    pub group: &'static str,
439}
440
441/// The `@<Set>.<name>` texts of the mod's `.yres` files — the name/tooltip picker's choices.
442pub fn strings(file: &Path) -> Vec<String> {
443    let mut out = Vec::new();
444    let Some(root) = mod_root(file) else {
445        return out;
446    };
447    for p in crate::design::files::of(&root.join("resources"), Format::Res) {
448        let Some(set) = p.file_stem().and_then(|s| s.to_str()) else {
449            continue;
450        };
451        if set.contains('.') {
452            continue; // a culture variant
453        }
454        let Ok(text) = std::fs::read_to_string(&p) else {
455            continue;
456        };
457        let Some(doc) = crate::design::data::read_object(&text) else {
458            continue;
459        };
460        for (name, e) in &doc {
461            let ty = e.get("type").and_then(Value::as_str);
462            if e.is_string() || ty.is_none_or(|t| t == "string") {
463                out.push(format!("@{set}.{name}"));
464            }
465        }
466    }
467    out.sort();
468    out
469}
470
471/// What a `.ydef` reference offers: the game's built-ins by short name,
472/// then the mod's own definitions of `kind` as `@<kind>:` refs.
473fn definitions(file: &Path, kind: &str, built_ins: impl IntoIterator<Item = String>) -> Vec<String> {
474    let mut out: Vec<String> = built_ins.into_iter().map(|id| id.trim_start_matches(&format!("{}{}", yog_ids::ns::MINECRAFT, yog_ids::sep::NAMESPACE)).to_string()).collect();
475    let Some(root) = mod_root(file) else {
476        return out;
477    };
478    let ns = crate::design::project::mod_id(&root).unwrap_or_else(|| yog_ids::ns::MINECRAFT.into());
479    let mut own = Vec::new();
480    for p in crate::design::files::of(&root.join(yog_ids::project::CONTENT), Format::Def) {
481        let Some(doc) = std::fs::read_to_string(&p).ok().and_then(|t| crate::design::data::read_object(&t)) else {
482            continue;
483        };
484        if doc.get(yog_ids::content_field::KIND).and_then(Value::as_str) != Some(kind) {
485            continue;
486        }
487        if let Some(id) = doc.get(yog_ids::content_field::ID).and_then(Value::as_str) {
488            own.push(format!("{}{kind}{}{}", yog_ids::sep::TYPED, yog_ids::sep::NAMESPACE, yog_ids::qualify_in(&ns, id)));
489        }
490    }
491    own.sort();
492    out.extend(own);
493    out
494}
495
496/// What a block's `sound` offers: the game's sound groups, then the mod's.
497pub fn sound_groups(file: &Path) -> Vec<String> {
498    definitions(file, "sound_group", yog_formats::def::SoundGroup::built_ins().iter().map(|g| g.id.clone()))
499}
500
501/// What a tool's `material` offers: the game's, then the mod's.
502pub fn tool_materials(file: &Path) -> Vec<String> {
503    definitions(file, "tool_material", yog_formats::def::ToolMaterial::built_ins().iter().map(|m| m.id.clone()))
504}
505
506/// What an armor piece's `material` offers: the game's, then the mod's.
507pub fn armor_materials(file: &Path) -> Vec<String> {
508    definitions(file, "armor_material", yog_formats::def::ArmorMaterial::built_ins().iter().map(|m| m.id.clone()))
509}
510
511/// What a `tags` list of content in `registry` (`item`, `block`) offers:
512/// the mod's own tags by bare name (as the build last listed them), then
513/// Yog's common tags.
514pub fn tags(file: &Path, registry: &str) -> Vec<String> {
515    let mut out = Vec::new();
516    if let Some(root) = mod_root(file) {
517        let ns = crate::design::project::mod_id(&root).unwrap_or_default();
518        let listed = std::fs::read(root.join(".yog-build/generated/tags.json"))
519            .ok()
520            .and_then(|b| serde_json::from_slice::<std::collections::BTreeMap<String, std::collections::BTreeMap<String, Value>>>(&b).ok())
521            .and_then(|mut by| by.remove(registry))
522            .unwrap_or_default();
523        out.extend(listed.into_keys().filter_map(|t| t.strip_prefix(&format!("{ns}:")).map(str::to_string)));
524    }
525    out.extend(yog_formats::tags::yog_tags(registry).into_iter().map(|(p, _)| format!("yog:{p}")));
526    out
527}
528
529/// Everything the texture picker offers.
530pub fn textures(file: &Path) -> Vec<TextureChoice> {
531    let mut out = Vec::new();
532    let root = mod_root(file);
533    if let Some(root) = &root {
534        out.extend(crate::media::textures(root).into_iter().map(|t| TextureChoice {
535            value: format!("{}{}", yog_ids::reference::TEXTURE, t.id),
536            group: "Mod textures",
537        }));
538    }
539    if let Some(v) = Resources::for_mod(root.as_deref()).vanilla {
540        let (items, blocks) = v.texture_names();
541        out.extend(blocks.into_iter().map(|b| TextureChoice {
542            value: format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::join(yog_ids::ns::MINECRAFT, &format!("{}{b}", yog_ids::res::BLOCK_DIR))),
543            group: "Minecraft blocks",
544        }));
545        out.extend(items.into_iter().map(|i| TextureChoice {
546            value: format!("{}{}", yog_ids::reference::TEXTURE, yog_ids::join(yog_ids::ns::MINECRAFT, &format!("{}{i}", yog_ids::res::ITEM_DIR))),
547            group: "Minecraft items",
548        }));
549    }
550    out
551}
552
553/// A model by id, parents flattened: `{textures, elements?, display, parent?}`
554/// where `parent` is the vanilla sprite/builtin root it ends at, if any.
555pub fn parent_model(file: &Path, id: &str) -> Result<Value, String> {
556    let resources = Resources::for_mod(mod_root(file).as_deref());
557    let mut textures = Map::new();
558    let mut display = Map::new();
559    let mut elements: Option<Value> = None;
560    let mut current = id.to_string();
561    let mut root_parent: Option<String> = None;
562    for _ in 0..16 {
563        let (ns, path) = yog_ids::split(&current);
564        let bytes = resources
565            .read(ns, &yog_ids::model_file(path))
566            .ok_or_else(|| format!("model {ns}:{path} not found"))?;
567        let m: Map<String, Value> =
568            serde_json::from_slice(&bytes).map_err(|e| format!("{ns}:{path}: {e}"))?;
569        if let Some(Value::Object(t)) = m.get(yog_ids::model::TEXTURES) {
570            for (k, v) in t {
571                textures.entry(k.clone()).or_insert_with(|| v.clone());
572            }
573        }
574        if let Some(Value::Object(d)) = m.get(yog_ids::model::DISPLAY) {
575            for (k, v) in d {
576                display.entry(k.clone()).or_insert_with(|| v.clone());
577            }
578        }
579        if elements.is_none() {
580            elements = m.get(yog_ids::model::ELEMENTS).cloned();
581        }
582        match m.get(yog_ids::model::PARENT).and_then(Value::as_str) {
583            None => break,
584            Some(p) if p.contains(yog_ids::res::BUILTIN) => {
585                root_parent = Some(p.to_string());
586                break;
587            }
588            Some(p) => {
589                let full = yog_ids::qualify(p);
590                // A sprite's parent (`item/generated`): where the model ends.
591                if crate::content::TEMPLATES.iter().any(|t| t.sprite && t.parent == full) {
592                    root_parent = Some(full.clone());
593                }
594                current = full;
595            }
596        }
597    }
598    let mut out = Map::new();
599    out.insert(yog_ids::model::TEXTURES.into(), Value::Object(textures));
600    out.insert(yog_ids::model::DISPLAY.into(), Value::Object(display));
601    if let Some(e) = elements {
602        out.insert(yog_ids::model::ELEMENTS.into(), e);
603    }
604    if let Some(p) = root_parent {
605        out.insert(yog_ids::model::PARENT.into(), Value::String(p));
606    }
607    Ok(Value::Object(out))
608}
609
610/// `yog import <kind> <source> [--model <id>] [--force]` (`kind`: `resourcepack`,
611/// `bbmodel`, or `bbmodel-item`) in the mod of
612/// `file` (a content file or the mod folder) → what it printed.
613pub fn import(
614    file: &Path,
615    kind: &str,
616    source: &str,
617    model: Option<&str>,
618    force: bool,
619) -> Result<String, String> {
620    let root = mod_root(file).ok_or("not in a mod (no yog.toml)")?;
621    // `bbmodel-item`: a Blockbench model as an item.
622    let (what, item) = match kind {
623        "resourcepack" => ("resourcepack", false),
624        "bbmodel" => ("bbmodel", false),
625        "bbmodel-item" => ("bbmodel", true),
626        _ => return Err(format!("unknown import kind {kind}")),
627    };
628    let mut cmd = crate::design::this_cli();
629    cmd.current_dir(&root).args(["import", what, source]);
630    if let Some(m) = model.filter(|m| !m.is_empty()) {
631        cmd.args([if what == "bbmodel" { "--id" } else { "--model" }, m]);
632    } else if what == "resourcepack" {
633        cmd.arg("--all");
634    }
635    if item {
636        cmd.arg("--item");
637    }
638    if force {
639        cmd.arg("--force");
640    }
641    let out = cmd
642        .output()
643        .map_err(|e| format!("failed to run yog: {e}"))?;
644    let text = String::from_utf8_lossy(&out.stderr).replace("yog: error: ", "");
645    if out.status.success() {
646        Ok(text.trim().to_string())
647    } else {
648        Err(text.trim().to_string())
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    #[test]
655    fn animation_reads_the_games_mcmeta() {
656        let meta = r#"{"animation":{"frametime":3,"frames":[0,{"index":2,"time":10},7]}}"#;
657        let a = Animation::read(meta, (16, 64)).unwrap();
658        assert_eq!((a.frame_width, a.frame_height), (16, 16));
659        // Frame 7 is past the four the image holds.
660        assert_eq!(a.frames, vec![AnimationFrame { index: 0, time: 3 }, AnimationFrame { index: 2, time: 10 }]);
661        assert!(!a.interpolate);
662        let all = Animation::read(r#"{"animation":{}}"#, (16, 48)).unwrap();
663        assert_eq!(all.frames.iter().map(|f| f.index).collect::<Vec<_>>(), vec![0, 1, 2]);
664        assert!(Animation::read("{}", (16, 16)).is_none());
665    }
666
667    #[test]
668    fn animation_round_trips() {
669        let a = Animation { frame_width: 16, frame_height: 16, frames: vec![AnimationFrame { index: 1, time: 4 }, AnimationFrame { index: 0, time: 1 }], interpolate: true };
670        assert_eq!(Animation::of(&a.to_format((16, 32)), (16, 32)), Some(a));
671    }
672
673    use super::*;
674
675    #[test]
676    fn png_size() {
677        let mut png = vec![
678            0x89, b'P', b'N', b'G', 13, 10, 26, 10, 0, 0, 0, 13, b'I', b'H', b'D', b'R',
679        ];
680        png.extend(16u32.to_be_bytes());
681        png.extend(64u32.to_be_bytes());
682        assert_eq!(image_size(&png), Some((16, 64)));
683    }
684
685    #[test]
686    fn sound_groups_are_the_games_then_the_mods() {
687        let dir = std::env::temp_dir().join(format!("idle-sounds-{}", std::process::id()));
688        let _ = std::fs::remove_dir_all(&dir);
689        std::fs::create_dir_all(dir.join("content")).unwrap();
690        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
691        std::fs::write(dir.join("content/crystal.ydef"), r#"{ kind: "sound_group", id: "crystal" }"#).unwrap();
692        std::fs::write(dir.join("content/mythril.ydef"), r#"{ kind: "level", id: "mythril" }"#).unwrap();
693        let all = sound_groups(&dir.join("content/ore.yblock"));
694        assert!(all.contains(&"stone".to_string()) && all.contains(&"amethyst".to_string()));
695        assert_eq!(all.last().map(String::as_str), Some("@sound_group:mymod:crystal"));
696        assert!(!all.iter().any(|s| s.contains("mythril")));
697        let _ = std::fs::remove_dir_all(&dir);
698    }
699
700    #[test]
701    fn resolves_yres_images_and_resource_paths() {
702        let dir = std::env::temp_dir().join(format!("idle-content-{}", std::process::id()));
703        let _ = std::fs::remove_dir_all(&dir);
704        std::fs::create_dir_all(dir.join("content")).unwrap();
705        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
706        let png = yog_media::image::encode_png(&yog_media::image::Image { width: 1, height: 1, rgba: vec![1, 2, 3, 255] }).unwrap();
707        let file = dir.join("content/ruby.yblock");
708        // Painting a new texture of the mod makes its picture and .ytexture.
709        assert_eq!(own_texture(&file, "@texture:mymod:block/ruby").map(|(_, id)| id).as_deref(), Some("mymod:block/ruby"));
710        write_texture(&file, "@texture:mymod:block/ruby", png.clone()).unwrap();
711        assert_eq!(texture_bytes(&file, "@texture:mymod:block/ruby").unwrap(), png);
712        assert!(textures(&file).iter().any(|c| c.value == "@texture:mymod:block/ruby"));
713        assert_eq!(own_texture(&file, "@texture:minecraft:item/apple"), None);
714        assert_eq!(own_texture(&file, "#layer0"), None);
715        let _ = std::fs::remove_dir_all(dir);
716    }
717
718    #[test]
719    fn item_appearance_reads_its_model_and_effect_colors() {
720        let dir = std::env::temp_dir().join(format!("idle-item-appearance-{}", std::process::id()));
721        let _ = std::fs::remove_dir_all(&dir);
722        std::fs::create_dir_all(dir.join("content/items")).unwrap();
723        std::fs::create_dir_all(dir.join("content/effects")).unwrap();
724        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
725        std::fs::write(
726            dir.join("content/items/potion.yitem"),
727            r#"{ model: { parent: "minecraft:item/potion" }, item: { potion: { effects: [{ effect: "@effect:mymod:glow" }] } } }"#,
728        )
729        .unwrap();
730        std::fs::write(
731            dir.join("content/effects/glow.ydef"),
732            r#"{ kind: "effect", id: "glow", color: 1193046 }"#,
733        )
734        .unwrap();
735
736        let (definition, color) = content_item_appearance(&dir, "mymod:potion").unwrap();
737        assert_eq!(
738            definition.pointer("/model/parent").and_then(Value::as_str),
739            Some("minecraft:item/potion")
740        );
741        assert_eq!(color, Some(0x123456));
742        assert_eq!(item_potion_color(&dir.join("content/items/potion.yitem")), Some(0x123456));
743        let _ = std::fs::remove_dir_all(dir);
744    }
745
746    #[test]
747    fn stairs_without_custom_elements_get_a_step_shape() {
748        let dir = std::env::temp_dir().join(format!("idle-stairs-{}", std::process::id()));
749        let _ = std::fs::remove_dir_all(&dir);
750        std::fs::create_dir_all(dir.join("content/blocks")).unwrap();
751        std::fs::write(dir.join("yog.toml"), "[mod]\nid = \"mymod\"\n").unwrap();
752        std::fs::write(
753            dir.join("content/blocks/stairs.yblock"),
754            r#"{ kind: "stairs", model: { textures: { top: "block/top.png", bottom: "block/bottom.png", side: "block/side.png" } } }"#,
755        )
756        .unwrap();
757
758        let (elements, _) = content_block_model(&dir, "mymod:stairs").unwrap();
759        assert_eq!(elements.len(), 2);
760        assert_eq!(elements[0]["from"], serde_json::json!([0, 0, 0]));
761        assert_eq!(elements[0]["to"], serde_json::json!([16, 8, 16]));
762        assert_eq!(elements[1]["from"], serde_json::json!([0, 8, 8]));
763        assert_eq!(elements[1]["to"], serde_json::json!([16, 16, 16]));
764        let _ = std::fs::remove_dir_all(&dir);
765    }
766}