1mod import;
23pub mod json;
24
25pub use import::import_command;
26
27use crate::yres;
28use json::{J, V, num_value};
29use serde_json::{Map, Value, json};
30use std::collections::{BTreeMap, BTreeSet, HashMap};
31use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34pub const CONTENT_DIR: &str = "content";
36pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
38
39pub type Output = (String, Vec<u8>);
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Kind {
45 Block,
47 Item,
49 Recipe,
51 Book,
53}
54
55impl Kind {
56 pub fn of(path: &Path) -> Option<Kind> {
57 match path.extension()?.to_str()? {
58 "yblock" => Some(Kind::Block),
59 "yitem" => Some(Kind::Item),
60 "yrecipe" => Some(Kind::Recipe),
61 "ybook" => Some(Kind::Book),
62 _ => None,
63 }
64 }
65
66 fn word(self) -> &'static str {
67 match self {
68 Kind::Block => "block",
69 Kind::Item => "item",
70 Kind::Recipe => "recipe",
71 Kind::Book => "book",
72 }
73 }
74}
75
76#[derive(Debug, Default)]
78pub struct Compiled {
79 pub outputs: Vec<Output>,
81 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
84 lang_aliases: Vec<(String, String)>,
86 tags: BTreeMap<String, BTreeSet<String>>,
88 pub source: Option<String>,
90 pub warnings: Vec<String>,
91 pub blocks: usize,
92 pub items: usize,
93 pub recipes: usize,
94 pub books: usize,
95}
96
97pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
100pub const DISPLAY_SLOTS: [&str; 8] = [
101 "thirdperson_righthand",
102 "thirdperson_lefthand",
103 "firstperson_righthand",
104 "firstperson_lefthand",
105 "gui",
106 "head",
107 "ground",
108 "fixed",
109];
110pub const SOUNDS: [&str; 10] = [
112 "stone",
113 "wood",
114 "grass",
115 "gravel",
116 "sand",
117 "snow",
118 "metal",
119 "glass",
120 "wool",
121 "nether_brick",
122];
123pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
124pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
125pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
126pub const RECIPE_TYPES: [&str; 8] =
127 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
128
129pub struct Template {
131 pub name: &'static str,
132 pub parent: &'static str,
134 pub slots: &'static [&'static str],
136 pub sprite: bool,
138 pub has_particle: bool,
140}
141
142pub const TEMPLATES: &[Template] = &[
143 Template {
144 name: "cube_all",
145 parent: "minecraft:block/cube_all",
146 slots: &["all"],
147 sprite: false,
148 has_particle: true,
149 },
150 Template {
151 name: "cube_column",
152 parent: "minecraft:block/cube_column",
153 slots: &["end", "side"],
154 sprite: false,
155 has_particle: true,
156 },
157 Template {
158 name: "cube_bottom_top",
159 parent: "minecraft:block/cube_bottom_top",
160 slots: &["top", "bottom", "side"],
161 sprite: false,
162 has_particle: true,
163 },
164 Template {
165 name: "cube",
166 parent: "minecraft:block/cube",
167 slots: &["down", "up", "north", "south", "west", "east"],
168 sprite: false,
169 has_particle: false,
170 },
171 Template {
172 name: "orientable",
173 parent: "minecraft:block/orientable",
174 slots: &["front", "side", "top"],
175 sprite: false,
176 has_particle: true,
177 },
178 Template {
179 name: "cross",
180 parent: "minecraft:block/cross",
181 slots: &["cross"],
182 sprite: false,
183 has_particle: true,
184 },
185 Template {
186 name: "generated",
187 parent: "minecraft:item/generated",
188 slots: &["layer0"],
189 sprite: true,
190 has_particle: true,
191 },
192 Template {
193 name: "handheld",
194 parent: "minecraft:item/handheld",
195 slots: &["layer0"],
196 sprite: true,
197 has_particle: true,
198 },
199];
200
201pub fn template(name: &str) -> Option<&'static Template> {
202 TEMPLATES.iter().find(|t| t.name == name)
203}
204
205pub fn valid_namespace(s: &str) -> bool {
207 !s.is_empty()
208 && s.bytes().all(|b| {
209 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
210 })
211}
212
213pub fn valid_path(s: &str) -> bool {
215 !s.is_empty()
216 && !s.starts_with('/')
217 && !s.ends_with('/')
218 && s.bytes().all(|b| {
219 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
220 })
221}
222
223pub fn valid_id(s: &str) -> bool {
225 match s.split_once(':') {
226 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
227 None => valid_path(s),
228 }
229}
230
231pub fn find(root: &Path) -> Vec<PathBuf> {
235 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
236 let Ok(entries) = std::fs::read_dir(dir) else {
237 return;
238 };
239 for entry in entries.flatten() {
240 let path = entry.path();
241 if path.is_dir() {
242 walk(&path, out);
243 } else if Kind::of(&path).is_some() {
244 out.push(path);
245 }
246 }
247 }
248 let mut out = Vec::new();
249 walk(&root.join(CONTENT_DIR), &mut out);
250 out.sort();
251 out
252}
253
254fn display(root: &Path, p: &Path) -> String {
255 p.strip_prefix(root)
256 .unwrap_or(p)
257 .to_string_lossy()
258 .replace('\\', "/")
259}
260
261#[derive(Debug, Clone)]
265enum Text {
266 Key(String),
268 Literal(String),
269}
270
271#[derive(Debug, Clone, Default)]
272struct BlockProps {
273 hardness: Option<f64>,
274 resistance: Option<f64>,
275 sound: Option<String>,
276 light: u8,
277 tool: Option<String>,
278 tool_level: Option<String>,
279 requires_tool: bool,
280 no_collision: bool,
281 slipperiness: Option<f64>,
282 shape: Option<[f64; 6]>,
284 connect_groups: Vec<String>,
285 connects: bool,
286 inventory: Option<String>,
288 redstone: bool,
290}
291
292#[derive(Debug, Clone, Default)]
293struct ItemProps {
294 stack_size: Option<u8>,
295 durability: u32,
296 fire_resistant: bool,
297 fuel_ticks: u32,
298 food: Option<(u32, f64, bool)>,
300}
301
302#[derive(Debug, Clone)]
304struct Entry {
305 kind: Kind,
306 id: String,
307 file: String,
308 auto: bool,
309 block: BlockProps,
310 item: ItemProps,
311}
312
313struct Ctx<'a> {
315 root: &'a Path,
316 refs: &'a yres::Refs,
317 file: String,
318 ns: String,
319 errors: &'a mut Vec<String>,
320 warnings: &'a mut Vec<String>,
321 textures: &'a mut BTreeMap<String, PathBuf>,
323 inventories: &'a mut Vec<(String, usize, String)>,
326}
327
328impl Ctx<'_> {
329 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
330 self.errors.push(format!("{}:{line}: {msg}", self.file));
331 }
332
333 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
334 self.warnings.push(format!("{}:{line}: {msg}", self.file));
335 }
336
337 fn unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
339 for (k, v) in obj.members() {
340 if !known.contains(&k.as_str()) && !is_metadata(k) {
341 self.err(
342 v.line,
343 format!(
344 "unknown key {k:?} in {what} (expected one of: {})",
345 known.join(", ")
346 ),
347 );
348 }
349 }
350 }
351
352 fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
353 match j.num() {
354 Some(n) if (min..=max).contains(&n) => Some(n),
355 Some(n) => {
356 self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
357 None
358 }
359 None => {
360 self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
361 None
362 }
363 }
364 }
365
366 fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
367 let b = j.bool();
368 if b.is_none() {
369 self.err(j.line, format!("{what} must be true or false"));
370 }
371 b
372 }
373
374 fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
375 let s = j.str();
376 if s.is_none() {
377 self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
378 }
379 s
380 }
381
382 fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
383 let s = self.string(j, what)?;
384 if allowed.contains(&s) {
385 Some(s.to_string())
386 } else {
387 self.err(
388 j.line,
389 format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
390 );
391 None
392 }
393 }
394
395 fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
396 let items = j.items();
397 if !j.is_arr() || items.len() != N {
398 self.err(j.line, format!("{what} must be an array of {N} numbers"));
399 return None;
400 }
401 let mut out = [0.0; N];
402 for (i, item) in items.iter().enumerate() {
403 out[i] = self.number(item, what, min, max)?;
404 }
405 Some(out)
406 }
407
408 fn text(&mut self, j: &J, what: &str) -> Option<Text> {
411 let s = self.string(j, what)?;
412 match s.strip_prefix('@') {
413 Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
414 Some(rest) => match self.refs.texts.get(rest) {
415 Some((key, _)) => Some(Text::Key(key.clone())),
416 None => {
417 self.err(
418 j.line,
419 format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
420 );
421 None
422 }
423 },
424 None => Some(Text::Literal(s.to_string())),
425 }
426 }
427
428 fn texture(&mut self, j: &J, what: &str) -> Option<String> {
432 let s = self.string(j, what)?.trim().to_string();
433 if let Some(slot) = s.strip_prefix('#') {
434 if slot.is_empty() {
435 self.err(j.line, format!("{what}: `#` needs a slot name"));
436 return None;
437 }
438 return Some(s);
439 }
440 if let Some(rest) = s.strip_prefix('@') {
441 return match self.refs.images.get(rest) {
442 Some(id) => Some(id.clone()),
443 None => {
444 self.err(
445 j.line,
446 format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
447 );
448 None
449 }
450 };
451 }
452 if let Some(stem) = s.strip_suffix(".png") {
453 let rel = stem.trim_start_matches("./").trim_start_matches('/');
454 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
455 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
456 self.err(
457 j.line,
458 format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
459 );
460 return None;
461 }
462 let file = self
463 .root
464 .join(yres::RESOURCES_DIR)
465 .join(format!("{rel}.png"));
466 if !file.is_file() {
467 self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
468 return None;
469 }
470 let target = format!("assets/{}/textures/{rel}.png", self.ns);
471 if let Some(other) = self.textures.insert(target.clone(), file.clone())
472 && other != file
473 {
474 self.err(
475 j.line,
476 format!(
477 "{what}: {target} also comes from {}",
478 display(self.root, &other)
479 ),
480 );
481 }
482 let meta = self
483 .root
484 .join(yres::RESOURCES_DIR)
485 .join(format!("{rel}.png.mcmeta"));
486 if meta.is_file() {
487 self.textures.insert(format!("{target}.mcmeta"), meta);
488 }
489 return Some(format!("{}:{rel}", self.ns));
490 }
491 if !valid_id(&s) {
492 self.err(
493 j.line,
494 format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
495 );
496 return None;
497 }
498 let id = if s.contains(':') {
499 s
500 } else {
501 format!("minecraft:{s}")
502 };
503 let (ns, path) = id.split_once(':').unwrap_or_default();
504 if ns == self.ns {
505 let file = self
506 .root
507 .join("assets")
508 .join(ns)
509 .join("textures")
510 .join(format!("{path}.png"));
511 let bundled = self
512 .textures
513 .contains_key(&format!("assets/{ns}/textures/{path}.png"))
514 || self.refs.images.values().any(|v| *v == id);
515 if !file.is_file() && !bundled {
516 self.warn(
517 j.line,
518 format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
519 );
520 }
521 }
522 Some(id)
523 }
524}
525
526struct Model {
528 json: Value,
529 bounds: Option<[f64; 6]>,
531}
532
533fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
536 if !j.is_obj() {
537 cx.err(j.line, format!("{what} must be an object"));
538 return None;
539 }
540 cx.unknown_keys(
541 j,
542 what,
543 &[
544 "parent",
545 "textures",
546 "elements",
547 "display",
548 "ambient_occlusion",
549 "gui_light",
550 ],
551 );
552 let mut out = Map::new();
553 let mut tpl: Option<&Template> = None;
554 let mut raw_parent = false;
555 if let Some(p) = j.get("parent") {
556 let name = cx.string(p, &format!("{what}.parent"))?;
557 if let Some(t) = template(name) {
558 if t.sprite && !item {
559 cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
560 }
561 out.insert("parent".into(), Value::String(t.parent.into()));
562 tpl = Some(t);
563 } else if name.contains(':') && valid_id(name) {
564 out.insert("parent".into(), Value::String(name.into()));
565 raw_parent = true;
566 } else {
567 let names: Vec<&str> = TEMPLATES
568 .iter()
569 .filter(|t| item || !t.sprite)
570 .map(|t| t.name)
571 .collect();
572 cx.err(
573 p.line,
574 format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
575 );
576 return None;
577 }
578 }
579
580 let mut slots: Vec<String> = Vec::new();
582 let mut textures = Map::new();
583 if let Some(t) = j.get("textures") {
584 if !t.is_obj() {
585 cx.err(
586 t.line,
587 format!("{what}.textures must be an object of slot → texture"),
588 );
589 }
590 for (slot, v) in t.members() {
591 if slot.is_empty() || slot.starts_with('#') {
592 cx.err(
593 v.line,
594 format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
595 );
596 continue;
597 }
598 if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
599 textures.insert(slot.clone(), Value::String(tex));
600 slots.push(slot.clone());
601 }
602 }
603 }
604 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
605 if let Some(t) = tpl {
606 if t.sprite {
607 if !defined("layer0", &textures) {
608 cx.err(
609 j.line,
610 format!("{what}: parent {:?} needs textures.layer0", t.name),
611 );
612 }
613 for slot in &slots {
614 let n = slot
615 .strip_prefix("layer")
616 .and_then(|n| n.parse::<usize>().ok());
617 match n {
618 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
619 Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
620 None if slot == "particle" => {}
621 None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
622 }
623 }
624 } else {
625 for slot in t.slots {
626 if !defined(slot, &textures) {
627 cx.err(
628 j.line,
629 format!("{what}: parent {:?} needs textures.{slot}", t.name),
630 );
631 }
632 }
633 }
634 }
635
636 let mut bounds: Option<[f64; 6]> = None;
638 if let Some(e) = j.get("elements") {
639 if let Some(t) = tpl {
640 cx.err(
641 e.line,
642 format!(
643 "{what}: elements replace the shape of parent {:?} — remove one of them",
644 t.name
645 ),
646 );
647 }
648 if !e.is_arr() {
649 cx.err(e.line, format!("{what}.elements must be an array"));
650 }
651 let mut elements = Vec::new();
652 for (i, el) in e.items().iter().enumerate() {
653 if let Some((json, from, to)) = element(
654 cx,
655 el,
656 &format!("{what}.elements[{i}]"),
657 &textures,
658 raw_parent,
659 ) {
660 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
661 for a in 0..3 {
662 b[a] = b[a].min(from[a].min(to[a]));
663 b[a + 3] = b[a + 3].max(from[a].max(to[a]));
664 }
665 elements.push(json);
666 }
667 }
668 out.insert("elements".into(), Value::Array(elements));
669 } else if tpl.is_none() && !raw_parent {
670 cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
671 }
672
673 if !textures.contains_key("particle")
677 && !tpl.is_some_and(|t| t.has_particle)
678 && (!raw_parent || j.get("elements").is_some())
679 && let Some(first) = slots.first()
680 {
681 let v = textures[first].clone();
682 textures.insert("particle".into(), v);
683 }
684 if !textures.is_empty() {
685 out.insert("textures".into(), Value::Object(textures));
686 }
687
688 if let Some(d) = j.get("display") {
689 if !d.is_obj() {
690 cx.err(d.line, format!("{what}.display must be an object"));
691 }
692 let mut display = Map::new();
693 for (slot, t) in d.members() {
694 let w = format!("{what}.display.{slot}");
695 if !DISPLAY_SLOTS.contains(&slot.as_str()) {
696 cx.err(
697 t.line,
698 format!(
699 "{w}: unknown display position (expected one of: {})",
700 DISPLAY_SLOTS.join(", ")
701 ),
702 );
703 continue;
704 }
705 cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
706 let mut m = Map::new();
707 for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
708 if let Some(v) = t.get(key)
709 && let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
710 {
711 m.insert(
712 key.into(),
713 Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
714 );
715 }
716 }
717 display.insert(slot.clone(), Value::Object(m));
718 }
719 out.insert("display".into(), Value::Object(display));
720 }
721 if let Some(ao) = j.get("ambient_occlusion")
722 && let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
723 {
724 out.insert("ambientocclusion".into(), Value::Bool(b));
725 }
726 if let Some(gl) = j.get("gui_light")
727 && let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
728 {
729 out.insert("gui_light".into(), Value::String(s));
730 }
731 Some(Model {
732 json: Value::Object(out),
733 bounds,
734 })
735}
736
737fn element(
739 cx: &mut Ctx,
740 j: &J,
741 what: &str,
742 textures: &Map<String, Value>,
743 raw_parent: bool,
744) -> Option<(Value, [f64; 3], [f64; 3])> {
745 if !j.is_obj() {
746 cx.err(j.line, format!("{what} must be an object"));
747 return None;
748 }
749 cx.unknown_keys(
750 j,
751 what,
752 &["name", "from", "to", "rotation", "shade", "faces"],
753 );
754 if let Some(n) = j.get("name") {
755 cx.string(n, &format!("{what}.name"));
756 }
757 let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
758 cx.err(j.line, format!("{what} needs `from` and `to`"));
759 return None;
760 };
761 let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
762 let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
763 let mut out = Map::new();
764 out.insert(
765 "from".into(),
766 Value::Array(from.iter().map(|n| num_value(*n)).collect()),
767 );
768 out.insert(
769 "to".into(),
770 Value::Array(to.iter().map(|n| num_value(*n)).collect()),
771 );
772 if let Some(r) = j.get("rotation") {
773 let w = format!("{what}.rotation");
774 cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
775 let axis = r
776 .get("axis")
777 .and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
778 let angle = r
779 .get("angle")
780 .and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
781 if let Some(a) = angle
782 && (a / 22.5).fract() != 0.0
783 {
784 cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
785 }
786 let origin = match r.get("origin") {
787 Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
788 None => Some([8.0, 8.0, 8.0]),
789 };
790 if axis.is_none() || angle.is_none() {
791 if r.get("axis").is_none() || r.get("angle").is_none() {
792 cx.err(r.line, format!("{w} needs `axis` and `angle`"));
793 }
794 } else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
795 let mut rot = Map::new();
796 rot.insert(
797 "origin".into(),
798 Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
799 );
800 rot.insert("axis".into(), Value::String(axis));
801 rot.insert("angle".into(), num_value(angle));
802 if let Some(rs) = r.get("rescale")
803 && let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
804 && b
805 {
806 rot.insert("rescale".into(), Value::Bool(true));
807 }
808 out.insert("rotation".into(), Value::Object(rot));
809 }
810 }
811 if let Some(s) = j.get("shade")
812 && let Some(b) = cx.boolean(s, &format!("{what}.shade"))
813 && !b
814 {
815 out.insert("shade".into(), Value::Bool(false));
816 }
817 let mut faces = Map::new();
818 match j.get("faces") {
819 None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
820 Some(f) => {
821 if !f.is_obj() {
822 cx.err(
823 f.line,
824 format!("{what}.faces must be an object of direction → face"),
825 );
826 }
827 for (dir, face) in f.members() {
828 let w = format!("{what}.faces.{dir}");
829 if !DIRECTIONS.contains(&dir.as_str()) {
830 cx.err(
831 face.line,
832 format!(
833 "{w}: unknown face (expected one of: {})",
834 DIRECTIONS.join(", ")
835 ),
836 );
837 continue;
838 }
839 cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
840 let mut fo = Map::new();
841 match face.get("texture") {
842 None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
843 Some(t) => {
844 if let Some(s) = cx.string(t, &format!("{w}.texture")) {
845 let slot = s.strip_prefix('#').unwrap_or(s);
846 if !textures.contains_key(slot) && !raw_parent {
847 cx.err(
848 t.line,
849 format!("{w}.texture `#{slot}`: no such slot in textures"),
850 );
851 }
852 fo.insert("texture".into(), Value::String(format!("#{slot}")));
853 }
854 }
855 }
856 if let Some(uv) = face.get("uv")
857 && let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
858 {
859 fo.insert(
860 "uv".into(),
861 Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
862 );
863 }
864 if let Some(r) = face.get("rotation") {
865 match r.num() {
866 Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
867 if n != 0.0 {
868 fo.insert("rotation".into(), num_value(n));
869 }
870 }
871 _ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
872 }
873 }
874 if let Some(c) = face.get("cullface")
875 && let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
876 {
877 fo.insert("cullface".into(), Value::String(c));
878 }
879 if let Some(t) = face.get("tint")
880 && let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
881 && n >= 0.0
882 {
883 fo.insert("tintindex".into(), num_value(n.floor()));
884 }
885 faces.insert(dir.clone(), Value::Object(fo));
886 }
887 }
888 }
889 out.insert("faces".into(), Value::Object(faces));
890 Some((Value::Object(out), from, to))
891}
892
893fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
895 let (raw, line) = match j.get("id") {
896 Some(v) => (cx.string(v, "id")?.to_string(), v.line),
897 None => (
898 path.file_stem()
899 .unwrap_or_default()
900 .to_string_lossy()
901 .into_owned(),
902 1,
903 ),
904 };
905 let (ns, p) = match raw.split_once(':') {
906 Some((ns, p)) => (ns.to_string(), p.to_string()),
907 None => match mod_ns {
908 Some(ns) => (ns.to_string(), raw.clone()),
909 None => {
910 cx.err(
911 line,
912 "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
913 );
914 return None;
915 }
916 },
917 };
918 if !valid_namespace(&ns) || !valid_path(&p) {
919 cx.err(
920 line,
921 format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
922 );
923 return None;
924 }
925 Some((ns, p))
926}
927
928fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
930 let mut v = Map::new();
931 v.insert("model".into(), Value::String(model.into()));
932 if x != 0.0 {
933 v.insert("x".into(), num_value(x));
934 }
935 if y != 0.0 {
936 v.insert("y".into(), num_value(y));
937 }
938 if uvlock {
939 v.insert("uvlock".into(), Value::Bool(true));
940 }
941 if let Some(w) = weight
942 && w != 1.0
943 {
944 v.insert("weight".into(), num_value(w));
945 }
946 Value::Object(v)
947}
948
949fn pretty(v: &Value) -> Vec<u8> {
950 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
951 s.push('\n');
952 s.into_bytes()
953}
954
955#[allow(clippy::too_many_arguments)]
957fn compile_file(
958 cx: &mut Ctx,
959 j: &J,
960 kind: Kind,
961 path: &Path,
962 mod_ns: Option<&str>,
963 out: &mut Compiled,
964 generated: &mut BTreeMap<String, String>,
965) -> Option<Entry> {
966 if !j.is_obj() {
967 cx.err(
968 j.line,
969 format!("a .y{} file is one JSON object", kind.word()),
970 );
971 return None;
972 }
973 let common = [
974 "$schema", "format", "id", "name", "tooltip", "register", "model",
975 ];
976 let specific: &[&str] = match kind {
977 Kind::Block => &["block", "item_model", "states"],
978 Kind::Item => &["item"],
979 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
980 };
981 let known: Vec<&str> = common.iter().chain(specific).copied().collect();
982 cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
983 if let Some(f) = j.get("format")
984 && f.num() != Some(1.0)
985 {
986 cx.err(f.line, "format must be 1 (this yog understands format 1)");
987 }
988 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
989 cx.ns = ns.clone();
990 let id = format!("{ns}:{p}");
991
992 let auto = match j.get("register") {
993 None => true,
994 Some(r) => cx
995 .one_of(r, "register", &["auto", "manual"])
996 .is_none_or(|s| s == "auto"),
997 };
998
999 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
1001 for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
1002 let Some(v) = j.get(field) else { continue };
1003 match cx.text(v, field)? {
1004 Text::Key(from) if from == target => {}
1005 Text::Key(from) => out.lang_aliases.push((from, target)),
1006 Text::Literal(s) => {
1007 out.lang
1008 .entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
1009 .or_default()
1010 .insert(target, s);
1011 }
1012 }
1013 }
1014
1015 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
1016 let full = format!("assets/{ns}/{rel}");
1017 if cx.root.join(&full).is_file() {
1018 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
1019 }
1020 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
1021 cx.err(1, format!("{full} is also written by {other}"));
1022 }
1023 out.outputs.push((full, pretty(value)));
1024 };
1025
1026 let mut entry = Entry {
1027 kind,
1028 id: id.clone(),
1029 file: cx.file.clone(),
1030 auto,
1031 block: BlockProps::default(),
1032 item: ItemProps::default(),
1033 };
1034
1035 match kind {
1036 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
1037 Kind::Item => {
1038 if let Some(it) = j.get("item") {
1039 cx.unknown_keys(
1040 it,
1041 "item",
1042 &[
1043 "stack_size",
1044 "durability",
1045 "fire_resistant",
1046 "fuel_ticks",
1047 "food",
1048 ],
1049 );
1050 let props = &mut entry.item;
1051 if let Some(v) = it.get("stack_size") {
1052 props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
1053 }
1054 if let Some(v) = it.get("durability") {
1055 props.durability =
1056 cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
1057 }
1058 if let Some(v) = it.get("fire_resistant") {
1059 props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
1060 }
1061 if let Some(v) = it.get("fuel_ticks") {
1062 props.fuel_ticks =
1063 cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
1064 }
1065 if let Some(f) = it.get("food") {
1066 cx.unknown_keys(
1067 f,
1068 "item.food",
1069 &["nutrition", "saturation", "always_edible"],
1070 );
1071 let n = f
1072 .get("nutrition")
1073 .and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
1074 let s = f
1075 .get("saturation")
1076 .and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
1077 let a = f
1078 .get("always_edible")
1079 .and_then(|v| cx.boolean(v, "item.food.always_edible"));
1080 if n.is_none() {
1081 cx.err(f.line, "item.food needs nutrition");
1082 }
1083 props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
1084 }
1085 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
1086 cx.warn(
1087 it.line,
1088 "item.stack_size is ignored: an item with durability stacks to 1",
1089 );
1090 }
1091 }
1092 let Some(mj) = j.get("model") else {
1093 cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
1094 return Some(entry);
1095 };
1096 let m = model(cx, mj, "model", true)?;
1097 emit(cx, format!("models/item/{p}.json"), &m.json);
1098 emit(
1099 cx,
1100 format!("items/{p}.json"),
1101 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1102 );
1103 }
1104 Kind::Block => {
1105 let mut render_layer: Option<String> = None;
1106 let mut drops = Value::Null;
1107 let mut shape_auto = true;
1108 if let Some(b) = j.get("block") {
1109 cx.unknown_keys(
1110 b,
1111 "block",
1112 &[
1113 "hardness",
1114 "resistance",
1115 "sound",
1116 "light",
1117 "tool",
1118 "tool_level",
1119 "requires_tool",
1120 "collision",
1121 "slipperiness",
1122 "shape",
1123 "render_layer",
1124 "drops",
1125 "connect_groups",
1126 "inventory",
1127 "redstone",
1128 ],
1129 );
1130 let props = &mut entry.block;
1131 if let Some(v) = b.get("hardness") {
1132 props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
1133 }
1134 if let Some(v) = b.get("resistance") {
1135 props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
1136 }
1137 if let Some(v) = b.get("sound") {
1138 props.sound = cx.one_of(v, "block.sound", &SOUNDS);
1139 }
1140 if let Some(v) = b.get("light") {
1141 props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
1142 }
1143 if let Some(v) = b.get("tool") {
1144 props.tool = cx.one_of(v, "block.tool", &TOOLS);
1145 }
1146 if let Some(v) = b.get("tool_level") {
1147 props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
1148 }
1149 if let Some(v) = b.get("requires_tool") {
1150 props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
1151 }
1152 if let Some(v) = b.get("collision") {
1153 props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
1154 }
1155 if let Some(v) = b.get("slipperiness") {
1156 props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
1157 }
1158 if let Some(v) = b.get("shape") {
1159 match &v.v {
1160 V::Str(s) if s == "auto" => {}
1161 V::Str(s) if s == "full" => shape_auto = false,
1162 V::Arr(_) => {
1163 shape_auto = false;
1164 props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
1165 }
1166 _ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
1167 }
1168 }
1169 if let Some(v) = b.get("render_layer") {
1170 render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
1171 }
1172 if let Some(v) = b.get("redstone") {
1173 props.redstone = cx.boolean(v, "block.redstone").unwrap_or(false);
1174 }
1175 if let Some(v) = b.get("inventory")
1176 && let Some(id) = cx.string(v, "block.inventory")
1177 {
1178 if valid_id(id) && id.contains(':') {
1179 props.inventory = Some(id.to_string());
1180 let at = (cx.file.clone(), v.line, id.to_string());
1181 cx.inventories.push(at);
1182 } else {
1183 cx.err(v.line, format!("block.inventory `{id}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
1184 }
1185 }
1186 if let Some(v) = b.get("connect_groups") {
1187 for g in v.items() {
1188 if let Some(s) = cx.string(g, "block.connect_groups[]") {
1189 props.connect_groups.push(s.to_string());
1190 }
1191 }
1192 if !v.is_arr() {
1193 cx.err(
1194 v.line,
1195 "block.connect_groups must be an array of group names",
1196 );
1197 }
1198 }
1199 if props.tool_level.is_some() && props.tool.is_none() {
1200 cx.warn(
1201 b.line,
1202 "block.tool_level without block.tool — any tool of that level mines it",
1203 );
1204 }
1205 if let Some(d) = b.get("drops") {
1206 drops = drops_table(cx, d, &id);
1207 }
1208 }
1209 if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
1210 drops = drops_table_self(&id);
1211 }
1212
1213 let Some(mj) = j.get("model") else {
1214 cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
1215 return Some(entry);
1216 };
1217 let mut m = model(cx, mj, "model", false)?;
1218 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1219 o.insert(
1221 "render_type".into(),
1222 Value::String(format!("minecraft:{layer}")),
1223 );
1224 }
1225 if shape_auto {
1226 entry.block.shape = m
1227 .bounds
1228 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1229 .map(|b| {
1230 [
1231 b[0].max(0.0),
1232 b[1].max(0.0),
1233 b[2].max(0.0),
1234 b[3].min(16.0),
1235 b[4].min(16.0),
1236 b[5].min(16.0),
1237 ]
1238 });
1239 }
1240 let main = format!("{ns}:block/{p}");
1241 emit(cx, format!("models/block/{p}.json"), &m.json);
1242
1243 let mut rotations: Vec<Value> = Vec::new();
1245 let mut arm: Option<(String, bool)> = None;
1246 if let Some(s) = j.get("states") {
1247 cx.unknown_keys(s, "states", &["rotations", "connect"]);
1248 if let Some(r) = s.get("rotations") {
1249 if !r.is_arr() || r.items().is_empty() {
1250 cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
1251 }
1252 for (i, rot) in r.items().iter().enumerate() {
1253 let w = format!("states.rotations[{i}]");
1254 cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
1255 let mut angle = |k: &str| -> f64 {
1256 match rot.get(k).map(|v| (v, v.num())) {
1257 None => 0.0,
1258 Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
1259 Some((v, _)) => {
1260 cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
1261 0.0
1262 }
1263 }
1264 };
1265 let (x, y) = (angle("x"), angle("y"));
1266 let weight = rot
1267 .get("weight")
1268 .and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
1269 let uvlock = rot
1270 .get("uvlock")
1271 .and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
1272 .unwrap_or(false);
1273 rotations.push(variant(&main, x, y, uvlock, weight));
1274 }
1275 }
1276 if let Some(c) = s.get("connect") {
1277 cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
1278 let uvlock = c
1279 .get("uvlock")
1280 .and_then(|v| cx.boolean(v, "states.connect.uvlock"))
1281 .unwrap_or(false);
1282 match c.get("arm") {
1283 None => cx.err(
1284 c.line,
1285 "states.connect needs `arm` — the model of the arm towards north",
1286 ),
1287 Some(a) => {
1288 if let Some(am) = model(cx, a, "states.connect.arm", false) {
1289 let mut json = am.json;
1290 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
1291 {
1292 o.insert(
1293 "render_type".into(),
1294 Value::String(format!("minecraft:{layer}")),
1295 );
1296 }
1297 emit(cx, format!("models/block/{p}_arm.json"), &json);
1298 arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
1299 }
1300 }
1301 }
1302 entry.block.connects = true;
1303 if entry.block.connect_groups.is_empty() {
1304 entry.block.connect_groups.push(id.clone());
1305 }
1306 }
1307 }
1308 let base: Value = match rotations.len() {
1309 0 => variant(&main, 0.0, 0.0, false, None),
1310 1 => rotations.remove(0),
1311 _ => Value::Array(rotations),
1312 };
1313 let blockstate = match &arm {
1314 None => json!({ "variants": { "": base } }),
1315 Some((arm, uvlock)) => {
1316 let mut parts = vec![json!({ "apply": base })];
1317 for (dir, x, y) in [
1318 ("north", 0.0, 0.0),
1319 ("east", 0.0, 90.0),
1320 ("south", 0.0, 180.0),
1321 ("west", 0.0, 270.0),
1322 ("up", 270.0, 0.0),
1323 ("down", 90.0, 0.0),
1324 ] {
1325 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1326 }
1327 json!({ "multipart": parts })
1328 }
1329 };
1330 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1331
1332 let item_model = match j.get("item_model") {
1334 None => Some(json!({ "parent": main })),
1335 Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
1336 };
1337 if let Some(im) = item_model {
1338 emit(cx, format!("models/item/{p}.json"), &im);
1339 }
1340 emit(
1341 cx,
1342 format!("items/{p}.json"),
1343 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1344 );
1345
1346 if !drops.is_null() {
1347 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1348 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1349 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1350 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
1351 }
1352 out.outputs.push((full, pretty(&drops)));
1353 }
1354 if let Some(tool) = &entry.block.tool {
1355 out.tags
1356 .entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
1357 .or_default()
1358 .insert(id.clone());
1359 }
1360 if let Some(level) = &entry.block.tool_level {
1361 out.tags
1362 .entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
1363 .or_default()
1364 .insert(id.clone());
1365 }
1366 }
1367 }
1368 Some(entry)
1369}
1370
1371fn drops_table_self(id: &str) -> Value {
1372 json!({
1373 "type": "minecraft:block",
1374 "pools": [{
1375 "rolls": 1,
1376 "entries": [{ "type": "minecraft:item", "name": id }],
1377 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1378 }]
1379 })
1380}
1381
1382fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
1384 match &d.v {
1385 V::Str(s) if s == "self" => drops_table_self(id),
1386 V::Str(s) if s == "none" => Value::Null,
1387 V::Obj(_) => {
1388 cx.unknown_keys(d, "block.drops", &["item", "count"]);
1389 let item = match d.get("item") {
1390 Some(i) => match cx.string(i, "block.drops.item") {
1391 Some(s) if valid_id(s) => {
1392 if s.contains(':') {
1393 s.to_string()
1394 } else {
1395 format!("minecraft:{s}")
1396 }
1397 }
1398 Some(s) => {
1399 cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
1400 return Value::Null;
1401 }
1402 None => return Value::Null,
1403 },
1404 None => id.to_string(),
1405 };
1406 let mut entry = json!({ "type": "minecraft:item", "name": item });
1407 if let Some(c) = d.get("count") {
1408 let count = match &c.v {
1409 V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
1410 V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
1411 json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
1412 }),
1413 _ => {
1414 cx.err(c.line, "block.drops.count must be a number or [min, max]");
1415 None
1416 }
1417 };
1418 if let Some(count) = count {
1419 entry["functions"] =
1420 json!([{ "function": "minecraft:set_count", "count": count }]);
1421 }
1422 }
1423 json!({
1424 "type": "minecraft:block",
1425 "pools": [{
1426 "rolls": 1,
1427 "entries": [entry],
1428 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1429 }]
1430 })
1431 }
1432 _ => {
1433 cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
1434 Value::Null
1435 }
1436 }
1437}
1438
1439#[derive(Debug, Clone)]
1443enum RecipeKind {
1444 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1447 Shapeless(Vec<Vec<String>>),
1449 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1451 Stonecutting(Vec<String>),
1452 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1453}
1454
1455struct BookFile {
1457 id: String,
1458 json: String,
1460}
1461
1462fn compile_book(cx: &mut Ctx, j: &J, mod_ns: Option<&str>) -> Option<BookFile> {
1468 fn convert(cx: &mut Ctx, j: &J, refs_used: &mut bool) -> serde_json::Value {
1469 use serde_json::Value;
1470 match &j.v {
1471 V::Null => Value::Null,
1472 V::Bool(b) => Value::Bool(*b),
1473 V::Num(n) => serde_json::Number::from_f64(*n).map_or(Value::Null, |n| {
1474 if n.as_f64().is_some_and(|f| f.fract() == 0.0 && f.abs() < 9e15) {
1476 Value::from(n.as_f64().unwrap_or(0.0) as i64)
1477 } else {
1478 Value::Number(n)
1479 }
1480 }),
1481 V::Str(s) => match s.strip_prefix('@') {
1482 Some(rest) if rest.starts_with('@') => Value::String(rest.to_string()),
1483 Some(rest) => {
1484 let key = if let Some(id) = rest.strip_prefix("item:").or_else(|| rest.strip_prefix("block:")) {
1485 let kind = if rest.starts_with("item:") { "item" } else { "block" };
1486 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
1487 Some(format!("{kind}.{ns}.{}", path.replace('/', ".")))
1488 } else {
1489 cx.refs.texts.get(rest).map(|(key, _)| key.clone())
1490 };
1491 match key {
1492 Some(key) => {
1493 *refs_used = true;
1494 Value::String(key)
1495 }
1496 None => {
1497 cx.err(j.line, format!("`{s}`: no text `{rest}` in resources/*.yres"));
1498 Value::String(s.clone())
1499 }
1500 }
1501 }
1502 None => Value::String(s.clone()),
1503 },
1504 V::Arr(items) => Value::Array(items.iter().map(|i| convert(cx, i, refs_used)).collect()),
1505 V::Obj(members) => {
1506 let mut map = serde_json::Map::new();
1507 for (k, v) in members {
1508 map.insert(k.clone(), convert(cx, v, refs_used));
1509 }
1510 Value::Object(map)
1511 }
1512 }
1513 }
1514
1515 if !matches!(j.v, V::Obj(_)) {
1516 cx.err(j.line, "a .ybook file is one JSON object");
1517 return None;
1518 }
1519 let mut refs_used = false;
1520 let mut value = convert(cx, j, &mut refs_used);
1521 let obj = value.as_object_mut()?;
1522 let id = match obj.get("id").and_then(|v| v.as_str()) {
1523 Some(id) if id.contains(':') => id.to_string(),
1524 Some(id) => format!("{}:{id}", mod_ns.unwrap_or("minecraft")),
1525 None => {
1526 cx.err(j.line, "a book needs an `id` (the item that opens it)");
1527 return None;
1528 }
1529 };
1530 if !valid_id(&id) {
1531 cx.err(j.line, format!("book id `{id}`: not a valid id"));
1532 return None;
1533 }
1534 obj.insert("id".into(), id.clone().into());
1535 if refs_used && !obj.contains_key("i18n") {
1536 obj.insert("i18n".into(), true.into());
1537 }
1538 if let Err(e) = serde_json::from_value::<yog_book::Book>(value.clone()) {
1539 cx.err(j.line, format!("not a book: {e}"));
1540 return None;
1541 }
1542 Some(BookFile { id, json: value.to_string() })
1543}
1544
1545fn is_metadata(key: &str) -> bool {
1548 key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
1549}
1550
1551#[derive(Debug, Clone)]
1553struct Recipe {
1554 id: String,
1555 file: String,
1556 kind: RecipeKind,
1557 result: String,
1558 count: u32,
1559 group: Option<String>,
1560 category: Option<String>,
1561 items: Vec<(usize, String)>,
1564}
1565
1566impl Recipe {
1567 fn registration(&self) -> String {
1569 let list = |items: &[String]| {
1570 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1571 format!("vec![{}]", quoted.join(", "))
1572 };
1573 let kind = match &self.kind {
1574 RecipeKind::Shaped { pattern, key } => {
1575 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1576 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1577 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1578 }
1579 RecipeKind::Shapeless(ingredients) => {
1580 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1581 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1582 }
1583 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1584 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1585 list(input),
1586 float(*experience)
1587 ),
1588 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1589 RecipeKind::Smithing { template, base, addition } => format!(
1590 "Smithing {{ template: {}, base: {}, addition: {} }}",
1591 list(template),
1592 list(base),
1593 list(addition)
1594 ),
1595 };
1596 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1597 format!(
1598 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1599 self.id,
1600 self.result,
1601 self.count,
1602 opt(&self.group),
1603 opt(&self.category)
1604 )
1605 }
1606}
1607
1608fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1611 let s = cx.string(j, what)?.trim().to_string();
1612 let (tag, id) = match s.strip_prefix('#') {
1613 Some(rest) => (true, rest),
1614 None => (false, s.as_str()),
1615 };
1616 if tag && !tags {
1617 cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1618 return None;
1619 }
1620 let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1621 if !ok {
1622 let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1623 cx.err(
1624 j.line,
1625 format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1626 );
1627 return None;
1628 }
1629 if !tag {
1630 items.push((j.line, id.to_string()));
1631 }
1632 Some(s)
1633}
1634
1635fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1637 if !j.is_obj() {
1638 cx.err(j.line, "a .yrecipe file is one JSON object");
1639 return None;
1640 }
1641 cx.unknown_keys(
1642 j,
1643 "a .yrecipe",
1644 &[
1645 "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1646 "result", "count", "experience", "cook_time", "template", "base", "addition", "group",
1647 "category",
1648 ],
1649 );
1650 if let Some(f) = j.get("format")
1651 && f.num() != Some(1.0)
1652 {
1653 cx.err(f.line, "format must be 1 (this yog understands format 1)");
1654 }
1655 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1656 let Some(t) = j.get("type") else {
1657 cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1658 return None;
1659 };
1660 let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1661 let cooking = matches!(ty.as_str(), "smelting" | "blasting" | "smoking" | "campfire_cooking");
1662 for (key, owners) in [
1664 ("pattern", &["shaped"][..]),
1665 ("key", &["shaped"]),
1666 ("ingredients", &["shapeless"]),
1667 ("ingredient", &["smelting", "blasting", "smoking", "campfire_cooking", "stonecutting"]),
1668 ("experience", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1669 ("cook_time", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1670 ("template", &["smithing"]),
1671 ("base", &["smithing"]),
1672 ("addition", &["smithing"]),
1673 ] {
1674 if !owners.contains(&ty.as_str())
1675 && let Some(v) = j.get(key)
1676 {
1677 cx.err(v.line, format!("{key} is for {} recipes, this one is {ty}", owners.join("/")));
1678 }
1679 }
1680 let mut items = Vec::new();
1681 let result = match j.get("result") {
1682 Some(v) => recipe_item(cx, v, "result", false, &mut items),
1683 None => {
1684 cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1685 None
1686 }
1687 };
1688 let count = match j.get("count") {
1689 Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1690 None => Some(1),
1691 };
1692 let text = |cx: &mut Ctx, key: &str| j.get(key).and_then(|v| cx.string(v, key).map(str::to_owned));
1693 let group = text(cx, "group");
1694 let category = text(cx, "category");
1695 let kind = match ty.as_str() {
1696 "shaped" => {
1697 let Some(pv) = j.get("pattern") else {
1698 cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1699 return None;
1700 };
1701 let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1702 let width = rows.first().map_or(0, |r| r.chars().count());
1703 if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1704 cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1705 return None;
1706 }
1707 let mut key = Vec::new();
1708 match j.get("key") {
1709 Some(kv) if kv.is_obj() => {
1710 for (k, v) in kv.members() {
1711 let mut chars = k.chars();
1712 match (chars.next(), chars.next()) {
1713 (Some(c), None) if c != ' ' => {
1714 if let Some(alts) = recipe_ingredient(cx, v, &format!("key {k:?}"), &mut items) {
1715 if !rows.iter().any(|r| r.contains(c)) {
1716 cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1717 }
1718 key.push((c, alts));
1719 }
1720 }
1721 _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1722 }
1723 }
1724 }
1725 Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1726 None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1727 }
1728 for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1729 if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1730 cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1731 return None;
1732 }
1733 }
1734 RecipeKind::Shaped { pattern: rows, key }
1735 }
1736 "shapeless" => {
1737 let Some(iv) = j.get("ingredients") else {
1738 cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1739 return None;
1740 };
1741 let list: Vec<Vec<String>> = iv
1742 .items()
1743 .iter()
1744 .filter_map(|i| recipe_ingredient(cx, i, "ingredients[]", &mut items))
1745 .collect();
1746 if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1747 cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1748 return None;
1749 }
1750 RecipeKind::Shapeless(list)
1751 }
1752 "smithing" => {
1753 let mut part = |cx: &mut Ctx, key: &str| match j.get(key) {
1754 Some(v) => recipe_ingredient(cx, v, key, &mut items),
1755 None => {
1756 cx.err(j.line, format!("a smithing recipe needs a \"{key}\""));
1757 None
1758 }
1759 };
1760 let template = part(cx, "template");
1761 let base = part(cx, "base");
1762 let addition = part(cx, "addition");
1763 RecipeKind::Smithing { template: template?, base: base?, addition: addition? }
1764 }
1765 _ => {
1766 let Some(iv) = j.get("ingredient") else {
1767 cx.err(j.line, format!("a {ty} recipe needs an \"ingredient\": the item or tag it takes"));
1768 return None;
1769 };
1770 let input = recipe_ingredient(cx, iv, "ingredient", &mut items)?;
1771 if !cooking {
1772 RecipeKind::Stonecutting(input)
1773 } else {
1774 let (method, default_time) = match ty.as_str() {
1775 "smelting" => ("Smelting", 200.0),
1776 "blasting" => ("Blasting", 100.0),
1777 "smoking" => ("Smoking", 100.0),
1778 _ => ("Campfire", 600.0),
1779 };
1780 let experience = match j.get("experience") {
1781 Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1782 None => 0.1,
1783 };
1784 let cook_time = match j.get("cook_time") {
1785 Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)?,
1786 None => default_time,
1787 } as u32;
1788 RecipeKind::Cooking { method, input, experience, cook_time }
1789 }
1790 }
1791 };
1792 Some(Recipe {
1793 id: format!("{ns}:{p}"),
1794 file: cx.file.clone(),
1795 kind,
1796 result: result?,
1797 count: count?,
1798 group,
1799 category,
1800 items,
1801 })
1802}
1803
1804fn recipe_ingredient(cx: &mut Ctx, j: &J, what: &str, items: &mut Vec<(usize, String)>) -> Option<Vec<String>> {
1806 if j.is_arr() {
1807 let alts: Vec<String> = j.items().iter().filter_map(|i| recipe_item(cx, i, what, true, items)).collect();
1808 if alts.is_empty() {
1809 cx.err(j.line, format!("{what}: an array of items or tags, at least one"));
1810 return None;
1811 }
1812 return Some(alts);
1813 }
1814 recipe_item(cx, j, what, true, items).map(|i| vec![i])
1815}
1816
1817fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1820 let mut out = BTreeSet::new();
1821 let Ok(entries) = std::fs::read_dir(dir) else {
1822 return out;
1823 };
1824 for e in entries.flatten() {
1825 let path = e.path();
1826 if path.is_dir() {
1827 out.extend(inventory_screens(&path));
1828 continue;
1829 }
1830 if path.extension().is_none_or(|x| x != "yui") {
1831 continue;
1832 }
1833 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1834 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1835 continue;
1836 }
1837 if let Some(screen) = text.find("<Screen")
1838 && let Some(at) = text[screen..].find(" id=\"")
1839 {
1840 let rest = &text[screen + at + 5..];
1841 if let Some(end) = rest.find('"') {
1842 out.insert(rest[..end].to_string());
1843 }
1844 }
1845 }
1846 out
1847}
1848
1849pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1850 let mut out = Compiled::default();
1851 let files = find(root);
1852 if files.is_empty() {
1853 return Ok(out);
1854 }
1855 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1856 let mut errors: Vec<String> = Vec::new();
1857 let mut warnings: Vec<String> = Vec::new();
1858 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1859 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1860 let mut entries: Vec<Entry> = Vec::new();
1861 let mut ids: HashMap<String, String> = HashMap::new();
1862 let mut recipes: Vec<Recipe> = Vec::new();
1863 let mut recipe_ids: HashMap<String, String> = HashMap::new();
1864 let mut books: Vec<BookFile> = Vec::new();
1865 let mut book_ids: HashMap<String, String> = HashMap::new();
1866 let mut inventories: Vec<(String, usize, String)> = Vec::new();
1867 for path in &files {
1868 let shown = display(root, path);
1869 let Some(kind) = Kind::of(path) else { continue };
1870 let text = match std::fs::read_to_string(path) {
1871 Ok(t) => t,
1872 Err(e) => {
1873 errors.push(format!("{shown}: {e}"));
1874 continue;
1875 }
1876 };
1877 let j = match json::parse(&text) {
1878 Ok(j) => j,
1879 Err(e) => {
1880 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
1881 continue;
1882 }
1883 };
1884 let mut cx = Ctx {
1885 root,
1886 refs,
1887 file: shown.clone(),
1888 ns: mod_ns.unwrap_or("minecraft").to_string(),
1889 errors: &mut errors,
1890 warnings: &mut warnings,
1891 textures: &mut textures,
1892 inventories: &mut inventories,
1893 };
1894 if kind == Kind::Book {
1895 if let Some(b) = compile_book(&mut cx, &j, mod_ns) {
1896 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
1897 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
1898 }
1899 books.push(b);
1900 }
1901 continue;
1902 }
1903 if kind == Kind::Recipe {
1904 if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
1905 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
1906 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
1907 }
1908 recipes.push(r);
1909 }
1910 continue;
1911 }
1912 if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
1913 {
1914 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
1915 errors.push(format!(
1916 "{shown}: id {} is also defined by {other}",
1917 entry.id
1918 ));
1919 }
1920 entries.push(entry);
1921 }
1922 }
1923 if let Some(ns) = mod_ns {
1926 for r in &recipes {
1927 for (line, item) in &r.items {
1928 if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
1929 errors.push(format!(
1930 "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
1931 r.file
1932 ));
1933 }
1934 }
1935 }
1936 let screens = inventory_screens(&root.join("src"));
1937 for (file, line, id) in &inventories {
1938 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
1939 errors.push(format!(
1940 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
1941 ));
1942 }
1943 }
1944 }
1945 for (target, source) in &textures {
1946 match std::fs::read(source) {
1947 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
1948 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
1949 }
1950 }
1951 out.warnings = warnings;
1952 if !errors.is_empty() {
1953 return Err(format!(
1954 "invalid blocks/items in content/:\n {}",
1955 errors.join("\n ")
1956 ));
1957 }
1958 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
1959 out.items = entries.len() - out.blocks;
1960 out.recipes = recipes.len();
1961 out.books = books.len();
1962 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
1963 && !mentions_content_macro(&root.join("src"))
1964 {
1965 out.warnings.push(format!(
1966 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
1967 CONTENT_DIR
1968 ));
1969 }
1970 out.source = Some(rust_source(&entries, &recipes, &books));
1971 Ok(out)
1972}
1973
1974fn mentions_content_macro(dir: &Path) -> bool {
1976 let Ok(entries) = std::fs::read_dir(dir) else {
1977 return false;
1978 };
1979 entries.flatten().any(|e| {
1980 let p = e.path();
1981 if p.is_dir() {
1982 mentions_content_macro(&p)
1983 } else {
1984 p.extension().is_some_and(|x| x == "rs")
1985 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
1986 }
1987 })
1988}
1989
1990fn rust_ident(path: &str) -> String {
1992 let mut s: String = path
1993 .chars()
1994 .map(|c| {
1995 if c.is_ascii_alphanumeric() {
1996 c.to_ascii_lowercase()
1997 } else {
1998 '_'
1999 }
2000 })
2001 .collect();
2002 if s.starts_with(|c: char| c.is_ascii_digit()) {
2003 s.insert(0, '_');
2004 }
2005 s
2006}
2007
2008fn float(n: f64) -> String {
2009 let s = format!("{n:?}");
2010 if s.contains('.') || s.contains('e') {
2011 s
2012 } else {
2013 format!("{s}.0")
2014 }
2015}
2016
2017fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
2018 let mut code = String::from(
2019 "// <auto-generated>\n// Generated by `yog build` from content/**/*.yblock and *.yitem — don't edit,\n// it's rewritten on every build. Included by `yog_api::content!()`.\n// </auto-generated>\n",
2020 );
2021 for (kind, set, def) in [
2022 (Kind::Block, "Blocks", "BlockDef"),
2023 (Kind::Item, "Items", "ItemDef"),
2024 ] {
2025 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2026 let _ = writeln!(
2027 code,
2028 "\n/// The {} of `content/`: the id and a [`::yog_api::{def}`] of each.\n#[allow(dead_code)]\npub struct {set};\n\n#[allow(dead_code, clippy::all)]\nimpl {set} {{",
2029 if kind == Kind::Block {
2030 "blocks"
2031 } else {
2032 "items"
2033 }
2034 );
2035 let mut used: HashMap<String, usize> = HashMap::new();
2036 for e in mine {
2037 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2038 let mut ident = rust_ident(path);
2039 let n = used.entry(ident.clone()).or_insert(0);
2040 *n += 1;
2041 if *n > 1 {
2042 let _ = write!(ident, "_{n}");
2043 }
2044 let upper = ident.to_ascii_uppercase();
2045 let ident = if is_keyword(&ident) {
2046 format!("r#{ident}")
2047 } else {
2048 ident
2049 };
2050 let _ = writeln!(
2051 code,
2052 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
2053 e.id, e.file, e.id
2054 );
2055 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2056 match kind {
2057 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2058 Kind::Block => {
2059 let p = &e.block;
2060 if p.hardness.is_some() || p.resistance.is_some() {
2061 let _ = write!(
2062 b,
2063 "\n .strength({}, {})",
2064 float(p.hardness.unwrap_or(1.5)),
2065 float(p.resistance.unwrap_or(6.0))
2066 );
2067 }
2068 if let Some(s) = &p.sound {
2069 let _ = write!(b, "\n .sound({s:?})");
2070 }
2071 if p.light > 0 {
2072 let _ = write!(b, "\n .light_level({})", p.light);
2073 }
2074 if p.requires_tool {
2075 b.push_str("\n .requires_tool()");
2076 }
2077 if p.no_collision {
2078 b.push_str("\n .no_collision()");
2079 }
2080 if let Some(s) = p.slipperiness {
2081 let _ = write!(b, "\n .slipperiness({})", float(s));
2082 }
2083 if let Some(s) = p.shape {
2084 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2085 let _ = write!(b, "\n .shape({})", args.join(", "));
2086 }
2087 if p.connects {
2088 b.push_str("\n .connects_to_neighbors()");
2089 }
2090 if !p.connect_groups.is_empty() {
2091 let groups: Vec<String> =
2092 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2093 let _ =
2094 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
2095 }
2096 if let Some(inv) = &p.inventory {
2097 let _ = write!(b, "\n .inventory({inv:?})");
2098 }
2099 if p.redstone {
2100 b.push_str("\n .redstone_source()");
2101 }
2102 }
2103 Kind::Item => {
2104 let p = &e.item;
2105 if let Some(s) = p.stack_size {
2106 let _ = write!(b, "\n .max_stack({s})");
2107 }
2108 if p.durability > 0 {
2109 let _ = write!(b, "\n .max_damage({})", p.durability);
2110 }
2111 if p.fire_resistant {
2112 b.push_str("\n .fire_resistant()");
2113 }
2114 if p.fuel_ticks > 0 {
2115 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
2116 }
2117 if let Some((n, s, always)) = p.food {
2118 let _ = write!(
2119 b,
2120 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
2121 float(s),
2122 if always { ".can_always_eat()" } else { "" }
2123 );
2124 }
2125 }
2126 }
2127 let _ = writeln!(
2128 code,
2129 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
2130 e.file
2131 );
2132 }
2133 code.push_str("}\n");
2134 }
2135
2136 code.push_str("\n/// Registers every block and item of `content/` whose file doesn't say\n/// `\"register\": \"manual\"` — `yog_api::content!()` calls it after\n/// `Mod::register`.\n#[allow(unused_variables)]\npub fn register(registry: &mut ::yog_api::Registry) {\n");
2137 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2138 for e in entries {
2139 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2140 let mut ident = rust_ident(path);
2141 let n = used.entry((e.kind, ident.clone())).or_insert(0);
2142 *n += 1;
2143 if *n > 1 {
2144 let _ = write!(ident, "_{n}");
2145 }
2146 let ident = if is_keyword(&ident) {
2147 format!("r#{ident}")
2148 } else {
2149 ident
2150 };
2151 if !e.auto {
2152 continue;
2153 }
2154 let _ = match e.kind {
2155 Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
2156 Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
2157 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2158 };
2159 }
2160 for r in recipes {
2161 let _ = writeln!(code, " {}", r.registration());
2162 }
2163 for b in books {
2164 let _ = writeln!(code, " registry.register_book_json({:?}, {:?});", b.id, b.json);
2165 }
2166 code.push_str("}\n");
2167 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
2168 code.push_str(
2169 "\n// Runs when the mod's library loads: `export_mod!` calls `register` after\n// `Mod::register`.\n#[::yog_api::ctor::ctor(unsafe, crate_path = ::yog_api::ctor)]\nfn __yog_content_auto_register() {\n ::yog_api::__yog_content_registrars()\n .lock()\n .unwrap_or_else(|e| e.into_inner())\n .push(register);\n}\n",
2170 );
2171 }
2172 code
2173}
2174
2175fn is_keyword(s: &str) -> bool {
2176 const KW: &[&str] = &[
2177 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2178 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2179 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2180 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2181 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2182 ];
2183 KW.contains(&s)
2184}
2185
2186pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2189 let out = root.join(GENERATED_FILE);
2190 let Some(code) = &compiled.source else {
2191 let _ = std::fs::remove_file(&out);
2192 return Ok(());
2193 };
2194 crate::write_if_changed(&out, code)?;
2195 Ok(())
2196}
2197
2198#[derive(Default)]
2201pub struct OutputSet {
2202 items: Vec<Option<Output>>,
2203 at: HashMap<String, usize>,
2204}
2205
2206impl OutputSet {
2207 pub fn get(&self, path: &str) -> Option<&[u8]> {
2208 let i = *self.at.get(path)?;
2209 self.items[i].as_ref().map(|(_, b)| b.as_slice())
2210 }
2211
2212 pub fn contains(&self, path: &str) -> bool {
2213 self.at.contains_key(path)
2214 }
2215
2216 pub fn paths(&self) -> impl Iterator<Item = &String> {
2217 self.at.keys()
2218 }
2219
2220 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2222 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2223 self.items[i] = None;
2224 }
2225 self.items.push(Some((path, bytes)));
2226 }
2227
2228 pub fn into_vec(self) -> Vec<Output> {
2229 self.items.into_iter().flatten().collect()
2230 }
2231}
2232
2233impl From<Vec<Output>> for OutputSet {
2234 fn from(outputs: Vec<Output>) -> Self {
2235 let mut set = OutputSet::default();
2236 for (path, bytes) in outputs {
2237 set.put(path, bytes);
2238 }
2239 set
2240 }
2241}
2242
2243fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2246 let read;
2247 let bytes = match outputs.get(path) {
2248 Some(b) => b,
2249 None => match std::fs::read(root.join(path)) {
2250 Ok(b) => {
2251 read = b;
2252 &read
2253 }
2254 Err(_) => return Ok(Map::new()),
2255 },
2256 };
2257 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2258}
2259
2260pub fn merge_outputs(
2264 root: &Path,
2265 outputs: &mut Vec<Output>,
2266 compiled: Compiled,
2267) -> Result<(), String> {
2268 let mut set = OutputSet::from(std::mem::take(outputs));
2269 let mut errors = Vec::new();
2270 if !compiled.lang_aliases.is_empty() {
2273 let mut lang_files: BTreeSet<String> =
2274 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2275 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2276 for ns in dirs.flatten() {
2277 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2278 for f in files.flatten() {
2279 if let Ok(rel) = f.path().strip_prefix(root) {
2280 let rel = rel.to_string_lossy().replace('\\', "/");
2281 if is_lang_path(&rel) {
2282 lang_files.insert(rel);
2283 }
2284 }
2285 }
2286 }
2287 }
2288 }
2289 for path in lang_files {
2290 let mut map = match json_output(&set, root, &path) {
2291 Ok(m) => m,
2292 Err(e) => {
2293 errors.push(e);
2294 continue;
2295 }
2296 };
2297 let mut changed = false;
2298 for (from, to) in &compiled.lang_aliases {
2299 if let Some(text) = map.get(from).cloned()
2300 && !map.contains_key(to)
2301 {
2302 map.insert(to.clone(), text);
2303 changed = true;
2304 }
2305 }
2306 if changed {
2307 set.put(path, pretty(&Value::Object(map)));
2308 }
2309 }
2310 }
2311 for ((ns, locale), entries) in &compiled.lang {
2312 let path = format!("assets/{ns}/lang/{locale}.json");
2313 let mut map = match json_output(&set, root, &path) {
2314 Ok(m) => m,
2315 Err(e) => {
2316 errors.push(e);
2317 continue;
2318 }
2319 };
2320 for (k, v) in entries {
2321 map.entry(k.clone())
2322 .or_insert_with(|| Value::String(v.clone()));
2323 }
2324 set.put(path, pretty(&Value::Object(map)));
2325 }
2326 for (path, ids) in &compiled.tags {
2327 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2330 let path = if !set.contains(path) && root.join(&plural).is_file() {
2331 plural
2332 } else {
2333 path.clone()
2334 };
2335 let mut map = match json_output(&set, root, &path) {
2336 Ok(m) => m,
2337 Err(e) => {
2338 errors.push(e);
2339 continue;
2340 }
2341 };
2342 let values = map
2343 .entry("values")
2344 .or_insert_with(|| Value::Array(Vec::new()));
2345 if let Value::Array(list) = values {
2346 for id in ids {
2347 if !list.iter().any(|v| v.as_str() == Some(id)) {
2348 list.push(Value::String(id.clone()));
2349 }
2350 }
2351 }
2352 map.entry("replace").or_insert(Value::Bool(false));
2353 set.put(path, pretty(&Value::Object(map)));
2354 }
2355 for (path, bytes) in compiled.outputs {
2356 set.put(path, bytes);
2357 }
2358 *outputs = set.into_vec();
2359 if errors.is_empty() {
2360 Ok(())
2361 } else {
2362 Err(format!(
2363 "merging blocks/items into the mod's files:\n {}",
2364 errors.join("\n ")
2365 ))
2366 }
2367}
2368
2369fn is_lang_path(p: &str) -> bool {
2370 let parts: Vec<&str> = p.split('/').collect();
2371 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2372}
2373
2374#[cfg(test)]
2375mod tests {
2376 use super::*;
2377
2378 fn project(files: &[(&str, &str)]) -> PathBuf {
2379 use std::sync::atomic::{AtomicUsize, Ordering};
2380 static N: AtomicUsize = AtomicUsize::new(0);
2381 let dir = std::env::temp_dir().join(format!(
2382 "yog-content-test-{}-{}",
2383 std::process::id(),
2384 N.fetch_add(1, Ordering::SeqCst)
2385 ));
2386 let _ = std::fs::remove_dir_all(&dir);
2387 for (path, text) in files {
2388 let p = dir.join(path);
2389 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2390 std::fs::write(p, text).unwrap();
2391 }
2392 dir
2393 }
2394
2395 fn output(c: &Compiled, path: &str) -> Value {
2396 let (_, b) = c
2397 .outputs
2398 .iter()
2399 .find(|(p, _)| p == path)
2400 .unwrap_or_else(|| {
2401 panic!(
2402 "no {path} in {:?}",
2403 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2404 )
2405 });
2406 serde_json::from_slice(b).unwrap()
2407 }
2408
2409 #[test]
2410 fn compiles_a_cube_block() {
2411 let root = project(&[
2412 ("resources/block/ruby_ore.png", "png"),
2413 (
2414 "content/blocks/ruby_ore.yblock",
2415 r#"{
2416 "name": "Ruby Ore",
2417 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2418 "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2419 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2420}"#,
2421 ),
2422 ]);
2423 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2424 assert_eq!(c.blocks, 1);
2425 assert_eq!(
2426 output(&c, "assets/mymod/models/block/ruby_ore.json"),
2427 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2428 );
2429 assert_eq!(
2430 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2431 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2432 );
2433 assert_eq!(
2434 output(&c, "assets/mymod/models/item/ruby_ore.json"),
2435 json!({ "parent": "mymod:block/ruby_ore" })
2436 );
2437 assert_eq!(
2438 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2439 json!("mymod:item/ruby_ore")
2440 );
2441 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2442 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2443 assert_eq!(
2444 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2445 json!(3)
2446 );
2447 assert!(
2448 c.outputs
2449 .iter()
2450 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2451 );
2452 let src = c.source.as_ref().unwrap();
2453 assert!(
2454 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2455 "{src}"
2456 );
2457 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2458 assert!(src.contains(".requires_tool()"), "{src}");
2459 assert!(
2460 src.contains("registry.register_block(Blocks::ruby_ore());"),
2461 "{src}"
2462 );
2463
2464 let mut outputs = Vec::new();
2465 merge_outputs(&root, &mut outputs, c).unwrap();
2466 let find = |p: &str| -> Value {
2467 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2468 };
2469 assert_eq!(
2470 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2471 json!("Ruby Ore")
2472 );
2473 assert_eq!(
2474 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2475 json!(["mymod:ruby_ore"])
2476 );
2477 assert_eq!(
2478 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2479 json!(["mymod:ruby_ore"])
2480 );
2481 let _ = std::fs::remove_dir_all(root);
2482 }
2483
2484 #[test]
2485 fn raw_parent_with_elements_gets_a_particle() {
2486 let root = project(&[(
2487 "content/cable.yblock",
2488 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2489 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2490 )]);
2491 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2492 let m = output(&c, "assets/mymod/models/block/cable.json");
2493 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2494 let _ = std::fs::remove_dir_all(root);
2495 }
2496
2497 #[test]
2498 fn compiles_elements_shape_and_connect() {
2499 let root = project(&[(
2500 "content/pipe.yblock",
2501 r##"{
2502 "block": { "render_layer": "cutout" },
2503 "model": {
2504 "textures": { "0": "minecraft:block/stone" },
2505 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2506 "rotation": { "axis": "y", "angle": 22.5 },
2507 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2508 },
2509 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2510 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2511}"##,
2512 )]);
2513 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2514 let m = output(&c, "assets/mymod/models/block/pipe.json");
2515 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2516 assert_eq!(m["render_type"], json!("minecraft:cutout"));
2517 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2518 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2519 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2520 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2521 let src = c.source.unwrap();
2522 assert!(
2523 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2524 "{src}"
2525 );
2526 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2527 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2528 let _ = std::fs::remove_dir_all(root);
2529 }
2530
2531 #[test]
2532 fn reports_errors_with_lines() {
2533 let root = project(&[(
2534 "content/bad.yblock",
2535 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 16],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"north\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
2536 )]);
2537 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2538 assert!(
2539 e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2540 "{e}"
2541 );
2542 assert!(
2543 e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2544 "{e}"
2545 );
2546 assert!(
2547 e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2548 "{e}"
2549 );
2550 assert!(
2551 e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2552 "{e}"
2553 );
2554 let _ = std::fs::remove_dir_all(root);
2555 }
2556
2557 #[test]
2558 fn compiles_recipes() {
2559 let root = project(&[
2560 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2561 (
2562 "content/recipes/ruby_block.yrecipe",
2563 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2564 ),
2565 (
2566 "content/recipes/ruby.yrecipe",
2567 r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2568 ),
2569 (
2570 "content/recipes/smelt_ruby.yrecipe",
2571 r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2572 ),
2573 (
2574 "content/recipes/cut.yrecipe",
2575 r##"{ "type": "stonecutting", "ingredient": ["minecraft:stone", "minecraft:andesite"], "result": "mymod:ruby", "group": "rubies" }"##,
2576 ),
2577 ]);
2578 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2579 assert_eq!(c.recipes, 4);
2580 let src = c.source.clone().unwrap();
2581 for want in [
2582 r#"kind: ::yog_api::RecipeKind::Shaped { pattern: vec!["RR".into(), "RR".into()], key: vec![('R', vec!["mymod:ruby".into()])] }, result: "minecraft:emerald_block".into(), count: 1"#,
2583 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
2584 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
2585 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
2586 r##"group: Some("rubies".into())"##,
2587 ] {
2588 assert!(src.contains(want), "missing {want}\n{src}");
2589 }
2590 let _ = std::fs::remove_dir_all(root);
2591 }
2592
2593 #[test]
2594 fn recipe_mistakes() {
2595 let bad = |recipe: &str| {
2596 let root = project(&[("content/r.yrecipe", recipe)]);
2597 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2598 let _ = std::fs::remove_dir_all(root);
2599 e
2600 };
2601 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2602 assert!(e.contains("pattern uses 'B'"), "{e}");
2603 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2604 assert!(e.contains("write `minecraft:stick`"), "{e}");
2605 let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2606 assert!(e.contains("must be an item, not a tag"), "{e}");
2607 let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2608 assert!(e.contains("no block or item mymod:nothing"), "{e}");
2609 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2610 assert!(e.contains("pattern is for shaped recipes"), "{e}");
2611 }
2612
2613 #[test]
2614 fn block_inventory_names_a_screen() {
2615 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2616 let root = project(&[
2617 ("content/bench.yblock", block),
2618 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2619 ]);
2620 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2621 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2622 let _ = std::fs::remove_dir_all(root);
2623
2624 let root = project(&[("content/bench.yblock", block)]);
2625 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2626 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2627 let _ = std::fs::remove_dir_all(root);
2628 }
2629
2630 #[test]
2631 fn compiles_a_book() {
2632 let book = r#"{
2633 "id": "guide",
2634 "name": "@Book.title",
2635 "categories": [{ "id": "basics", "name": "Basics" }],
2636 "entries": [{
2637 "id": "ruby", "name": "@item:mymod:ruby", "category": "basics",
2638 "pages": [{ "type": "text", "text": "@@not a ref" }, { "type": "spotlight", "item": "mymod:ruby" }]
2639 }]
2640 }"#;
2641 let root = project(&[("content/guide.ybook", book)]);
2642 let mut refs = yres::Refs::default();
2643 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
2644 let out = compile(&root, "mymod", &refs).unwrap();
2645 assert_eq!(out.books, 1);
2646 let src = out.source.unwrap();
2647 assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
2648 assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
2649 assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
2650
2651 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
2652 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2653 assert!(e.contains("not a book"), "{e}");
2654 }
2655
2656 #[test]
2657 fn compiles_an_item_with_resource_refs() {
2658 let root = project(&[(
2659 "content/items/ruby.yitem",
2660 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2661 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2662 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2663 )]);
2664 let mut refs = yres::Refs::default();
2665 refs.texts.insert(
2666 "Items.ruby".into(),
2667 ("item.mymod.ruby".into(), "Ruby".into()),
2668 );
2669 refs.texts.insert(
2670 "Strings.ruby_tip".into(),
2671 ("mymod.ruby_tip".into(), "Shiny".into()),
2672 );
2673 refs.images
2674 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2675 let c = compile(&root, "mymod", &refs).unwrap();
2676 assert_eq!(
2677 output(&c, "assets/mymod/models/item/ruby.json"),
2678 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2679 );
2680 let src = c.source.clone().unwrap();
2681 assert!(src.contains(".max_stack(16)"), "{src}");
2682 assert!(
2683 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2684 "{src}"
2685 );
2686 let mut outputs = vec![(
2688 "assets/mymod/lang/ru_ru.json".to_string(),
2689 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2690 .as_bytes()
2691 .to_vec(),
2692 )];
2693 merge_outputs(&root, &mut outputs, c).unwrap();
2694 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2695 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2696 let _ = std::fs::remove_dir_all(root);
2697 }
2698
2699 #[test]
2700 fn namespace_needed_for_odd_mod_ids() {
2701 let root = project(&[(
2702 "content/x.yitem",
2703 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2704 )]);
2705 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2706 assert!(e.contains("give the namespace"), "{e}");
2707 let _ = std::fs::remove_dir_all(root);
2708 }
2709}