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