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