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