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)]
1445enum RecipeKind {
1446 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1449 Shapeless(Vec<Vec<String>>),
1451 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1453 Stonecutting(Vec<String>),
1454 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1455}
1456
1457struct BookFile {
1459 id: String,
1460 json: String,
1462}
1463
1464fn compile_book(cx: &mut Ctx, j: &J, mod_ns: Option<&str>) -> Option<BookFile> {
1470 fn convert(cx: &mut Ctx, j: &J, refs_used: &mut bool) -> serde_json::Value {
1471 use serde_json::Value;
1472 match &j.v {
1473 V::Null => Value::Null,
1474 V::Bool(b) => Value::Bool(*b),
1475 V::Num(n) => serde_json::Number::from_f64(*n).map_or(Value::Null, |n| {
1476 if n.as_f64().is_some_and(|f| f.fract() == 0.0 && f.abs() < 9e15) {
1478 Value::from(n.as_f64().unwrap_or(0.0) as i64)
1479 } else {
1480 Value::Number(n)
1481 }
1482 }),
1483 V::Str(s) => match s.strip_prefix('@') {
1484 Some(rest) if rest.starts_with('@') => Value::String(rest.to_string()),
1485 Some(rest) => {
1486 let key = if let Some(id) = rest.strip_prefix("item:").or_else(|| rest.strip_prefix("block:")) {
1487 let kind = if rest.starts_with("item:") { "item" } else { "block" };
1488 let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
1489 Some(format!("{kind}.{ns}.{}", path.replace('/', ".")))
1490 } else {
1491 cx.refs.texts.get(rest).map(|(key, _)| key.clone())
1492 };
1493 match key {
1494 Some(key) => {
1495 *refs_used = true;
1496 Value::String(key)
1497 }
1498 None => {
1499 cx.err(j.line, format!("`{s}`: no text `{rest}` in resources/*.yres"));
1500 Value::String(s.clone())
1501 }
1502 }
1503 }
1504 None => Value::String(s.clone()),
1505 },
1506 V::Arr(items) => Value::Array(items.iter().map(|i| convert(cx, i, refs_used)).collect()),
1507 V::Obj(members) => {
1508 let mut map = serde_json::Map::new();
1509 for (k, v) in members {
1510 map.insert(k.clone(), convert(cx, v, refs_used));
1511 }
1512 Value::Object(map)
1513 }
1514 }
1515 }
1516
1517 if !matches!(j.v, V::Obj(_)) {
1518 cx.err(j.line, "a .ybook file is one JSON object");
1519 return None;
1520 }
1521 let mut refs_used = false;
1522 let mut value = convert(cx, j, &mut refs_used);
1523 let obj = value.as_object_mut()?;
1524 let id = match obj.get("id").and_then(|v| v.as_str()) {
1525 Some(id) if id.contains(':') => id.to_string(),
1526 Some(id) => format!("{}:{id}", mod_ns.unwrap_or("minecraft")),
1527 None => {
1528 cx.err(j.line, "a book needs an `id` (the item that opens it)");
1529 return None;
1530 }
1531 };
1532 if !valid_id(&id) {
1533 cx.err(j.line, format!("book id `{id}`: not a valid id"));
1534 return None;
1535 }
1536 obj.insert("id".into(), id.clone().into());
1537 if refs_used && !obj.contains_key("i18n") {
1538 obj.insert("i18n".into(), true.into());
1539 }
1540 if let Err(e) = serde_json::from_value::<yog_book::Book>(value.clone()) {
1541 cx.err(j.line, format!("not a book: {e}"));
1542 return None;
1543 }
1544 Some(BookFile { id, json: value.to_string() })
1545}
1546
1547fn is_metadata(key: &str) -> bool {
1550 key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
1551}
1552
1553#[derive(Debug, Clone)]
1555struct Recipe {
1556 id: String,
1557 file: String,
1558 kind: RecipeKind,
1559 result: String,
1560 count: u32,
1561 group: Option<String>,
1562 category: Option<String>,
1563 items: Vec<(usize, String)>,
1566}
1567
1568impl Recipe {
1569 fn registration(&self) -> String {
1571 let list = |items: &[String]| {
1572 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1573 format!("vec![{}]", quoted.join(", "))
1574 };
1575 let kind = match &self.kind {
1576 RecipeKind::Shaped { pattern, key } => {
1577 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1578 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1579 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1580 }
1581 RecipeKind::Shapeless(ingredients) => {
1582 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1583 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1584 }
1585 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1586 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1587 list(input),
1588 float(*experience)
1589 ),
1590 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1591 RecipeKind::Smithing { template, base, addition } => format!(
1592 "Smithing {{ template: {}, base: {}, addition: {} }}",
1593 list(template),
1594 list(base),
1595 list(addition)
1596 ),
1597 };
1598 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1599 format!(
1600 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1601 self.id,
1602 self.result,
1603 self.count,
1604 opt(&self.group),
1605 opt(&self.category)
1606 )
1607 }
1608}
1609
1610fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1613 let s = cx.string(j, what)?.trim().to_string();
1614 let (tag, id) = match s.strip_prefix('#') {
1615 Some(rest) => (true, rest),
1616 None => (false, s.as_str()),
1617 };
1618 if tag && !tags {
1619 cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1620 return None;
1621 }
1622 let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1623 if !ok {
1624 let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1625 cx.err(
1626 j.line,
1627 format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1628 );
1629 return None;
1630 }
1631 if !tag {
1632 items.push((j.line, id.to_string()));
1633 }
1634 Some(s)
1635}
1636
1637fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1639 if !j.is_obj() {
1640 cx.err(j.line, "a .yrecipe file is one JSON object");
1641 return None;
1642 }
1643 cx.unknown_keys(
1644 j,
1645 "a .yrecipe",
1646 &[
1647 "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1648 "result", "count", "experience", "cook_time", "template", "base", "addition", "group",
1649 "category",
1650 ],
1651 );
1652 if let Some(f) = j.get("format")
1653 && f.num() != Some(1.0)
1654 {
1655 cx.err(f.line, "format must be 1 (this yog understands format 1)");
1656 }
1657 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1658 let Some(t) = j.get("type") else {
1659 cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1660 return None;
1661 };
1662 let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1663 let cooking = matches!(ty.as_str(), "smelting" | "blasting" | "smoking" | "campfire_cooking");
1664 for (key, owners) in [
1666 ("pattern", &["shaped"][..]),
1667 ("key", &["shaped"]),
1668 ("ingredients", &["shapeless"]),
1669 ("ingredient", &["smelting", "blasting", "smoking", "campfire_cooking", "stonecutting"]),
1670 ("experience", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1671 ("cook_time", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1672 ("template", &["smithing"]),
1673 ("base", &["smithing"]),
1674 ("addition", &["smithing"]),
1675 ] {
1676 if !owners.contains(&ty.as_str())
1677 && let Some(v) = j.get(key)
1678 {
1679 cx.err(v.line, format!("{key} is for {} recipes, this one is {ty}", owners.join("/")));
1680 }
1681 }
1682 let mut items = Vec::new();
1683 let result = match j.get("result") {
1684 Some(v) => recipe_item(cx, v, "result", false, &mut items),
1685 None => {
1686 cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1687 None
1688 }
1689 };
1690 let count = match j.get("count") {
1691 Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1692 None => Some(1),
1693 };
1694 let text = |cx: &mut Ctx, key: &str| j.get(key).and_then(|v| cx.string(v, key).map(str::to_owned));
1695 let group = text(cx, "group");
1696 let category = text(cx, "category");
1697 let kind = match ty.as_str() {
1698 "shaped" => {
1699 let Some(pv) = j.get("pattern") else {
1700 cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1701 return None;
1702 };
1703 let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1704 let width = rows.first().map_or(0, |r| r.chars().count());
1705 if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1706 cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1707 return None;
1708 }
1709 let mut key = Vec::new();
1710 match j.get("key") {
1711 Some(kv) if kv.is_obj() => {
1712 for (k, v) in kv.members() {
1713 let mut chars = k.chars();
1714 match (chars.next(), chars.next()) {
1715 (Some(c), None) if c != ' ' => {
1716 if let Some(alts) = recipe_ingredient(cx, v, &format!("key {k:?}"), &mut items) {
1717 if !rows.iter().any(|r| r.contains(c)) {
1718 cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1719 }
1720 key.push((c, alts));
1721 }
1722 }
1723 _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1724 }
1725 }
1726 }
1727 Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1728 None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1729 }
1730 for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1731 if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1732 cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1733 return None;
1734 }
1735 }
1736 RecipeKind::Shaped { pattern: rows, key }
1737 }
1738 "shapeless" => {
1739 let Some(iv) = j.get("ingredients") else {
1740 cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1741 return None;
1742 };
1743 let list: Vec<Vec<String>> = iv
1744 .items()
1745 .iter()
1746 .filter_map(|i| recipe_ingredient(cx, i, "ingredients[]", &mut items))
1747 .collect();
1748 if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1749 cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1750 return None;
1751 }
1752 RecipeKind::Shapeless(list)
1753 }
1754 "smithing" => {
1755 let mut part = |cx: &mut Ctx, key: &str| match j.get(key) {
1756 Some(v) => recipe_ingredient(cx, v, key, &mut items),
1757 None => {
1758 cx.err(j.line, format!("a smithing recipe needs a \"{key}\""));
1759 None
1760 }
1761 };
1762 let template = part(cx, "template");
1763 let base = part(cx, "base");
1764 let addition = part(cx, "addition");
1765 RecipeKind::Smithing { template: template?, base: base?, addition: addition? }
1766 }
1767 _ => {
1768 let Some(iv) = j.get("ingredient") else {
1769 cx.err(j.line, format!("a {ty} recipe needs an \"ingredient\": the item or tag it takes"));
1770 return None;
1771 };
1772 let input = recipe_ingredient(cx, iv, "ingredient", &mut items)?;
1773 if !cooking {
1774 RecipeKind::Stonecutting(input)
1775 } else {
1776 let (method, default_time) = match ty.as_str() {
1777 "smelting" => ("Smelting", 200.0),
1778 "blasting" => ("Blasting", 100.0),
1779 "smoking" => ("Smoking", 100.0),
1780 _ => ("Campfire", 600.0),
1781 };
1782 let experience = match j.get("experience") {
1783 Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1784 None => 0.1,
1785 };
1786 let cook_time = match j.get("cook_time") {
1787 Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)?,
1788 None => default_time,
1789 } as u32;
1790 RecipeKind::Cooking { method, input, experience, cook_time }
1791 }
1792 }
1793 };
1794 Some(Recipe {
1795 id: format!("{ns}:{p}"),
1796 file: cx.file.clone(),
1797 kind,
1798 result: result?,
1799 count: count?,
1800 group,
1801 category,
1802 items,
1803 })
1804}
1805
1806fn recipe_ingredient(cx: &mut Ctx, j: &J, what: &str, items: &mut Vec<(usize, String)>) -> Option<Vec<String>> {
1808 if j.is_arr() {
1809 let alts: Vec<String> = j.items().iter().filter_map(|i| recipe_item(cx, i, what, true, items)).collect();
1810 if alts.is_empty() {
1811 cx.err(j.line, format!("{what}: an array of items or tags, at least one"));
1812 return None;
1813 }
1814 return Some(alts);
1815 }
1816 recipe_item(cx, j, what, true, items).map(|i| vec![i])
1817}
1818
1819fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1822 let mut out = BTreeSet::new();
1823 let Ok(entries) = std::fs::read_dir(dir) else {
1824 return out;
1825 };
1826 for e in entries.flatten() {
1827 let path = e.path();
1828 if path.is_dir() {
1829 out.extend(inventory_screens(&path));
1830 continue;
1831 }
1832 if path.extension().is_none_or(|x| x != "yui") {
1833 continue;
1834 }
1835 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1836 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1837 continue;
1838 }
1839 if let Some(screen) = text.find("<Screen")
1840 && let Some(at) = text[screen..].find(" id=\"")
1841 {
1842 let rest = &text[screen + at + 5..];
1843 if let Some(end) = rest.find('"') {
1844 out.insert(rest[..end].to_string());
1845 }
1846 }
1847 }
1848 out
1849}
1850
1851pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1852 let mut out = Compiled::default();
1853 let files = find(root);
1854 if files.is_empty() {
1855 return Ok(out);
1856 }
1857 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1858 let mut errors: Vec<String> = Vec::new();
1859 let mut warnings: Vec<String> = Vec::new();
1860 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1861 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1862 let mut entries: Vec<Entry> = Vec::new();
1863 let mut ids: HashMap<String, String> = HashMap::new();
1864 let mut recipes: Vec<Recipe> = Vec::new();
1865 let mut recipe_ids: HashMap<String, String> = HashMap::new();
1866 let mut books: Vec<BookFile> = Vec::new();
1867 let mut book_ids: HashMap<String, String> = HashMap::new();
1868 let mut inventories: Vec<(String, usize, String)> = Vec::new();
1869 for path in &files {
1870 let shown = display(root, path);
1871 let Some(kind) = Kind::of(path) else { continue };
1872 let text = match std::fs::read_to_string(path) {
1873 Ok(t) => t,
1874 Err(e) => {
1875 errors.push(format!("{shown}: {e}"));
1876 continue;
1877 }
1878 };
1879 let j = match json::parse(&text) {
1880 Ok(j) => j,
1881 Err(e) => {
1882 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
1883 continue;
1884 }
1885 };
1886 let mut cx = Ctx {
1887 root,
1888 refs,
1889 file: shown.clone(),
1890 ns: mod_ns.unwrap_or("minecraft").to_string(),
1891 errors: &mut errors,
1892 warnings: &mut warnings,
1893 textures: &mut textures,
1894 inventories: &mut inventories,
1895 };
1896 if kind == Kind::Book {
1897 if let Some(b) = compile_book(&mut cx, &j, mod_ns) {
1898 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
1899 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
1900 }
1901 books.push(b);
1902 }
1903 continue;
1904 }
1905 if kind == Kind::Recipe {
1906 if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
1907 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
1908 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
1909 }
1910 recipes.push(r);
1911 }
1912 continue;
1913 }
1914 if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
1915 {
1916 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
1917 errors.push(format!(
1918 "{shown}: id {} is also defined by {other}",
1919 entry.id
1920 ));
1921 }
1922 entries.push(entry);
1923 }
1924 }
1925 if let Some(ns) = mod_ns {
1928 for r in &recipes {
1929 for (line, item) in &r.items {
1930 if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
1931 errors.push(format!(
1932 "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
1933 r.file
1934 ));
1935 }
1936 }
1937 }
1938 let screens = inventory_screens(&root.join("src"));
1939 for (file, line, id) in &inventories {
1940 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
1941 errors.push(format!(
1942 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
1943 ));
1944 }
1945 }
1946 }
1947 for (target, source) in &textures {
1948 match std::fs::read(source) {
1949 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
1950 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
1951 }
1952 }
1953 out.warnings = warnings;
1954 if !errors.is_empty() {
1955 return Err(format!(
1956 "invalid blocks/items in content/:\n {}",
1957 errors.join("\n ")
1958 ));
1959 }
1960 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
1961 out.items = entries.len() - out.blocks;
1962 out.recipes = recipes.len();
1963 out.books = books.len();
1964 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
1965 && !mentions_content_macro(&root.join("src"))
1966 {
1967 out.warnings.push(format!(
1968 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
1969 CONTENT_DIR
1970 ));
1971 }
1972 out.source = Some(rust_source(&entries, &recipes, &books));
1973 Ok(out)
1974}
1975
1976fn mentions_content_macro(dir: &Path) -> bool {
1978 let Ok(entries) = std::fs::read_dir(dir) else {
1979 return false;
1980 };
1981 entries.flatten().any(|e| {
1982 let p = e.path();
1983 if p.is_dir() {
1984 mentions_content_macro(&p)
1985 } else {
1986 p.extension().is_some_and(|x| x == "rs")
1987 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
1988 }
1989 })
1990}
1991
1992fn rust_ident(path: &str) -> String {
1994 let mut s: String = path
1995 .chars()
1996 .map(|c| {
1997 if c.is_ascii_alphanumeric() {
1998 c.to_ascii_lowercase()
1999 } else {
2000 '_'
2001 }
2002 })
2003 .collect();
2004 if s.starts_with(|c: char| c.is_ascii_digit()) {
2005 s.insert(0, '_');
2006 }
2007 s
2008}
2009
2010fn float(n: f64) -> String {
2011 let s = format!("{n:?}");
2012 if s.contains('.') || s.contains('e') {
2013 s
2014 } else {
2015 format!("{s}.0")
2016 }
2017}
2018
2019fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
2020 let mut code = String::from(
2021 "// <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",
2022 );
2023 for (kind, set, def) in [
2024 (Kind::Block, "Blocks", "BlockDef"),
2025 (Kind::Item, "Items", "ItemDef"),
2026 ] {
2027 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2028 let _ = writeln!(
2029 code,
2030 "\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} {{",
2031 if kind == Kind::Block {
2032 "blocks"
2033 } else {
2034 "items"
2035 }
2036 );
2037 let mut used: HashMap<String, usize> = HashMap::new();
2038 for e in mine {
2039 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2040 let mut ident = rust_ident(path);
2041 let n = used.entry(ident.clone()).or_insert(0);
2042 *n += 1;
2043 if *n > 1 {
2044 let _ = write!(ident, "_{n}");
2045 }
2046 let upper = ident.to_ascii_uppercase();
2047 let ident = if is_keyword(&ident) {
2048 format!("r#{ident}")
2049 } else {
2050 ident
2051 };
2052 let _ = writeln!(
2053 code,
2054 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
2055 e.id, e.file, e.id
2056 );
2057 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2058 match kind {
2059 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2060 Kind::Block => {
2061 let p = &e.block;
2062 if p.hardness.is_some() || p.resistance.is_some() {
2063 let _ = write!(
2064 b,
2065 "\n .strength({}, {})",
2066 float(p.hardness.unwrap_or(1.5)),
2067 float(p.resistance.unwrap_or(6.0))
2068 );
2069 }
2070 if let Some(s) = &p.sound {
2071 let _ = write!(b, "\n .sound({s:?})");
2072 }
2073 if p.light > 0 {
2074 let _ = write!(b, "\n .light_level({})", p.light);
2075 }
2076 if p.requires_tool {
2077 b.push_str("\n .requires_tool()");
2078 }
2079 if p.no_collision {
2080 b.push_str("\n .no_collision()");
2081 }
2082 if let Some(s) = p.slipperiness {
2083 let _ = write!(b, "\n .slipperiness({})", float(s));
2084 }
2085 if let Some(s) = p.shape {
2086 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2087 let _ = write!(b, "\n .shape({})", args.join(", "));
2088 }
2089 if p.connects {
2090 b.push_str("\n .connects_to_neighbors()");
2091 }
2092 if !p.connect_groups.is_empty() {
2093 let groups: Vec<String> =
2094 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2095 let _ =
2096 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
2097 }
2098 if let Some(inv) = &p.inventory {
2099 let _ = write!(b, "\n .inventory({inv:?})");
2100 }
2101 if p.redstone {
2102 b.push_str("\n .redstone_source()");
2103 }
2104 }
2105 Kind::Item => {
2106 let p = &e.item;
2107 if let Some(s) = p.stack_size {
2108 let _ = write!(b, "\n .max_stack({s})");
2109 }
2110 if p.durability > 0 {
2111 let _ = write!(b, "\n .max_damage({})", p.durability);
2112 }
2113 if p.fire_resistant {
2114 b.push_str("\n .fire_resistant()");
2115 }
2116 if p.fuel_ticks > 0 {
2117 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
2118 }
2119 if let Some((n, s, always)) = p.food {
2120 let _ = write!(
2121 b,
2122 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
2123 float(s),
2124 if always { ".can_always_eat()" } else { "" }
2125 );
2126 }
2127 }
2128 }
2129 let _ = writeln!(
2130 code,
2131 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
2132 e.file
2133 );
2134 }
2135 code.push_str("}\n");
2136 }
2137
2138 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");
2139 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2140 for e in entries {
2141 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2142 let mut ident = rust_ident(path);
2143 let n = used.entry((e.kind, ident.clone())).or_insert(0);
2144 *n += 1;
2145 if *n > 1 {
2146 let _ = write!(ident, "_{n}");
2147 }
2148 let ident = if is_keyword(&ident) {
2149 format!("r#{ident}")
2150 } else {
2151 ident
2152 };
2153 if !e.auto {
2154 continue;
2155 }
2156 let _ = match e.kind {
2157 Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
2158 Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
2159 Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2160 };
2161 }
2162 for r in recipes {
2163 let _ = writeln!(code, " {}", r.registration());
2164 }
2165 for b in books {
2166 let _ = writeln!(code, " registry.register_book_json({:?}, {:?});", b.id, b.json);
2167 }
2168 code.push_str("}\n");
2169 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
2170 code.push_str(
2171 "\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",
2172 );
2173 }
2174 code
2175}
2176
2177fn is_keyword(s: &str) -> bool {
2178 const KW: &[&str] = &[
2179 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2180 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2181 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2182 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2183 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2184 ];
2185 KW.contains(&s)
2186}
2187
2188pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2191 let out = root.join(GENERATED_FILE);
2192 let Some(code) = &compiled.source else {
2193 let _ = std::fs::remove_file(&out);
2194 return Ok(());
2195 };
2196 crate::write_if_changed(&out, code)?;
2197 Ok(())
2198}
2199
2200#[derive(Default)]
2203pub struct OutputSet {
2204 items: Vec<Option<Output>>,
2205 at: HashMap<String, usize>,
2206}
2207
2208impl OutputSet {
2209 pub fn get(&self, path: &str) -> Option<&[u8]> {
2210 let i = *self.at.get(path)?;
2211 self.items[i].as_ref().map(|(_, b)| b.as_slice())
2212 }
2213
2214 pub fn contains(&self, path: &str) -> bool {
2215 self.at.contains_key(path)
2216 }
2217
2218 pub fn paths(&self) -> impl Iterator<Item = &String> {
2219 self.at.keys()
2220 }
2221
2222 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2224 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2225 self.items[i] = None;
2226 }
2227 self.items.push(Some((path, bytes)));
2228 }
2229
2230 pub fn into_vec(self) -> Vec<Output> {
2231 self.items.into_iter().flatten().collect()
2232 }
2233}
2234
2235impl From<Vec<Output>> for OutputSet {
2236 fn from(outputs: Vec<Output>) -> Self {
2237 let mut set = OutputSet::default();
2238 for (path, bytes) in outputs {
2239 set.put(path, bytes);
2240 }
2241 set
2242 }
2243}
2244
2245fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2248 let read;
2249 let bytes = match outputs.get(path) {
2250 Some(b) => b,
2251 None => match std::fs::read(root.join(path)) {
2252 Ok(b) => {
2253 read = b;
2254 &read
2255 }
2256 Err(_) => return Ok(Map::new()),
2257 },
2258 };
2259 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2260}
2261
2262pub fn merge_outputs(
2266 root: &Path,
2267 outputs: &mut Vec<Output>,
2268 compiled: Compiled,
2269) -> Result<(), String> {
2270 let mut set = OutputSet::from(std::mem::take(outputs));
2271 let mut errors = Vec::new();
2272 if !compiled.lang_aliases.is_empty() {
2275 let mut lang_files: BTreeSet<String> =
2276 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2277 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2278 for ns in dirs.flatten() {
2279 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2280 for f in files.flatten() {
2281 if let Ok(rel) = f.path().strip_prefix(root) {
2282 let rel = rel.to_string_lossy().replace('\\', "/");
2283 if is_lang_path(&rel) {
2284 lang_files.insert(rel);
2285 }
2286 }
2287 }
2288 }
2289 }
2290 }
2291 for path in lang_files {
2292 let mut map = match json_output(&set, root, &path) {
2293 Ok(m) => m,
2294 Err(e) => {
2295 errors.push(e);
2296 continue;
2297 }
2298 };
2299 let mut changed = false;
2300 for (from, to) in &compiled.lang_aliases {
2301 if let Some(text) = map.get(from).cloned()
2302 && !map.contains_key(to)
2303 {
2304 map.insert(to.clone(), text);
2305 changed = true;
2306 }
2307 }
2308 if changed {
2309 set.put(path, pretty(&Value::Object(map)));
2310 }
2311 }
2312 }
2313 for ((ns, locale), entries) in &compiled.lang {
2314 let path = format!("assets/{ns}/lang/{locale}.json");
2315 let mut map = match json_output(&set, root, &path) {
2316 Ok(m) => m,
2317 Err(e) => {
2318 errors.push(e);
2319 continue;
2320 }
2321 };
2322 for (k, v) in entries {
2323 map.entry(k.clone())
2324 .or_insert_with(|| Value::String(v.clone()));
2325 }
2326 set.put(path, pretty(&Value::Object(map)));
2327 }
2328 for (path, ids) in &compiled.tags {
2329 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2332 let path = if !set.contains(path) && root.join(&plural).is_file() {
2333 plural
2334 } else {
2335 path.clone()
2336 };
2337 let mut map = match json_output(&set, root, &path) {
2338 Ok(m) => m,
2339 Err(e) => {
2340 errors.push(e);
2341 continue;
2342 }
2343 };
2344 let values = map
2345 .entry("values")
2346 .or_insert_with(|| Value::Array(Vec::new()));
2347 if let Value::Array(list) = values {
2348 for id in ids {
2349 if !list.iter().any(|v| v.as_str() == Some(id)) {
2350 list.push(Value::String(id.clone()));
2351 }
2352 }
2353 }
2354 map.entry("replace").or_insert(Value::Bool(false));
2355 set.put(path, pretty(&Value::Object(map)));
2356 }
2357 for (path, bytes) in compiled.outputs {
2358 set.put(path, bytes);
2359 }
2360 *outputs = set.into_vec();
2361 if errors.is_empty() {
2362 Ok(())
2363 } else {
2364 Err(format!(
2365 "merging blocks/items into the mod's files:\n {}",
2366 errors.join("\n ")
2367 ))
2368 }
2369}
2370
2371fn is_lang_path(p: &str) -> bool {
2372 let parts: Vec<&str> = p.split('/').collect();
2373 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2374}
2375
2376#[cfg(test)]
2377mod tests {
2378 use super::*;
2379
2380 fn project(files: &[(&str, &str)]) -> PathBuf {
2381 use std::sync::atomic::{AtomicUsize, Ordering};
2382 static N: AtomicUsize = AtomicUsize::new(0);
2383 let dir = std::env::temp_dir().join(format!(
2384 "yog-content-test-{}-{}",
2385 std::process::id(),
2386 N.fetch_add(1, Ordering::SeqCst)
2387 ));
2388 let _ = std::fs::remove_dir_all(&dir);
2389 for (path, text) in files {
2390 let p = dir.join(path);
2391 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2392 std::fs::write(p, text).unwrap();
2393 }
2394 dir
2395 }
2396
2397 fn output(c: &Compiled, path: &str) -> Value {
2398 let (_, b) = c
2399 .outputs
2400 .iter()
2401 .find(|(p, _)| p == path)
2402 .unwrap_or_else(|| {
2403 panic!(
2404 "no {path} in {:?}",
2405 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2406 )
2407 });
2408 serde_json::from_slice(b).unwrap()
2409 }
2410
2411 #[test]
2412 fn compiles_a_cube_block() {
2413 let root = project(&[
2414 ("resources/block/ruby_ore.png", "png"),
2415 (
2416 "content/blocks/ruby_ore.yblock",
2417 r#"{
2418 "name": "Ruby Ore",
2419 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2420 "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2421 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2422}"#,
2423 ),
2424 ]);
2425 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2426 assert_eq!(c.blocks, 1);
2427 assert_eq!(
2428 output(&c, "assets/mymod/models/block/ruby_ore.json"),
2429 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2430 );
2431 assert_eq!(
2432 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2433 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2434 );
2435 assert_eq!(
2436 output(&c, "assets/mymod/models/item/ruby_ore.json"),
2437 json!({ "parent": "mymod:block/ruby_ore" })
2438 );
2439 assert_eq!(
2440 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2441 json!("mymod:item/ruby_ore")
2442 );
2443 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2444 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2445 assert_eq!(
2446 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2447 json!(3)
2448 );
2449 assert!(
2450 c.outputs
2451 .iter()
2452 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2453 );
2454 let src = c.source.as_ref().unwrap();
2455 assert!(
2456 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2457 "{src}"
2458 );
2459 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2460 assert!(src.contains(".requires_tool()"), "{src}");
2461 assert!(
2462 src.contains("registry.register_block(Blocks::ruby_ore());"),
2463 "{src}"
2464 );
2465
2466 let mut outputs = Vec::new();
2467 merge_outputs(&root, &mut outputs, c).unwrap();
2468 let find = |p: &str| -> Value {
2469 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2470 };
2471 assert_eq!(
2472 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2473 json!("Ruby Ore")
2474 );
2475 assert_eq!(
2476 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2477 json!(["mymod:ruby_ore"])
2478 );
2479 assert_eq!(
2480 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2481 json!(["mymod:ruby_ore"])
2482 );
2483 let _ = std::fs::remove_dir_all(root);
2484 }
2485
2486 #[test]
2487 fn raw_parent_with_elements_gets_a_particle() {
2488 let root = project(&[(
2489 "content/cable.yblock",
2490 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2491 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2492 )]);
2493 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2494 let m = output(&c, "assets/mymod/models/block/cable.json");
2495 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2496 let _ = std::fs::remove_dir_all(root);
2497 }
2498
2499 #[test]
2500 fn compiles_elements_shape_and_connect() {
2501 let root = project(&[(
2502 "content/pipe.yblock",
2503 r##"{
2504 "block": { "render_layer": "cutout" },
2505 "model": {
2506 "textures": { "0": "minecraft:block/stone" },
2507 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2508 "rotation": { "axis": "y", "angle": 22.5 },
2509 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2510 },
2511 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2512 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2513}"##,
2514 )]);
2515 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2516 let m = output(&c, "assets/mymod/models/block/pipe.json");
2517 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2518 assert_eq!(m["render_type"], json!("minecraft:cutout"));
2519 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2520 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2521 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2522 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2523 let src = c.source.unwrap();
2524 assert!(
2525 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2526 "{src}"
2527 );
2528 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2529 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2530 let _ = std::fs::remove_dir_all(root);
2531 }
2532
2533 #[test]
2534 fn reports_errors_with_lines() {
2535 let root = project(&[(
2536 "content/bad.yblock",
2537 "{\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}",
2538 )]);
2539 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2540 assert!(
2541 e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2542 "{e}"
2543 );
2544 assert!(
2545 e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2546 "{e}"
2547 );
2548 assert!(
2549 e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2550 "{e}"
2551 );
2552 assert!(
2553 e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2554 "{e}"
2555 );
2556 let _ = std::fs::remove_dir_all(root);
2557 }
2558
2559 #[test]
2560 fn compiles_recipes() {
2561 let root = project(&[
2562 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2563 (
2564 "content/recipes/ruby_block.yrecipe",
2565 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2566 ),
2567 (
2568 "content/recipes/ruby.yrecipe",
2569 r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2570 ),
2571 (
2572 "content/recipes/smelt_ruby.yrecipe",
2573 r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2574 ),
2575 (
2576 "content/recipes/cut.yrecipe",
2577 r##"{ "type": "stonecutting", "ingredient": ["minecraft:stone", "minecraft:andesite"], "result": "mymod:ruby", "group": "rubies" }"##,
2578 ),
2579 ]);
2580 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2581 assert_eq!(c.recipes, 4);
2582 let src = c.source.clone().unwrap();
2583 for want in [
2584 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"#,
2585 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
2586 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
2587 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
2588 r##"group: Some("rubies".into())"##,
2589 ] {
2590 assert!(src.contains(want), "missing {want}\n{src}");
2591 }
2592 let _ = std::fs::remove_dir_all(root);
2593 }
2594
2595 #[test]
2596 fn recipe_mistakes() {
2597 let bad = |recipe: &str| {
2598 let root = project(&[("content/r.yrecipe", recipe)]);
2599 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2600 let _ = std::fs::remove_dir_all(root);
2601 e
2602 };
2603 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2604 assert!(e.contains("pattern uses 'B'"), "{e}");
2605 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2606 assert!(e.contains("write `minecraft:stick`"), "{e}");
2607 let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2608 assert!(e.contains("must be an item, not a tag"), "{e}");
2609 let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2610 assert!(e.contains("no block or item mymod:nothing"), "{e}");
2611 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2612 assert!(e.contains("pattern is for shaped recipes"), "{e}");
2613 }
2614
2615 #[test]
2616 fn block_inventory_names_a_screen() {
2617 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2618 let root = project(&[
2619 ("content/bench.yblock", block),
2620 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2621 ]);
2622 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2623 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2624 let _ = std::fs::remove_dir_all(root);
2625
2626 let root = project(&[("content/bench.yblock", block)]);
2627 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2628 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2629 let _ = std::fs::remove_dir_all(root);
2630 }
2631
2632 #[test]
2633 fn compiles_a_book() {
2634 let book = r#"{
2635 "id": "guide",
2636 "name": "@Book.title",
2637 "categories": [{ "id": "basics", "name": "Basics" }],
2638 "entries": [{
2639 "id": "ruby", "name": "@item:mymod:ruby", "category": "basics",
2640 "pages": [{ "type": "text", "text": "@@not a ref" }, { "type": "spotlight", "item": "mymod:ruby" }]
2641 }]
2642 }"#;
2643 let root = project(&[("content/guide.ybook", book)]);
2644 let mut refs = yres::Refs::default();
2645 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
2646 let out = compile(&root, "mymod", &refs).unwrap();
2647 assert_eq!(out.books, 1);
2648 let src = out.source.unwrap();
2649 assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
2650 assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
2651 assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
2652
2653 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
2654 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2655 assert!(e.contains("not a book"), "{e}");
2656 }
2657
2658 #[test]
2659 fn compiles_an_item_with_resource_refs() {
2660 let root = project(&[(
2661 "content/items/ruby.yitem",
2662 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2663 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2664 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2665 )]);
2666 let mut refs = yres::Refs::default();
2667 refs.texts.insert(
2668 "Items.ruby".into(),
2669 ("item.mymod.ruby".into(), "Ruby".into()),
2670 );
2671 refs.texts.insert(
2672 "Strings.ruby_tip".into(),
2673 ("mymod.ruby_tip".into(), "Shiny".into()),
2674 );
2675 refs.images
2676 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2677 let c = compile(&root, "mymod", &refs).unwrap();
2678 assert_eq!(
2679 output(&c, "assets/mymod/models/item/ruby.json"),
2680 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2681 );
2682 let src = c.source.clone().unwrap();
2683 assert!(src.contains(".max_stack(16)"), "{src}");
2684 assert!(
2685 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2686 "{src}"
2687 );
2688 let mut outputs = vec![(
2690 "assets/mymod/lang/ru_ru.json".to_string(),
2691 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2692 .as_bytes()
2693 .to_vec(),
2694 )];
2695 merge_outputs(&root, &mut outputs, c).unwrap();
2696 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2697 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2698 let _ = std::fs::remove_dir_all(root);
2699 }
2700
2701 #[test]
2702 fn namespace_needed_for_odd_mod_ids() {
2703 let root = project(&[(
2704 "content/x.yitem",
2705 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2706 )]);
2707 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2708 assert!(e.contains("give the namespace"), "{e}");
2709 let _ = std::fs::remove_dir_all(root);
2710 }
2711}