Skip to main content

yog_cli/content/
import.rs

1//! `yog import` — turns models made elsewhere into `content/` files:
2//!
3//! - `yog import resourcepack <zip|dir> [--model <id>] [--all]` — a
4//!   resource pack's (or a mod's `assets/`) block and item models: parents
5//!   resolved (a vanilla template stays a template, other parents are
6//!   flattened from the pack or the Minecraft client jar), textures copied
7//!   into `resources/`, the blockstate's rotations kept;
8//! - `yog import bbmodel <file.bbmodel> [--id <name>] [--item]` — a
9//!   Blockbench project in the Java block/item format, textures included.
10
11use super::json::{J, V, to_pretty};
12use super::{TEMPLATES, template, valid_namespace, valid_path};
13use serde_json::{Map, Value};
14use std::collections::BTreeMap;
15use std::io::Read as _;
16use std::path::{Path, PathBuf};
17
18const USAGE: &str = "usage:\n  yog import resourcepack <pack.zip|dir> [--model <ns:block/name|ns:item/name>] [--all] [--force]\n  yog import bbmodel <file.bbmodel> [--id <name>] [--item] [--force]";
19
20/// `yog import ...` (args after `import`).
21pub fn import_command(args: &[String]) -> Result<(), String> {
22    let flag = |name: &str| args.iter().any(|a| a == name);
23    let value = |name: &str| {
24        args.iter()
25            .position(|a| a == name)
26            .and_then(|i| args.get(i + 1))
27            .map(String::as_str)
28    };
29    let positional: Vec<&str> = {
30        let mut out = Vec::new();
31        let mut skip = false;
32        for a in args.iter().skip(1) {
33            if skip {
34                skip = false;
35            } else if a == "--model" || a == "--id" {
36                skip = true;
37            } else if !a.starts_with("--") {
38                out.push(a.as_str());
39            }
40        }
41        out
42    };
43    let root = std::env::current_dir().map_err(|e| e.to_string())?;
44    let mod_id = read_mod_id(&root)?;
45    let mut target = Target {
46        root: root.clone(),
47        ns: valid_namespace(&mod_id).then_some(mod_id),
48        force: flag("--force"),
49        written: Vec::new(),
50        warnings: Vec::new(),
51    };
52    match (args.first().map(String::as_str), positional.first()) {
53        (Some("resourcepack"), Some(src)) => {
54            let pack = Pack::open(&root.join(src))?;
55            import_pack(&mut target, &pack, value("--model"), flag("--all"))?;
56        }
57        (Some("bbmodel"), Some(file)) => {
58            import_bbmodel(&mut target, &root.join(file), value("--id"), flag("--item"))?;
59        }
60        _ => return Err(USAGE.into()),
61    }
62    for w in &target.warnings {
63        eprintln!("warning: {w}");
64    }
65    for f in &target.written {
66        eprintln!("==> wrote {f}");
67    }
68    if target.written.is_empty() {
69        return Err("nothing imported".into());
70    }
71    eprintln!(
72        "    `yog content check` validates them; register with `yog_api::content!();` in src/lib.rs"
73    );
74    Ok(())
75}
76
77fn read_mod_id(root: &Path) -> Result<String, String> {
78    let text = std::fs::read_to_string(root.join("yog.toml")).map_err(|_| {
79        "no yog.toml in the current directory — run `yog import` in a mod".to_string()
80    })?;
81    let mut in_mod = false;
82    for line in text.lines() {
83        let t = line.trim();
84        if t.starts_with('[') {
85            in_mod = t == "[mod]";
86        } else if in_mod
87            && let Some(v) = t
88                .strip_prefix("id")
89                .map(str::trim_start)
90                .and_then(|r| r.strip_prefix('='))
91        {
92            return Ok(v.trim().trim_matches('"').to_string());
93        }
94    }
95    Err("yog.toml has no [mod] id".into())
96}
97
98/// Where imports go.
99struct Target {
100    root: PathBuf,
101    /// The mod's namespace, when its id is one.
102    ns: Option<String>,
103    force: bool,
104    written: Vec<String>,
105    warnings: Vec<String>,
106}
107
108impl Target {
109    /// The `"id"` to write for `name` from namespace `from_ns`: none when
110    /// the mod's namespace applies.
111    fn id_for(&self, from_ns: &str, name: &str) -> Option<String> {
112        self.ns.is_none().then(|| format!("{from_ns}:{name}"))
113    }
114
115    /// The namespace textures bundled for an entry of `from_ns` land in.
116    fn texture_ns(&self, from_ns: &str) -> String {
117        self.ns.clone().unwrap_or_else(|| from_ns.to_string())
118    }
119
120    fn write_file(&mut self, rel: &str, bytes: &[u8]) -> Result<(), String> {
121        let path = self.root.join(rel);
122        if path.exists() && !self.force {
123            if std::fs::read(&path).ok().as_deref() == Some(bytes) {
124                return Ok(());
125            }
126            return Err(format!("{rel} already exists (--force overwrites)"));
127        }
128        if let Some(dir) = path.parent() {
129            std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
130        }
131        std::fs::write(&path, bytes).map_err(|e| format!("{rel}: {e}"))?;
132        self.written.push(rel.to_string());
133        Ok(())
134    }
135
136    /// Copies a texture into `resources/<path>.png` → its value in a
137    /// content file (`path.png`).
138    fn put_texture(
139        &mut self,
140        path: &str,
141        png: &[u8],
142        mcmeta: Option<&[u8]>,
143    ) -> Result<String, String> {
144        let rel = format!("resources/{path}.png");
145        let existing = self.root.join(&rel);
146        if !(existing.is_file() && std::fs::read(&existing).ok().as_deref() == Some(png)) {
147            let force = self.force;
148            // A texture already there and different: keep it, warn.
149            if existing.is_file() && !force {
150                self.warnings.push(format!(
151                    "{rel} exists and differs — kept it (--force overwrites)"
152                ));
153            } else {
154                self.write_file(&rel, png)?;
155            }
156        }
157        if let Some(meta) = mcmeta {
158            let rel = format!("resources/{path}.png.mcmeta");
159            if !self.root.join(&rel).is_file() || self.force {
160                self.write_file(&rel, meta)?;
161            }
162        }
163        Ok(format!("{path}.png"))
164    }
165}
166
167// ── Resource packs ───────────────────────────────────────────────────────────
168
169/// A zip or a directory with `assets/`.
170enum Pack {
171    Dir(PathBuf),
172    Zip(BTreeMap<String, Vec<u8>>),
173}
174
175impl Pack {
176    fn open(path: &Path) -> Result<Pack, String> {
177        if path.is_dir() {
178            if !path.join("assets").is_dir() {
179                return Err(format!("{} has no assets/ folder", path.display()));
180            }
181            return Ok(Pack::Dir(path.to_path_buf()));
182        }
183        let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
184        let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", path.display()))?;
185        let mut files = BTreeMap::new();
186        // Packs sometimes wrap everything in one folder.
187        let mut prefix: Option<String> = None;
188        for i in 0..zip.len() {
189            let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
190            if entry.is_dir() {
191                continue;
192            }
193            let name = entry.name().map_err(|e| e.to_string())?.replace('\\', "/");
194            if prefix.is_none()
195                && let Some(at) = name.find("assets/")
196            {
197                prefix = Some(name[..at].to_string());
198            }
199            if !name.ends_with(".json") && !name.ends_with(".png") && !name.ends_with(".mcmeta") {
200                continue;
201            }
202            let mut bytes = Vec::new();
203            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
204            files.insert(name, bytes);
205        }
206        let prefix = prefix.unwrap_or_default();
207        Ok(Pack::Zip(
208            files
209                .into_iter()
210                .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|k| (k.to_string(), v)))
211                .collect(),
212        ))
213    }
214
215    fn read(&self, rel: &str) -> Option<Vec<u8>> {
216        match self {
217            Pack::Dir(d) => std::fs::read(d.join(rel)).ok(),
218            Pack::Zip(files) => files.get(rel).cloned(),
219        }
220    }
221
222    /// Paths under `assets/` matching `assets/<ns>/<dir>/<name>.json` → (ns, name).
223    fn list(&self, dir: &str) -> Vec<(String, String)> {
224        let mut out = Vec::new();
225        let mut add = |rel: &str| {
226            let rest = rel.strip_prefix("assets/")?;
227            let (ns, rest) = rest.split_once('/')?;
228            let name = rest
229                .strip_prefix(dir)?
230                .strip_prefix('/')?
231                .strip_suffix(".json")?;
232            out.push((ns.to_string(), name.to_string()));
233            Some(())
234        };
235        match self {
236            Pack::Dir(d) => {
237                let mut stack = vec![d.join("assets")];
238                while let Some(p) = stack.pop() {
239                    for e in std::fs::read_dir(&p).into_iter().flatten().flatten() {
240                        let path = e.path();
241                        if path.is_dir() {
242                            stack.push(path);
243                        } else if let Ok(rel) = path.strip_prefix(d) {
244                            add(&rel.to_string_lossy().replace('\\', "/"));
245                        }
246                    }
247                }
248            }
249            Pack::Zip(files) => {
250                for k in files.keys() {
251                    add(k);
252                }
253            }
254        }
255        out.sort();
256        out
257    }
258}
259
260/// The newest Minecraft client jar on this machine (vanilla models for
261/// flattening parents).
262fn client_jar(root: &Path) -> Option<PathBuf> {
263    if let Ok(p) = std::env::var("YOG_MC_CLIENT_JAR") {
264        return Some(PathBuf::from(p)).filter(|p| p.is_file());
265    }
266    let home = std::env::var_os("HOME")
267        .or_else(|| std::env::var_os("USERPROFILE"))
268        .map(PathBuf::from);
269    let mut dirs: Vec<(PathBuf, &str)> = Vec::new();
270    if let Some(h) = &home {
271        let caches = h.join(".gradle/caches");
272        dirs.push((caches.join("fabric-loom"), "minecraft-client.jar"));
273        dirs.push((
274            caches.join("forge_gradle/minecraft_repo/versions"),
275            "client.jar",
276        ));
277        dirs.push((caches.join("minecraft/versions"), "client.jar"));
278        dirs.push((h.join(".minecraft/versions"), ""));
279    }
280    for loader in ["neoforge", "fabric", "forge"] {
281        dirs.push((
282            root.join("../Yog-Mod-Loader")
283                .join(loader)
284                .join(".gradle/caches/minecraft/versions"),
285            "client.jar",
286        ));
287    }
288    let key = |v: &str| -> Vec<u32> {
289        v.split(['.', '-'])
290            .map(|p| p.parse().unwrap_or(0))
291            .collect()
292    };
293    let mut best: Option<(Vec<u32>, PathBuf)> = None;
294    for (dir, file) in dirs {
295        for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
296            let v = e.file_name().to_string_lossy().into_owned();
297            if !v.starts_with(|c: char| c.is_ascii_digit()) {
298                continue;
299            }
300            let jar = if file.is_empty() {
301                e.path().join(format!("{v}.jar"))
302            } else {
303                e.path().join(file)
304            };
305            if jar.is_file() && best.as_ref().is_none_or(|(k, _)| key(&v) > *k) {
306                best = Some((key(&v), jar));
307            }
308        }
309    }
310    best.map(|(_, p)| p)
311}
312
313/// Reads models: the pack first, then the client jar.
314struct Models<'a> {
315    pack: &'a Pack,
316    jar: Option<zip::ZipArchive<std::fs::File>>,
317    jar_tried: bool,
318    root: PathBuf,
319}
320
321impl Models<'_> {
322    fn model(&mut self, id: &str) -> Option<(Map<String, Value>, bool)> {
323        let (ns, path) = split(id);
324        let rel = format!("assets/{ns}/models/{path}.json");
325        if let Some(b) = self.pack.read(&rel) {
326            return serde_json::from_slice(&b).ok().map(|m| (m, true));
327        }
328        if !self.jar_tried {
329            self.jar_tried = true;
330            self.jar = client_jar(&self.root)
331                .and_then(|p| std::fs::File::open(p).ok())
332                .and_then(|f| zip::ZipArchive::new(f).ok());
333        }
334        let jar = self.jar.as_mut()?;
335        let mut entry = jar.by_name(&rel).ok()?;
336        let mut bytes = Vec::new();
337        entry.read_to_end(&mut bytes).ok()?;
338        serde_json::from_slice(&bytes).ok().map(|m| (m, false))
339    }
340}
341
342/// `ns:path` / `path` (minecraft).
343fn split(id: &str) -> (&str, &str) {
344    id.split_once(':').unwrap_or(("minecraft", id))
345}
346
347fn full_id(id: &str) -> String {
348    let (ns, p) = split(id);
349    format!("{ns}:{p}")
350}
351
352/// The template a vanilla model id is.
353fn template_of(id: &str) -> Option<&'static super::Template> {
354    let id = full_id(id);
355    TEMPLATES.iter().find(|t| t.parent == id)
356}
357
358/// A model of the pack → a content model object (and `render_type`).
359fn convert_model(
360    t: &mut Target,
361    models: &mut Models,
362    pack: &Pack,
363    id: &str,
364    item: bool,
365) -> Result<(J, Option<String>), String> {
366    let mut textures: Vec<(String, String)> = Vec::new();
367    let mut elements: Option<Vec<Value>> = None;
368    let mut display: Map<String, Value> = Map::new();
369    let mut ao: Option<bool> = None;
370    let mut gui_light: Option<String> = None;
371    let mut render_type: Option<String> = None;
372    let mut parent: Option<String> = None;
373    let mut current = full_id(id);
374    let mut from_ns = split(&current).0.to_string();
375    for depth in 0.. {
376        if depth > 16 {
377            return Err(format!("{id}: parent chain too long"));
378        }
379        let Some((m, _)) = models.model(&current) else {
380            if depth == 0 {
381                return Err(format!("{id}: no such model in the pack"));
382            }
383            t.warnings.push(format!(
384                "{id}: parent {current} not found (no Minecraft client jar?) — kept as the parent"
385            ));
386            parent = Some(current.clone());
387            break;
388        };
389        if depth == 0 {
390            from_ns = split(&current).0.to_string();
391        }
392        if let Some(Value::Object(tx)) = m.get("textures") {
393            for (k, v) in tx {
394                if !textures.iter().any(|(s, _)| s == k)
395                    && let Some(s) = v.as_str()
396                {
397                    textures.push((k.clone(), s.to_string()));
398                }
399            }
400        }
401        if elements.is_none()
402            && let Some(Value::Array(e)) = m.get("elements")
403        {
404            elements = Some(e.clone());
405        }
406        if let Some(Value::Object(d)) = m.get("display") {
407            for (k, v) in d {
408                display.entry(k.clone()).or_insert_with(|| v.clone());
409            }
410        }
411        if ao.is_none() {
412            ao = m.get("ambientocclusion").and_then(Value::as_bool);
413        }
414        if gui_light.is_none() {
415            gui_light = m.get("gui_light").and_then(Value::as_str).map(String::from);
416        }
417        if render_type.is_none() {
418            render_type = m
419                .get("render_type")
420                .and_then(Value::as_str)
421                .map(|s| s.trim_start_matches("minecraft:").to_string());
422        }
423        let Some(p) = m.get("parent").and_then(Value::as_str) else {
424            break;
425        };
426        if p.starts_with("builtin/") || p.starts_with("minecraft:builtin/") {
427            if p.ends_with("generated") {
428                parent = Some("generated".into());
429                break;
430            }
431            return Err(format!("{id}: built-in model {p} can't be imported"));
432        }
433        if elements.is_none()
434            && let Some(tpl) = template_of(p)
435            && (!tpl.sprite || item)
436        {
437            parent = Some(tpl.name.to_string());
438            break;
439        }
440        current = full_id(p);
441    }
442    // A sprite parent reached through the jar: `item/generated` itself.
443    if elements.is_none() && parent.is_none() {
444        parent = Some(if item {
445            "generated".into()
446        } else {
447            "cube_all".into()
448        });
449    }
450
451    let mut out = J::obj::<&str>([]);
452    if let Some(p) = &parent {
453        out.set("parent", J::string(p.clone()));
454    }
455    // Textures that are references to other slots stay; ids get copied
456    // when the pack has them.
457    let mut tex = J::obj::<&str>([]);
458    let tex_ns = t.texture_ns(&from_ns);
459    for (slot, v) in &textures {
460        let value = if v.starts_with('#') {
461            v.clone()
462        } else {
463            let (ns, path) = split(v);
464            let rel = format!("assets/{ns}/textures/{path}.png");
465            match pack.read(&rel) {
466                Some(png) if valid_path(path) => {
467                    let meta = pack.read(&format!("{rel}.mcmeta"));
468                    if ns != tex_ns {
469                        t.warnings.push(format!(
470                            "{id}: texture {ns}:{path} is bundled as {tex_ns}:{path}"
471                        ));
472                    }
473                    t.put_texture(path, &png, meta.as_deref())?
474                }
475                _ => full_id(v),
476            }
477        };
478        tex.set(slot, J::string(value));
479    }
480    // Templates already define `particle` — drop a duplicate of a slot.
481    if let Some(tpl) = parent.as_deref().and_then(template)
482        && tpl.has_particle
483        && let V::Obj(m) = &mut tex.v
484    {
485        m.retain(|(k, _)| k != "particle");
486    }
487    if !tex.members().is_empty() {
488        out.set("textures", tex);
489    }
490    if let Some(els) = &elements {
491        let converted: Vec<J> = els
492            .iter()
493            .enumerate()
494            .map(|(i, e)| element_from_mc(t, id, i, e))
495            .collect();
496        out.set("elements", J::of(V::Arr(converted)));
497    }
498    if !display.is_empty() {
499        out.set("display", J::from_value(&Value::Object(display)));
500    }
501    if let Some(ao) = ao {
502        out.set("ambient_occlusion", J::of(V::Bool(ao)));
503    }
504    if let Some(g) = gui_light {
505        out.set("gui_light", J::string(g));
506    }
507    Ok((out, render_type))
508}
509
510/// The nearest allowed element angle (-45..45 by 22.5).
511fn snap_angle(t: &mut Target, what: &str, a: f64) -> f64 {
512    let snapped = ((a / 22.5).round() * 22.5).clamp(-45.0, 45.0);
513    if (snapped - a).abs() > 1e-6 {
514        t.warnings.push(format!(
515            "{what}: rotation {a}° → {snapped}° (Minecraft allows -45..45 by 22.5)"
516        ));
517    }
518    snapped
519}
520
521fn clamp_coord(t: &mut Target, what: &str, v: f64) -> f64 {
522    let c = v.clamp(-16.0, 32.0);
523    if c != v {
524        t.warnings
525            .push(format!("{what}: {v} is outside -16..32, clamped"));
526    }
527    (c * 10000.0).round() / 10000.0
528}
529
530/// A Minecraft model element → a content element (same shape, `tint`).
531fn element_from_mc(t: &mut Target, id: &str, i: usize, e: &Value) -> J {
532    let what = format!("{id} element {i}");
533    let coords = |t: &mut Target, v: Option<&Value>| -> Vec<f64> {
534        let a: Vec<f64> = v
535            .and_then(Value::as_array)
536            .map(|a| a.iter().filter_map(Value::as_f64).collect())
537            .unwrap_or_default();
538        a.into_iter().map(|n| clamp_coord(t, &what, n)).collect()
539    };
540    let mut out = J::obj::<&str>([]);
541    if let Some(n) = e.get("name").and_then(Value::as_str) {
542        out.set("name", J::string(n));
543    }
544    out.set("from", J::nums(&coords(t, e.get("from"))));
545    out.set("to", J::nums(&coords(t, e.get("to"))));
546    if let Some(r) = e.get("rotation") {
547        let angle = r.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
548        let angle = snap_angle(t, &what, angle);
549        if angle != 0.0 {
550            let mut rot = J::obj([(
551                "axis",
552                J::string(r.get("axis").and_then(Value::as_str).unwrap_or("y")),
553            )]);
554            rot.set("angle", J::number(angle));
555            rot.set("origin", J::nums(&coords(t, r.get("origin"))));
556            if r.get("rescale").and_then(Value::as_bool) == Some(true) {
557                rot.set("rescale", J::of(V::Bool(true)));
558            }
559            out.set("rotation", rot);
560        }
561    }
562    if e.get("shade").and_then(Value::as_bool) == Some(false) {
563        out.set("shade", J::of(V::Bool(false)));
564    }
565    let mut faces = J::obj::<&str>([]);
566    if let Some(Value::Object(f)) = e.get("faces") {
567        for dir in super::DIRECTIONS {
568            let Some(face) = f.get(dir) else { continue };
569            let mut fo = J::obj([(
570                "texture",
571                J::string(
572                    face.get("texture")
573                        .and_then(Value::as_str)
574                        .unwrap_or("#missing"),
575                ),
576            )]);
577            if let Some(uv) = face.get("uv").and_then(Value::as_array) {
578                let uv: Vec<f64> = uv
579                    .iter()
580                    .filter_map(Value::as_f64)
581                    .map(|n| (n.clamp(0.0, 16.0) * 10000.0).round() / 10000.0)
582                    .collect();
583                fo.set("uv", J::nums(&uv));
584            }
585            if let Some(r) = face
586                .get("rotation")
587                .and_then(Value::as_f64)
588                .filter(|r| *r != 0.0)
589            {
590                fo.set("rotation", J::number(r));
591            }
592            if let Some(c) = face.get("cullface").and_then(Value::as_str) {
593                fo.set(
594                    "cullface",
595                    J::string(if c == "bottom" { "down" } else { c }),
596                );
597            }
598            if let Some(n) = face.get("tintindex").and_then(Value::as_f64) {
599                fo.set("tint", J::number(n));
600            }
601            faces.set(dir, fo);
602        }
603    }
604    out.set("faces", faces);
605    out
606}
607
608/// The blockstate's rotations (`"variants": {"": [...]}`) — or none.
609fn state_rotations(t: &mut Target, id: &str, bs: &Value) -> (Option<String>, Option<J>) {
610    let one = |v: &Value| -> (Option<String>, J) {
611        let mut r = J::obj::<&str>([]);
612        for k in ["x", "y"] {
613            if let Some(n) = v.get(k).and_then(Value::as_f64).filter(|n| *n != 0.0) {
614                r.set(k, J::number(n));
615            }
616        }
617        if let Some(w) = v.get("weight").and_then(Value::as_f64) {
618            r.set("weight", J::number(w));
619        }
620        if v.get("uvlock").and_then(Value::as_bool) == Some(true) {
621            r.set("uvlock", J::of(V::Bool(true)));
622        }
623        (v.get("model").and_then(Value::as_str).map(String::from), r)
624    };
625    let variant = if let Some(Value::Object(vs)) = bs.get("variants") {
626        if vs.len() > 1 {
627            t.warnings.push(format!(
628                "{id}: the blockstate has {} variants (block state properties) — only the first is imported; content files don't do properties yet",
629                vs.len()
630            ));
631        }
632        vs.values().next().cloned()
633    } else if let Some(Value::Array(parts)) = bs.get("multipart") {
634        t.warnings.push(format!(
635            "{id}: multipart blockstate — only the first part is imported"
636        ));
637        parts.first().and_then(|p| p.get("apply")).cloned()
638    } else {
639        None
640    };
641    match variant {
642        Some(Value::Array(list)) => {
643            let mut model = None;
644            let mut rots = Vec::new();
645            for v in &list {
646                let (m, r) = one(v);
647                model = model.or(m);
648                rots.push(r);
649            }
650            (model, Some(J::of(V::Arr(rots))))
651        }
652        Some(v) => {
653            let (m, r) = one(&v);
654            let rots = (!r.members().is_empty()).then(|| J::of(V::Arr(vec![r])));
655            (m, rots)
656        }
657        None => (None, None),
658    }
659}
660
661/// The pack's English name for a key, if it has a plain `en_us.json`.
662fn lang_name(pack: &Pack, ns: &str, key: &str) -> Option<String> {
663    let bytes = pack.read(&format!("assets/{ns}/lang/en_us.json"))?;
664    let m: Map<String, Value> = serde_json::from_slice(&bytes).ok()?;
665    m.get(key).and_then(Value::as_str).map(String::from)
666}
667
668fn header(t: &Target, ns: &str, name: &str, pack: &Pack, kind: &str) -> J {
669    let mut j = J::obj::<&str>([]);
670    if let Some(id) = t.id_for(ns, name) {
671        j.set("id", J::string(id));
672    }
673    if let Some(n) = lang_name(pack, ns, &format!("{kind}.{ns}.{}", name.replace('/', "."))) {
674        j.set("name", J::string(n));
675    }
676    j
677}
678
679fn import_block(
680    t: &mut Target,
681    models: &mut Models,
682    pack: &Pack,
683    ns: &str,
684    name: &str,
685) -> Result<(), String> {
686    let bs: Option<Value> = pack
687        .read(&format!("assets/{ns}/blockstates/{name}.json"))
688        .and_then(|b| serde_json::from_slice(&b).ok());
689    let (model_id, rotations) = match &bs {
690        Some(bs) => state_rotations(t, &format!("{ns}:{name}"), bs),
691        None => (None, None),
692    };
693    let model_id = model_id.unwrap_or_else(|| format!("{ns}:block/{name}"));
694    let (model, render) = convert_model(t, models, pack, &model_id, false)?;
695    let mut j = header(t, ns, name, pack, "block");
696    if let Some(r) = render.filter(|r| super::RENDER_LAYERS.contains(&r.as_str()) && r != "solid") {
697        j.set("block", J::obj([("render_layer", J::string(r))]));
698    }
699    j.set("model", model);
700    // Its own item model, unless it's just the block's.
701    if let Some((im, _)) = models.model(&format!("{ns}:item/{name}")) {
702        let plain = im.len() == 1
703            && im.get("parent").and_then(Value::as_str).map(full_id) == Some(full_id(&model_id));
704        if !plain {
705            let (item_model, _) =
706                convert_model(t, models, pack, &format!("{ns}:item/{name}"), true)?;
707            j.set("item_model", item_model);
708        }
709    }
710    if let Some(r) = rotations {
711        j.set("states", J::obj([("rotations", r)]));
712    }
713    let leaf = name.rsplit('/').next().unwrap_or(name);
714    t.write_file(
715        &format!("content/blocks/{leaf}.yblock"),
716        to_pretty(&j).as_bytes(),
717    )
718}
719
720fn import_item(
721    t: &mut Target,
722    models: &mut Models,
723    pack: &Pack,
724    ns: &str,
725    name: &str,
726) -> Result<(), String> {
727    let (model, _) = convert_model(t, models, pack, &format!("{ns}:item/{name}"), true)?;
728    let mut j = header(t, ns, name, pack, "item");
729    j.set("model", model);
730    let leaf = name.rsplit('/').next().unwrap_or(name);
731    t.write_file(
732        &format!("content/items/{leaf}.yitem"),
733        to_pretty(&j).as_bytes(),
734    )
735}
736
737fn import_pack(t: &mut Target, pack: &Pack, model: Option<&str>, all: bool) -> Result<(), String> {
738    let mut models = Models {
739        pack,
740        jar: None,
741        jar_tried: false,
742        root: t.root.clone(),
743    };
744    let blockstates = pack.list("blockstates");
745    let block_models = pack.list("models/block");
746    let item_models = pack.list("models/item");
747    if let Some(m) = model {
748        let (ns, path) = split(m);
749        let ns = if m.contains(':') {
750            ns.to_string()
751        } else {
752            t.ns.clone().unwrap_or_else(|| "minecraft".into())
753        };
754        let path = if m.contains(':') { path } else { m };
755        if let Some(name) = path.strip_prefix("block/") {
756            return import_block(t, &mut models, pack, &ns, name);
757        }
758        if let Some(name) = path.strip_prefix("item/") {
759            return import_item(t, &mut models, pack, &ns, name);
760        }
761        if blockstates.iter().any(|(n, p)| *n == ns && p == path)
762            || block_models.iter().any(|(n, p)| *n == ns && p == path)
763        {
764            return import_block(t, &mut models, pack, &ns, path);
765        }
766        if item_models.iter().any(|(n, p)| *n == ns && p == path) {
767            return import_item(t, &mut models, pack, &ns, path);
768        }
769        return Err(format!("no block or item {ns}:{path} in the pack"));
770    }
771    if !all {
772        let mut list = String::new();
773        for (ns, p) in blockstates.iter().chain(&block_models) {
774            list.push_str(&format!("\n  {ns}:block/{p}"));
775        }
776        for (ns, p) in &item_models {
777            list.push_str(&format!("\n  {ns}:item/{p}"));
778        }
779        return Err(format!(
780            "pick one with --model <id>, or --all for every block (blockstate) and item. The pack has:{}",
781            if list.is_empty() {
782                "\n  (no models)".to_string()
783            } else {
784                list
785            }
786        ));
787    }
788    let mut failed = Vec::new();
789    for (ns, name) in &blockstates {
790        if let Err(e) = import_block(t, &mut models, pack, ns, name) {
791            failed.push(e);
792        }
793    }
794    for (ns, name) in &item_models {
795        // A block's item model came with the block.
796        if blockstates.iter().any(|(n, p)| n == ns && p == name) {
797            continue;
798        }
799        if let Err(e) = import_item(t, &mut models, pack, ns, name) {
800            failed.push(e);
801        }
802    }
803    t.warnings.extend(failed);
804    Ok(())
805}
806
807// ── Blockbench ───────────────────────────────────────────────────────────────
808
809/// Standard base64 (data URLs of Blockbench textures).
810fn base64_decode(s: &str) -> Option<Vec<u8>> {
811    let mut out = Vec::with_capacity(s.len() * 3 / 4);
812    let (mut acc, mut bits) = (0u32, 0u32);
813    for c in s.bytes() {
814        let v = match c {
815            b'A'..=b'Z' => c - b'A',
816            b'a'..=b'z' => c - b'a' + 26,
817            b'0'..=b'9' => c - b'0' + 52,
818            b'+' | b'-' => 62,
819            b'/' | b'_' => 63,
820            b'=' | b'\n' | b'\r' | b' ' => continue,
821            _ => return None,
822        } as u32;
823        acc = (acc << 6) | v;
824        bits += 6;
825        if bits >= 8 {
826            bits -= 8;
827            out.push((acc >> bits) as u8);
828            acc &= (1 << bits) - 1;
829        }
830    }
831    Some(out)
832}
833
834/// `My Texture (1).png` → `my_texture_1`.
835fn sanitize(name: &str) -> String {
836    let stem = name.strip_suffix(".png").unwrap_or(name);
837    let mut s = String::new();
838    for c in stem.chars() {
839        if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
840            s.push(c.to_ascii_lowercase());
841        } else if !s.ends_with('_') {
842            s.push('_');
843        }
844    }
845    let s = s.trim_matches('_').to_string();
846    if s.is_empty() { "texture".into() } else { s }
847}
848
849fn import_bbmodel(t: &mut Target, file: &Path, id: Option<&str>, item: bool) -> Result<(), String> {
850    let text = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?;
851    let bb: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", file.display()))?;
852    let format = bb
853        .pointer("/meta/model_format")
854        .and_then(Value::as_str)
855        .unwrap_or("");
856    if !matches!(format, "java_block" | "free" | "") {
857        return Err(format!(
858            "{}: a {format:?} Blockbench model — only the \"Java Block/Item\" format maps to Minecraft models (File > Convert Project)",
859            file.display()
860        ));
861    }
862    if format == "free" {
863        t.warnings
864            .push("a generic Blockbench model: rotations get snapped to Minecraft's".into());
865    }
866    let name = match id {
867        Some(i) => i.to_string(),
868        None => {
869            let n = bb
870                .get("name")
871                .and_then(Value::as_str)
872                .filter(|s| !s.is_empty())
873                .map(String::from);
874            sanitize(&n.unwrap_or_else(|| {
875                file.file_stem()
876                    .unwrap_or_default()
877                    .to_string_lossy()
878                    .into_owned()
879            }))
880        }
881    };
882    let (id_ns, name) = match name.split_once(':') {
883        Some((n, p)) => (Some(n.to_string()), p.to_string()),
884        None => (None, name),
885    };
886    if !valid_path(&name) {
887        return Err(format!("`{name}` isn't a valid id — pass --id <name>"));
888    }
889    let ns = id_ns
890        .clone()
891        .or_else(|| t.ns.clone())
892        .ok_or("the mod id isn't a valid namespace — pass --id <namespace>:<name>")?;
893    let res_w = bb
894        .pointer("/resolution/width")
895        .and_then(Value::as_f64)
896        .unwrap_or(16.0);
897    let res_h = bb
898        .pointer("/resolution/height")
899        .and_then(Value::as_f64)
900        .unwrap_or(16.0);
901    let folder = if item { "item" } else { "block" };
902
903    // Textures: slot = index ("0", "1", ...), like Blockbench's export.
904    let mut tex = J::obj::<&str>([]);
905    let mut uv_scale: Vec<(f64, f64)> = Vec::new();
906    let mut particle: Option<String> = None;
907    let textures = bb
908        .get("textures")
909        .and_then(Value::as_array)
910        .cloned()
911        .unwrap_or_default();
912    for (i, tx) in textures.iter().enumerate() {
913        let slot = i.to_string();
914        let tex_name = sanitize(tx.get("name").and_then(Value::as_str).unwrap_or("texture"));
915        let uw = tx.get("uv_width").and_then(Value::as_f64).unwrap_or(res_w);
916        let uh = tx.get("uv_height").and_then(Value::as_f64).unwrap_or(res_h);
917        uv_scale.push((16.0 / uw, 16.0 / uh));
918        let source = tx.get("source").and_then(Value::as_str).unwrap_or("");
919        let value = if let Some(data) = source.strip_prefix("data:image/png;base64,") {
920            let png = base64_decode(data).ok_or("a texture's data isn't base64")?;
921            let tex_folder = tx
922                .get("folder")
923                .and_then(Value::as_str)
924                .filter(|f| valid_path(f))
925                .unwrap_or(folder);
926            t.put_texture(&format!("{tex_folder}/{tex_name}"), &png, None)?
927        } else {
928            let tns = tx
929                .get("namespace")
930                .and_then(Value::as_str)
931                .filter(|s| !s.is_empty())
932                .unwrap_or("minecraft");
933            let tfolder = tx
934                .get("folder")
935                .and_then(Value::as_str)
936                .filter(|s| !s.is_empty())
937                .unwrap_or(folder);
938            t.warnings.push(format!("texture {tex_name} isn't embedded in the .bbmodel — referenced as {tns}:{tfolder}/{tex_name}"));
939            format!("{tns}:{tfolder}/{tex_name}")
940        };
941        if tx.get("particle").and_then(Value::as_bool) == Some(true) {
942            particle = Some(value.clone());
943        }
944        tex.set(&slot, J::string(value));
945    }
946    if let Some(p) = particle {
947        tex.set("particle", J::string(p));
948    }
949    let uuid_slot: BTreeMap<String, usize> = textures
950        .iter()
951        .enumerate()
952        .filter_map(|(i, tx)| {
953            tx.get("uuid")
954                .and_then(Value::as_str)
955                .map(|u| (u.to_string(), i))
956        })
957        .collect();
958
959    let mut elements = Vec::new();
960    for (i, e) in bb
961        .get("elements")
962        .and_then(Value::as_array)
963        .cloned()
964        .unwrap_or_default()
965        .iter()
966        .enumerate()
967    {
968        let what = format!(
969            "element {i} ({})",
970            e.get("name").and_then(Value::as_str).unwrap_or("?")
971        );
972        if e.get("type")
973            .and_then(Value::as_str)
974            .is_some_and(|ty| ty != "cube")
975        {
976            t.warnings.push(format!("{what}: not a cube — skipped"));
977            continue;
978        }
979        if e.get("visibility").and_then(Value::as_bool) == Some(false) {
980            t.warnings
981                .push(format!("{what}: hidden in Blockbench — imported anyway"));
982        }
983        let vec3 = |t: &mut Target, k: &str| -> Vec<f64> {
984            let a: Vec<f64> = e
985                .get(k)
986                .and_then(Value::as_array)
987                .map(|a| a.iter().filter_map(Value::as_f64).collect())
988                .unwrap_or_default();
989            a.into_iter().map(|n| clamp_coord(t, &what, n)).collect()
990        };
991        let mut el = J::obj::<&str>([]);
992        if let Some(n) = e.get("name").and_then(Value::as_str) {
993            el.set("name", J::string(n));
994        }
995        // Blockbench's inflate grows the box both ways.
996        let inflate = e.get("inflate").and_then(Value::as_f64).unwrap_or(0.0);
997        let mut from = vec3(t, "from");
998        let mut to = vec3(t, "to");
999        if inflate != 0.0 {
1000            for v in &mut from {
1001                *v -= inflate;
1002            }
1003            for v in &mut to {
1004                *v += inflate;
1005            }
1006        }
1007        el.set("from", J::nums(&from));
1008        el.set("to", J::nums(&to));
1009        let rot: Vec<f64> = e
1010            .get("rotation")
1011            .and_then(Value::as_array)
1012            .map(|a| a.iter().filter_map(Value::as_f64).collect())
1013            .unwrap_or_default();
1014        let nonzero: Vec<(usize, f64)> = rot
1015            .iter()
1016            .copied()
1017            .enumerate()
1018            .filter(|(_, a)| *a != 0.0)
1019            .collect();
1020        if nonzero.len() > 1 {
1021            t.warnings.push(format!("{what}: rotated on more than one axis — only the first kept (Minecraft rotates elements on one axis)"));
1022        }
1023        if let Some((axis, angle)) = nonzero.first().copied() {
1024            let angle = snap_angle(t, &what, angle);
1025            if angle != 0.0 {
1026                let mut r = J::obj([("axis", J::string(["x", "y", "z"][axis.min(2)]))]);
1027                r.set("angle", J::number(angle));
1028                r.set("origin", J::nums(&vec3(t, "origin")));
1029                if e.get("rescale").and_then(Value::as_bool) == Some(true) {
1030                    r.set("rescale", J::of(V::Bool(true)));
1031                }
1032                el.set("rotation", r);
1033            }
1034        }
1035        if e.get("shade").and_then(Value::as_bool) == Some(false) {
1036            el.set("shade", J::of(V::Bool(false)));
1037        }
1038        let mut faces = J::obj::<&str>([]);
1039        for dir in super::DIRECTIONS {
1040            let Some(f) = e.pointer(&format!("/faces/{dir}")) else {
1041                continue;
1042            };
1043            let index = match f.get("texture") {
1044                Some(Value::Number(n)) => n.as_u64().map(|n| n as usize),
1045                Some(Value::String(u)) => uuid_slot.get(u).copied(),
1046                _ => None,
1047            };
1048            let Some(index) = index.filter(|i| *i < textures.len()) else {
1049                continue; // No texture: Blockbench doesn't export the face.
1050            };
1051            let (su, sv) = uv_scale[index];
1052            let mut fo = J::obj([("texture", J::string(format!("#{index}")))]);
1053            if let Some(uv) = f.get("uv").and_then(Value::as_array) {
1054                let uv: Vec<f64> = uv
1055                    .iter()
1056                    .filter_map(Value::as_f64)
1057                    .enumerate()
1058                    .map(|(k, n)| {
1059                        ((n * if k % 2 == 0 { su } else { sv }).clamp(0.0, 16.0) * 10000.0).round()
1060                            / 10000.0
1061                    })
1062                    .collect();
1063                fo.set("uv", J::nums(&uv));
1064            }
1065            if let Some(r) = f
1066                .get("rotation")
1067                .and_then(Value::as_f64)
1068                .filter(|r| *r != 0.0)
1069            {
1070                fo.set("rotation", J::number(r));
1071            }
1072            if let Some(c) = f
1073                .get("cullface")
1074                .and_then(Value::as_str)
1075                .filter(|c| !c.is_empty())
1076            {
1077                fo.set("cullface", J::string(c));
1078            }
1079            if let Some(n) = f.get("tint").and_then(Value::as_f64).filter(|n| *n >= 0.0) {
1080                fo.set("tint", J::number(n));
1081            }
1082            faces.set(dir, fo);
1083        }
1084        el.set("faces", faces);
1085        elements.push(el);
1086    }
1087
1088    let mut model = J::obj::<&str>([]);
1089    let parent = bb
1090        .get("parent")
1091        .and_then(Value::as_str)
1092        .filter(|p| !p.is_empty());
1093    if let Some(p) = parent {
1094        match template_of(p) {
1095            Some(tpl) if elements.is_empty() => model.set("parent", J::string(tpl.name)),
1096            _ if elements.is_empty() => model.set("parent", J::string(full_id(p))),
1097            _ => {}
1098        }
1099    } else if elements.is_empty() {
1100        return Err("the model has no cubes".into());
1101    }
1102    if !tex.members().is_empty() {
1103        model.set("textures", tex);
1104    }
1105    if !elements.is_empty() {
1106        model.set("elements", J::of(V::Arr(elements)));
1107    }
1108    if let Some(Value::Object(d)) = bb.get("display") {
1109        let mut display = J::obj::<&str>([]);
1110        for (slot, v) in d {
1111            if !super::DISPLAY_SLOTS.contains(&slot.as_str()) {
1112                continue;
1113            }
1114            let mut o = J::obj::<&str>([]);
1115            for k in ["rotation", "translation", "scale"] {
1116                if let Some(a) = v.get(k).and_then(Value::as_array) {
1117                    let a: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
1118                    let default = if k == "scale" { [1.0; 3] } else { [0.0; 3] };
1119                    if a.len() == 3 && a[..] != default[..] {
1120                        o.set(k, J::nums(&a));
1121                    }
1122                }
1123            }
1124            if !o.members().is_empty() {
1125                display.set(slot, o);
1126            }
1127        }
1128        if !display.members().is_empty() {
1129            model.set("display", display);
1130        }
1131    }
1132    if bb.get("ambientocclusion").and_then(Value::as_bool) == Some(false) {
1133        model.set("ambient_occlusion", J::of(V::Bool(false)));
1134    }
1135    if bb.get("front_gui_light").and_then(Value::as_bool) == Some(true) {
1136        model.set("gui_light", J::string("front"));
1137    }
1138
1139    let mut j = J::obj::<&str>([]);
1140    if t.ns.is_none() || id_ns.is_some() {
1141        j.set("id", J::string(format!("{ns}:{name}")));
1142    }
1143    j.set("model", model);
1144    let leaf = name.rsplit('/').next().unwrap_or(&name).to_string();
1145    let rel = if item {
1146        format!("content/items/{leaf}.yitem")
1147    } else {
1148        format!("content/blocks/{leaf}.yblock")
1149    };
1150    t.write_file(&rel, to_pretty(&j).as_bytes())
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156
1157    fn temp(name: &str) -> PathBuf {
1158        let d = std::env::temp_dir().join(format!("yog-import-test-{}-{name}", std::process::id()));
1159        let _ = std::fs::remove_dir_all(&d);
1160        std::fs::create_dir_all(&d).unwrap();
1161        d
1162    }
1163
1164    fn target(root: &Path) -> Target {
1165        Target {
1166            root: root.to_path_buf(),
1167            ns: Some("mymod".into()),
1168            force: false,
1169            written: Vec::new(),
1170            warnings: Vec::new(),
1171        }
1172    }
1173
1174    #[test]
1175    fn decodes_base64() {
1176        assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello");
1177        assert_eq!(base64_decode("aGk").unwrap(), b"hi");
1178    }
1179
1180    #[test]
1181    fn imports_a_pack_block_and_compiles_it() {
1182        let pack_dir = temp("pack");
1183        let w = |rel: &str, s: &[u8]| {
1184            let p = pack_dir.join(rel);
1185            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1186            std::fs::write(p, s).unwrap();
1187        };
1188        w(
1189            "assets/foo/blockstates/lamp.json",
1190            br#"{"variants":{"":[{"model":"foo:block/lamp"},{"model":"foo:block/lamp","y":90}]}}"#,
1191        );
1192        w("assets/foo/models/block/lamp_base.json", br##"{"textures":{"0":"foo:block/lamp","particle":"#0"},"render_type":"cutout",
1193            "elements":[{"from":[4,0,4],"to":[12,8,12],"rotation":{"angle":20,"axis":"y","origin":[8,8,8]},
1194            "faces":{"north":{"uv":[0,0,8,8],"texture":"#0","cullface":"north"}}}]}"##);
1195        w(
1196            "assets/foo/models/block/lamp.json",
1197            br#"{"parent":"foo:block/lamp_base"}"#,
1198        );
1199        w(
1200            "assets/foo/models/item/lamp.json",
1201            br#"{"parent":"foo:block/lamp"}"#,
1202        );
1203        w("assets/foo/textures/block/lamp.png", b"PNG");
1204        w(
1205            "assets/foo/lang/en_us.json",
1206            br#"{"block.foo.lamp":"Lamp"}"#,
1207        );
1208        let root = temp("mod");
1209        let mut t = target(&root);
1210        let pack = Pack::open(&pack_dir).unwrap();
1211        import_pack(&mut t, &pack, Some("foo:block/lamp"), false).unwrap();
1212        assert!(
1213            t.warnings.iter().any(|w| w.contains("20° → 22.5°")),
1214            "{:?}",
1215            t.warnings
1216        );
1217        let text = std::fs::read_to_string(root.join("content/blocks/lamp.yblock")).unwrap();
1218        assert!(text.contains("\"name\": \"Lamp\""), "{text}");
1219        assert!(text.contains("\"render_layer\": \"cutout\""), "{text}");
1220        assert!(text.contains("\"0\": \"block/lamp.png\""), "{text}");
1221        assert!(!text.contains("item_model"), "{text}");
1222        assert_eq!(
1223            std::fs::read(root.join("resources/block/lamp.png")).unwrap(),
1224            b"PNG"
1225        );
1226        let c = super::super::compile(&root, "mymod", &crate::yres::Refs::default()).unwrap();
1227        let bs = c
1228            .outputs
1229            .iter()
1230            .find(|(p, _)| p == "assets/mymod/blockstates/lamp.json")
1231            .unwrap();
1232        let bs: Value = serde_json::from_slice(&bs.1).unwrap();
1233        assert_eq!(bs["variants"][""][1]["y"], serde_json::json!(90));
1234        let _ = std::fs::remove_dir_all(pack_dir);
1235        let _ = std::fs::remove_dir_all(root);
1236    }
1237
1238    #[test]
1239    fn imports_a_bbmodel() {
1240        let root = temp("bb");
1241        let bb = root.join("thing.bbmodel");
1242        std::fs::write(
1243            &bb,
1244            r#"{"meta":{"model_format":"java_block"},"name":"Thing","resolution":{"width":32,"height":32},
1245                "textures":[{"name":"thing.png","uuid":"u1","source":"data:image/png;base64,aGVsbG8="}],
1246                "elements":[{"name":"cube","from":[0,0,0],"to":[16,8,16],"origin":[8,8,8],"rotation":[0,45,0],
1247                  "faces":{"north":{"uv":[0,0,32,16],"texture":0},"up":{"uv":[0,0,32,32],"texture":null}}}]}"#,
1248        )
1249        .unwrap();
1250        let mut t = target(&root);
1251        import_bbmodel(&mut t, &bb, None, false).unwrap();
1252        let text = std::fs::read_to_string(root.join("content/blocks/thing.yblock")).unwrap();
1253        assert!(text.contains("\"uv\": [0, 0, 16, 8]"), "{text}");
1254        assert!(!text.contains("\"up\""), "{text}");
1255        assert_eq!(
1256            std::fs::read(root.join("resources/block/thing.png")).unwrap(),
1257            b"hello"
1258        );
1259        super::super::compile(&root, "mymod", &crate::yres::Refs::default()).unwrap();
1260        let _ = std::fs::remove_dir_all(root);
1261    }
1262}