1use 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
20pub 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
98struct Target {
100 root: PathBuf,
101 ns: Option<String>,
103 force: bool,
104 written: Vec<String>,
105 warnings: Vec<String>,
106}
107
108impl Target {
109 fn id_for(&self, from_ns: &str, name: &str) -> Option<String> {
112 self.ns.is_none().then(|| format!("{from_ns}:{name}"))
113 }
114
115 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 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 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
167enum 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 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 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
260fn 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
313struct 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
342fn 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
352fn template_of(id: &str) -> Option<&'static super::Template> {
354 let id = full_id(id);
355 TEMPLATES.iter().find(|t| t.parent == id)
356}
357
358fn 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(¤t).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(¤t) 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(¤t).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 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 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 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
510fn 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
530fn 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
608fn 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
661fn 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 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 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
807fn 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
834fn 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 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 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; };
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}