1mod import;
23pub mod json;
24mod refs;
25mod table;
26
27pub use import::import_command;
28
29use crate::yres;
30use yog_data::Path as At;
31use json::num_value;
32use serde_json::{Map, Value, json};
33use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
34use std::fmt::Write as _;
35use std::path::{Path, PathBuf};
36
37pub const CONTENT_DIR: &str = "content";
39pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
41
42pub type Output = (String, Vec<u8>);
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum Kind {
48 Block,
50 Item,
52 Recipe,
54 Book,
56 Def,
59 Model,
61 Anim,
63 Entity,
65}
66
67impl Kind {
68 pub fn of(path: &Path) -> Option<Kind> {
69 match path.extension()?.to_str()? {
70 "yblock" => Some(Kind::Block),
71 "yitem" => Some(Kind::Item),
72 "yrecipe" => Some(Kind::Recipe),
73 "ybook" => Some(Kind::Book),
74 "ydef" => Some(Kind::Def),
75 "ymodel" => Some(Kind::Model),
76 "yanim" => Some(Kind::Anim),
77 "yentity" => Some(Kind::Entity),
78 _ => None,
79 }
80 }
81
82 fn word(self) -> &'static str {
83 match self {
84 Kind::Block => "block",
85 Kind::Item => "item",
86 Kind::Recipe => "recipe",
87 Kind::Book => "book",
88 Kind::Def => "definition",
89 Kind::Model => "model",
90 Kind::Anim => "animation",
91 Kind::Entity => "entity",
92 }
93 }
94}
95
96#[derive(Debug, Default)]
98pub struct Compiled {
99 pub outputs: Vec<Output>,
101 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
104 lang_aliases: Vec<(String, String)>,
106 tags: BTreeMap<String, BTreeMap<String, bool>>,
109 pub source: Option<String>,
111 pub data_files: Vec<(String, Vec<u8>)>,
114 pub warnings: Vec<String>,
115 pub registrations: String,
118 pub blocks: usize,
119 pub items: usize,
120 pub recipes: usize,
121 pub books: usize,
122}
123
124pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
127pub const DISPLAY_SLOTS: [&str; 8] = [
128 "thirdperson_righthand",
129 "thirdperson_lefthand",
130 "firstperson_righthand",
131 "firstperson_lefthand",
132 "gui",
133 "head",
134 "ground",
135 "fixed",
136];
137pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
138pub const RECIPE_TYPES: [&str; 8] =
139 ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
140
141pub struct Template {
143 pub name: &'static str,
144 pub parent: &'static str,
146 pub slots: &'static [&'static str],
148 pub sprite: bool,
150 pub has_particle: bool,
152}
153
154pub const TEMPLATES: &[Template] = &[
155 Template {
156 name: "cube_all",
157 parent: "minecraft:block/cube_all",
158 slots: &["all"],
159 sprite: false,
160 has_particle: true,
161 },
162 Template {
163 name: "cube_column",
164 parent: "minecraft:block/cube_column",
165 slots: &["end", "side"],
166 sprite: false,
167 has_particle: true,
168 },
169 Template {
170 name: "cube_bottom_top",
171 parent: "minecraft:block/cube_bottom_top",
172 slots: &["top", "bottom", "side"],
173 sprite: false,
174 has_particle: true,
175 },
176 Template {
177 name: "cube",
178 parent: "minecraft:block/cube",
179 slots: &["down", "up", "north", "south", "west", "east"],
180 sprite: false,
181 has_particle: false,
182 },
183 Template {
184 name: "orientable",
185 parent: "minecraft:block/orientable",
186 slots: &["front", "side", "top"],
187 sprite: false,
188 has_particle: true,
189 },
190 Template {
191 name: "cross",
192 parent: "minecraft:block/cross",
193 slots: &["cross"],
194 sprite: false,
195 has_particle: true,
196 },
197 Template {
198 name: "generated",
199 parent: "minecraft:item/generated",
200 slots: &["layer0"],
201 sprite: true,
202 has_particle: true,
203 },
204 Template {
205 name: "handheld",
206 parent: "minecraft:item/handheld",
207 slots: &["layer0"],
208 sprite: true,
209 has_particle: true,
210 },
211];
212
213pub fn template(name: &str) -> Option<&'static Template> {
214 TEMPLATES.iter().find(|t| t.name == name)
215}
216
217pub fn valid_namespace(s: &str) -> bool {
219 !s.is_empty()
220 && s.bytes().all(|b| {
221 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
222 })
223}
224
225pub fn valid_path(s: &str) -> bool {
227 !s.is_empty()
228 && !s.starts_with('/')
229 && !s.ends_with('/')
230 && s.bytes().all(|b| {
231 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
232 })
233}
234
235pub fn valid_id(s: &str) -> bool {
237 match s.split_once(':') {
238 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
239 None => valid_path(s),
240 }
241}
242
243pub fn find(root: &Path) -> Vec<PathBuf> {
247 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
248 let Ok(entries) = std::fs::read_dir(dir) else {
249 return;
250 };
251 for entry in entries.flatten() {
252 let path = entry.path();
253 if path.is_dir() {
254 walk(&path, out);
255 } else if Kind::of(&path).is_some() {
256 out.push(path);
257 }
258 }
259 }
260 let mut out = Vec::new();
261 walk(&root.join(CONTENT_DIR), &mut out);
262 out.sort();
263 out
264}
265
266fn display(root: &Path, p: &Path) -> String {
267 p.strip_prefix(root)
268 .unwrap_or(p)
269 .to_string_lossy()
270 .replace('\\', "/")
271}
272
273#[derive(Debug, Clone, Default)]
276struct BlockProps {
277 hardness: Option<f64>,
278 resistance: Option<f64>,
279 sound: Option<String>,
280 light: u8,
281 tools: Vec<String>,
283 tool_level: Option<(String, usize)>,
286 requires_tool: bool,
287 no_collision: bool,
288 slipperiness: Option<f64>,
289 shape: Option<[f64; 6]>,
291 connect_groups: Vec<String>,
292 connects: bool,
293 facing: String,
295 placement: String,
297 attach: bool,
298 waterlogged: bool,
299 properties: Vec<(String, Vec<String>)>,
301 powered: Option<String>,
303 comparator: Option<String>,
304 accepts_items: bool,
306 inventory: Option<String>,
308 redstone: bool,
310}
311
312#[derive(Debug, Clone, Default)]
313struct ItemProps {
314 stack_size: Option<u8>,
315 durability: u32,
316 fire_resistant: bool,
317 fuel_ticks: u32,
318 food: Option<(u32, f64, bool)>,
320 tool: Option<(String, String, f32, f32)>,
322 armor: Option<(String, String)>,
324 wearable: Option<String>,
326}
327
328#[derive(Debug, Clone)]
330struct Entry {
331 kind: Kind,
332 id: String,
333 file: String,
334 auto: bool,
335 requires: Vec<String>,
337 block: BlockProps,
338 item: ItemProps,
339}
340
341struct Ctx<'a> {
343 root: &'a Path,
344 refs: &'a yres::Refs,
345 file: String,
346 ns: String,
347 errors: &'a mut Vec<String>,
348 warnings: &'a mut Vec<String>,
349 textures: &'a mut BTreeMap<String, PathBuf>,
351 inventories: &'a mut Vec<(String, usize, String)>,
354 deferred: &'a mut Vec<(String, usize, String, yog_data::Ref)>,
357}
358
359impl Ctx<'_> {
360 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
361 self.errors.push(format!("{}:{line}: {msg}", self.file));
362 }
363
364 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
365 self.warnings.push(format!("{}:{line}: {msg}", self.file));
366 }
367
368 fn err_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
370 let line = loc(path);
371 self.err(line, format!("{path}: {msg}"));
372 }
373
374 fn warn_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
375 let line = loc(path);
376 self.warn(line, format!("{path}: {msg}"));
377 }
378
379 fn schema_errors(&mut self, errors: yog_data::Errors) {
381 for e in errors.0 {
382 let l = e.at.map_or(1, |a| a.line as usize);
383 if e.path.is_root() {
384 self.err(l, e.message)
385 } else {
386 self.err(l, format!("{}: {}", e.path, e.message))
387 }
388 }
389 }
390
391 fn texture(&mut self, loc: Loc, path: &At, t: &yog_formats::content::Texture) -> Option<String> {
395 let s = match t {
396 yog_formats::content::Texture::Slot(slot) => return Some(format!("#{slot}")),
397 yog_formats::content::Texture::Res(r) => {
398 let name = format!("{}.{}", r.set, r.key);
399 return match self.refs.images.get(&name) {
400 Some(id) => Some(id.clone()),
401 None => {
402 self.err_at(loc, path, format!("`@{name}`: no image `{name}` in resources/*.yres"));
403 None
404 }
405 };
406 }
407 yog_formats::content::Texture::Plain(s) => s.trim().to_string(),
408 };
409 if let Some(stem) = s.strip_suffix(".png") {
410 let rel = stem.trim_start_matches("./").trim_start_matches('/');
411 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
412 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
413 self.err_at(loc, path, format!("`{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"));
414 return None;
415 }
416 let file = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png"));
417 if !file.is_file() {
418 self.err_at(loc, path, format!("resources/{rel}.png doesn't exist"));
419 return None;
420 }
421 let target = format!("assets/{}/textures/{rel}.png", self.ns);
422 if let Some(other) = self.textures.insert(target.clone(), file.clone())
423 && other != file
424 {
425 let shown = display(self.root, &other);
426 self.err_at(loc, path, format!("{target} also comes from {shown}"));
427 }
428 let meta = self.root.join(yres::RESOURCES_DIR).join(format!("{rel}.png.mcmeta"));
429 if meta.is_file() {
430 self.textures.insert(format!("{target}.mcmeta"), meta);
431 }
432 return Some(format!("{}:{rel}", self.ns));
433 }
434 if !valid_id(&s) {
435 self.err_at(loc, path, format!("`{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"));
436 return None;
437 }
438 let id = if s.contains(':') { s } else { format!("minecraft:{s}") };
439 let (ns, tex) = id.split_once(':').unwrap_or_default();
440 if ns == self.ns {
441 let file = self.root.join("assets").join(ns).join("textures").join(format!("{tex}.png"));
442 let bundled = self.textures.contains_key(&format!("assets/{ns}/textures/{tex}.png")) || self.refs.images.values().any(|v| *v == id);
443 if !file.is_file() && !bundled {
444 self.warn_at(loc, path, format!("texture {id} isn't in assets/{ns}/textures/ or resources/"));
445 }
446 }
447 Some(id)
448 }
449}
450
451type Loc<'a> = &'a dyn Fn(&At) -> usize;
453
454struct Model {
456 json: Value,
457 bounds: Option<[f64; 6]>,
459}
460
461fn nums(ns: &[f64]) -> Value {
462 Value::Array(ns.iter().map(|n| num_value(*n)).collect())
463}
464
465fn model(cx: &mut Ctx, loc: Loc, at: &At, m: &yog_formats::content::Model, item: bool) -> Option<Model> {
467 let mut out = Map::new();
468 let mut tpl: Option<&Template> = None;
469 let mut raw_parent = false;
470 if let Some(name) = &m.parent {
471 if let Some(t) = template(name) {
472 if t.sprite && !item {
473 cx.err_at(loc, &at.key("parent"), format!("{name:?} is an item sprite — block models use cube_all, cube_column, ..."));
474 }
475 out.insert("parent".into(), Value::String(t.parent.into()));
476 tpl = Some(t);
477 } else if name.contains(':') && valid_id(name) {
478 out.insert("parent".into(), Value::String(name.clone()));
479 raw_parent = true;
480 } else {
481 let names: Vec<&str> = TEMPLATES.iter().filter(|t| item || !t.sprite).map(|t| t.name).collect();
482 cx.err_at(loc, &at.key("parent"), format!("{name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")));
483 return None;
484 }
485 }
486
487 let mut slots: Vec<String> = Vec::new();
489 let mut textures = Map::new();
490 for (slot, tex) in &m.textures.0 {
491 let here = at.key("textures").key(slot);
492 if slot.is_empty() || slot.starts_with('#') {
493 cx.err_at(loc, &here, "slot names are plain words (`all`, `side`, `0`)");
494 continue;
495 }
496 if let Some(t) = cx.texture(loc, &here, tex) {
497 textures.insert(slot.clone(), Value::String(t));
498 slots.push(slot.clone());
499 }
500 }
501 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
502 if let Some(t) = tpl {
503 if t.sprite {
504 if !defined("layer0", &textures) {
505 cx.err_at(loc, at, format!("parent {:?} needs textures.layer0", t.name));
506 }
507 for slot in &slots {
508 let n = slot.strip_prefix("layer").and_then(|n| n.parse::<usize>().ok());
509 match n {
510 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
511 Some(_) => cx.err_at(loc, &at.key("textures").key(slot), "without the layer before it"),
512 None if slot == "particle" => {}
513 None => cx.warn_at(loc, &at.key("textures").key(slot), "isn't used by an item sprite (layer0, layer1, ...)"),
514 }
515 }
516 } else {
517 for slot in t.slots {
518 if !defined(slot, &textures) {
519 cx.err_at(loc, at, format!("parent {:?} needs textures.{slot}", t.name));
520 }
521 }
522 }
523 }
524
525 let mut bounds: Option<[f64; 6]> = None;
527 if let Some(els) = &m.elements {
528 if let Some(t) = tpl {
529 cx.err_at(loc, &at.key("elements"), format!("elements replace the shape of parent {:?} — remove one of them", t.name));
530 }
531 let mut elements = Vec::new();
532 for (i, el) in els.iter().enumerate() {
533 if let Some(json) = element(cx, loc, &at.key("elements").index(i), el, &textures, raw_parent) {
534 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
535 for a in 0..3 {
536 b[a] = b[a].min(el.from[a].min(el.to[a]));
537 b[a + 3] = b[a + 3].max(el.from[a].max(el.to[a]));
538 }
539 elements.push(json);
540 }
541 }
542 out.insert("elements".into(), Value::Array(elements));
543 if m.parent.is_none() {
546 out.insert("parent".into(), Value::String("minecraft:block/block".into()));
547 }
548 } else if tpl.is_none() && !raw_parent {
549 cx.err_at(loc, at, "no parent and no elements — give a template (parent: \"cube_all\") or elements");
550 }
551
552 if !textures.contains_key("particle")
556 && !tpl.is_some_and(|t| t.has_particle)
557 && (!raw_parent || m.elements.is_some())
558 && let Some(first) = slots.first()
559 {
560 let v = textures[first].clone();
561 textures.insert("particle".into(), v);
562 }
563 if !textures.is_empty() {
564 out.insert("textures".into(), Value::Object(textures));
565 }
566 if let Some(d) = &m.display {
567 let mut display = Map::new();
568 for (place, t) in d.iter() {
569 let mut o = Map::new();
570 for (key, v) in [("rotation", t.rotation), ("translation", t.translation), ("scale", t.scale)] {
571 if let Some(v) = v {
572 o.insert(key.into(), nums(&v));
573 }
574 }
575 display.insert(place.into(), Value::Object(o));
576 }
577 out.insert("display".into(), Value::Object(display));
578 }
579 if let Some(b) = m.ambient_occlusion {
580 out.insert("ambientocclusion".into(), Value::Bool(b));
581 }
582 if let Some(g) = &m.gui_light {
583 out.insert("gui_light".into(), Value::String(yog_formats::content::word(g)));
584 }
585 Some(Model { json: Value::Object(out), bounds })
586}
587
588fn element(cx: &mut Ctx, loc: Loc, at: &At, el: &yog_formats::content::Element, textures: &Map<String, Value>, raw_parent: bool) -> Option<Value> {
590 let mut out = Map::new();
591 out.insert("from".into(), nums(&el.from));
592 out.insert("to".into(), nums(&el.to));
593 if let Some(r) = &el.rotation {
594 if (r.angle / 22.5).fract() != 0.0 {
595 cx.err_at(loc, &at.key("rotation").key("angle"), format!("{} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees", r.angle));
596 }
597 let mut rot = Map::new();
598 rot.insert("origin".into(), nums(&r.origin.unwrap_or([8.0, 8.0, 8.0])));
599 rot.insert("axis".into(), Value::String(yog_formats::content::word(&r.axis)));
600 rot.insert("angle".into(), num_value(r.angle));
601 if r.rescale {
602 rot.insert("rescale".into(), Value::Bool(true));
603 }
604 out.insert("rotation".into(), Value::Object(rot));
605 }
606 if el.shade == Some(false) {
607 out.insert("shade".into(), Value::Bool(false));
608 }
609 let mut faces = Map::new();
610 match &el.faces {
611 None => cx.warn_at(loc, at, "no faces — it's invisible"),
612 Some(f) => {
613 for (dir, face) in f.iter() {
614 let here = at.key("faces").key(dir);
615 let mut fo = Map::new();
616 let slot = face.texture.strip_prefix('#').unwrap_or(&face.texture);
617 if !textures.contains_key(slot) && !raw_parent {
618 cx.err_at(loc, &here.key("texture"), format!("`#{slot}`: no such slot in textures"));
619 }
620 fo.insert("texture".into(), Value::String(format!("#{slot}")));
621 if let Some(uv) = face.uv {
622 fo.insert("uv".into(), nums(&uv));
623 }
624 match face.rotation {
625 None | Some(0) => {}
626 Some(n @ (90 | 180 | 270)) => {
627 fo.insert("rotation".into(), Value::from(n));
628 }
629 Some(_) => cx.err_at(loc, &here.key("rotation"), "must be 0, 90, 180 or 270"),
630 }
631 if let Some(c) = &face.cullface {
632 fo.insert("cullface".into(), Value::String(yog_formats::content::word(c)));
633 }
634 if let Some(t) = face.tint
635 && t >= 0
636 {
637 fo.insert("tintindex".into(), Value::from(t));
638 }
639 faces.insert(dir.into(), Value::Object(fo));
640 }
641 }
642 }
643 out.insert("faces".into(), Value::Object(faces));
644 Some(Value::Object(out))
645}
646
647fn file_id(cx: &mut Ctx, line: usize, id: Option<&str>, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
649 let raw = id.map(str::to_string).unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
650 let (ns, p) = match raw.split_once(':') {
651 Some((ns, p)) => (ns.to_string(), p.to_string()),
652 None => match mod_ns {
653 Some(ns) => (ns.to_string(), raw.clone()),
654 None => {
655 cx.err(line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: id: \"mynamespace:name\"");
656 return None;
657 }
658 },
659 };
660 if !valid_namespace(&ns) || !valid_path(&p) {
661 cx.err(line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
662 return None;
663 }
664 Some((ns, p))
665}
666
667fn turn(facing: Option<&str>, face: Option<&str>) -> (f64, f64) {
672 let yaw = |f: &str| match f {
673 "east" => 90.0,
674 "south" => 180.0,
675 "west" => 270.0,
676 _ => 0.0,
677 };
678 match (face, facing) {
679 (Some("wall"), Some(f)) => (90.0, yaw(f)),
680 (Some("ceiling"), Some(f)) => (180.0, (yaw(f) + 180.0) % 360.0),
681 (_, Some("up")) => (270.0, 0.0),
682 (_, Some("down")) => (90.0, 0.0),
683 (_, Some(f)) => (0.0, yaw(f)),
684 _ => (0.0, 0.0),
685 }
686}
687
688fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
689 let mut v = Map::new();
690 v.insert("model".into(), Value::String(model.into()));
691 if x != 0.0 {
692 v.insert("x".into(), num_value(x));
693 }
694 if y != 0.0 {
695 v.insert("y".into(), num_value(y));
696 }
697 if uvlock {
698 v.insert("uvlock".into(), Value::Bool(true));
699 }
700 if let Some(w) = weight {
701 v.insert("weight".into(), num_value(w));
702 }
703 Value::Object(v)
704}
705
706fn pretty(v: &Value) -> Vec<u8> {
707 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
708 s.push('\n');
709 s.into_bytes()
710}
711
712fn compile_file(
715 cx: &mut Ctx,
716 text: &str,
717 kind: Kind,
718 path: &Path,
719 mod_ns: Option<&str>,
720 out: &mut Compiled,
721 generated: &mut BTreeMap<String, String>,
722) -> Option<Entry> {
723 let doc = match yog_data::text::parse(text) {
724 Ok(d) => d,
725 Err(e) => {
726 cx.err(e.at.line as usize, e.message);
727 return None;
728 }
729 };
730 let loc_fn = |p: &At| doc.locate(text, p).line as usize;
731 let loc: Loc = &loc_fn;
732 let root = At::root();
733 struct Parts {
734 format: Option<u32>,
735 id: Option<String>,
736 name: Option<yog_data::Text>,
737 tooltip: Option<yog_data::Text>,
738 register: Option<yog_formats::content::Register>,
739 model: yog_formats::content::Model,
740 item_model: Option<yog_formats::content::Model>,
741 block: Option<yog_formats::content::BlockProps>,
742 states: Option<yog_formats::content::States>,
743 item: Option<yog_formats::content::ItemProps>,
744 block_item: Option<yog_formats::content::BlockItemProps>,
746 requires: Vec<String>,
747 tags: Vec<String>,
748 }
749 let (parts, refs, tag_refs) = match kind {
750 Kind::Item => match yog_data::schema::from_doc::<yog_formats::content::ItemFile>(text, &doc) {
751 Ok(r) => {
752 let f = r.value;
753 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: None, block: None, states: None, item: f.item, block_item: None, requires: f.requires, tags: f.tags }, r.refs, r.tags)
754 }
755 Err(e) => {
756 cx.schema_errors(e);
757 return None;
758 }
759 },
760 Kind::Block => match yog_data::schema::from_doc::<yog_formats::content::BlockFile>(text, &doc) {
761 Ok(r) => {
762 let f = r.value;
763 (Parts { format: f.format, id: f.id, name: f.name, tooltip: f.tooltip, register: f.register, model: f.model, item_model: f.item_model, block: f.block, states: f.states, item: None, block_item: f.item, requires: f.requires, tags: f.tags }, r.refs, r.tags)
764 }
765 Err(e) => {
766 cx.schema_errors(e);
767 return None;
768 }
769 },
770 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
771 };
772 for f in &refs {
774 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), f.value.clone()));
775 }
776 for f in &tag_refs {
777 cx.deferred.push((cx.file.clone(), loc(&f.path), f.path.to_string(), yog_data::Ref::new(format!("{TAG_REF}{}", f.value.kind), f.value.id.clone())));
778 }
779 for (i, tag) in parts.tags.iter().enumerate() {
781 let at = root.key("tags").index(i);
782 let of = if kind == Kind::Block { "block|item" } else { "item" };
783 cx.deferred.push((cx.file.clone(), loc(&at), at.to_string(), yog_data::Ref::new(format!("{TAG_REF}{of}"), full_id(tag.trim_start_matches('#'), mod_ns.unwrap_or("minecraft")))));
784 }
785 if let Some(f) = parts.format
786 && f != 1
787 {
788 cx.err_at(loc, &root.key("format"), "format must be 1 (this yog understands format 1)");
789 }
790 let (ns, p) = file_id(cx, loc(&root.key("id")), parts.id.as_deref(), path, mod_ns)?;
791 cx.ns = ns.clone();
792 let id = format!("{ns}:{p}");
793 let auto = parts.register.unwrap_or_default() == yog_formats::content::Register::Auto;
794
795 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
797 for (field, text, target) in [("name", &parts.name, key.clone()), ("tooltip", &parts.tooltip, format!("{key}.desc"))] {
798 let Some(t) = text else { continue };
799 let from = match t {
800 yog_data::Text::Literal(s) => {
801 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(target, s.clone());
802 continue;
803 }
804 yog_data::Text::Lang(k) => k.clone(),
805 yog_data::Text::NameOf(r) => {
806 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
807 format!("{}.{rns}.{}", r.kind, rp.replace('/', "."))
808 }
809 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
810 Some((k, _)) => k.clone(),
811 None => {
812 cx.err_at(loc, &root.key(field), format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key));
813 continue;
814 }
815 },
816 };
817 if from != target {
818 out.lang_aliases.push((from, target));
819 }
820 }
821
822 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
823 let full = format!("assets/{ns}/{rel}");
824 if cx.root.join(&full).is_file() {
825 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
826 }
827 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
828 cx.err(1, format!("{full} is also written by {other}"));
829 }
830 out.outputs.push((full, pretty(value)));
831 };
832
833 let mut entry = Entry { kind, id: id.clone(), file: cx.file.clone(), auto, requires: parts.requires.clone(), block: BlockProps::default(), item: ItemProps::default() };
834
835 for tag in &parts.tags {
837 let tag = full_id(tag.trim_start_matches('#'), &cx.ns);
838 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", &tag));
839 if kind == Kind::Block {
840 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
841 }
842 out.tags.entry(format!("data/{tns}/tags/item/{tpath}.json")).or_default().insert(id.clone(), true);
843 }
844
845 if let Some(it) = &parts.block_item {
847 entry.item.stack_size = it.stack_size;
848 entry.item.fire_resistant = it.fire_resistant;
849 entry.item.fuel_ticks = it.fuel_ticks.unwrap_or(0);
850 }
851
852 if kind == Kind::Item {
853 if let Some(it) = &parts.item {
854 let props = &mut entry.item;
855 props.stack_size = it.stack_size;
856 props.durability = it.durability.unwrap_or(0);
857 props.fire_resistant = it.fire_resistant;
858 props.fuel_ticks = it.fuel_ticks.unwrap_or(0);
859 props.food = it.food.as_ref().map(|f| (f.nutrition, f.saturation, f.always_edible));
860 props.tool = it.tool.as_ref().map(|t| {
861 let (d, s) = t.attack();
862 (t.kind.clone(), t.material.clone(), d, s)
863 });
864 props.armor = it.armor.as_ref().map(|a| (yog_formats::content::word(&a.slot), a.material.clone()));
865 props.wearable = it.wearable.as_ref().map(yog_formats::content::word);
866 if props.wearable.is_some() && props.armor.is_some() {
867 cx.err_at(loc, &root.key("item").key("wearable"), "armor is worn already — leave wearable out");
868 }
869 if props.tool.is_some() && props.armor.is_some() {
870 cx.err_at(loc, &root.key("item").key("armor"), "an item is a tool or armor, not both");
871 }
872 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
873 cx.warn_at(loc, &root.key("item").key("stack_size"), "ignored: an item with durability stacks to 1");
874 }
875 }
876 let m = model(cx, loc, &root.key("model"), &parts.model, true)?;
877 emit(cx, format!("models/item/{p}.json"), &m.json);
878 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
879 return Some(entry);
880 }
881
882 let b = parts.block.clone().unwrap_or_default();
884 let bat = root.key("block");
885 let props = &mut entry.block;
886 props.hardness = b.hardness;
887 props.resistance = b.resistance;
888 props.sound = b.sound.as_ref().map(yog_formats::content::word);
889 props.light = b.light.unwrap_or(0);
890 props.tools = b.tool.iter().map(|t| t.0.clone()).collect();
891 props.tool_level = b.tool_level.clone().map(|l| (l, loc(&bat.key("tool_level"))));
892 props.requires_tool = b.requires_tool;
893 props.no_collision = b.collision == Some(false);
894 props.slipperiness = b.slipperiness;
895 props.redstone = b.redstone;
896 props.accepts_items = b.accepts_items;
897 props.connect_groups = b.connect_groups.clone();
898 let shape_auto = matches!(b.shape, None | Some(yog_formats::content::BlockShape::Auto));
899 if let Some(yog_formats::content::BlockShape::Box(bx)) = b.shape {
900 props.shape = Some(bx);
901 }
902 if let Some(inv) = &b.inventory {
903 if valid_id(inv) && inv.contains(':') {
904 props.inventory = Some(inv.clone());
905 let at = (cx.file.clone(), loc(&bat.key("inventory")), inv.clone());
906 cx.inventories.push(at);
907 } else {
908 cx.err_at(loc, &bat.key("inventory"), format!("`{inv}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
909 }
910 }
911 if props.tool_level.is_some() && props.tools.is_empty() {
912 cx.warn_at(loc, &bat, "tool_level without tool — any tool of that level mines it");
913 }
914 let drops = match &b.drops {
915 None | Some(yog_formats::content::Drops::Itself) => drops_table(&id, None),
916 Some(yog_formats::content::Drops::Nothing) => Value::Null,
917 Some(yog_formats::content::Drops::Item(d)) => drops_table(d.item.as_deref().unwrap_or(&id), d.count),
918 };
919 let render_layer = b.render_layer.as_ref().map(yog_formats::content::word);
920
921 let mut m = model(cx, loc, &root.key("model"), &parts.model, false)?;
922 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
923 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
925 }
926 if shape_auto {
927 entry.block.shape = m
928 .bounds
929 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
930 .map(|b| [b[0].max(0.0), b[1].max(0.0), b[2].max(0.0), b[3].min(16.0), b[4].min(16.0), b[5].min(16.0)]);
931 }
932 let main = format!("{ns}:block/{p}");
933 emit(cx, format!("models/block/{p}.json"), &m.json);
934
935 let mut rotations: Vec<Value> = Vec::new();
937 let mut arm: Option<(String, bool)> = None;
938 if let Some(s) = &parts.states {
939 let sat = root.key("states");
940 if let Some(rs) = &s.rotations {
941 if rs.is_empty() {
942 cx.err_at(loc, &sat.key("rotations"), "a non-empty list of { x: 0, y: 90, weight: 1, uvlock: false }");
943 }
944 for (i, r) in rs.iter().enumerate() {
945 let here = sat.key("rotations").index(i);
946 let mut angle = |k: &str, v: Option<u32>| match v {
947 None => 0.0,
948 Some(n @ (0 | 90 | 180 | 270)) => n as f64,
949 Some(_) => {
950 cx.err_at(loc, &here.key(k), "must be 0, 90, 180 or 270");
951 0.0
952 }
953 };
954 let (x, y) = (angle("x", r.x), angle("y", r.y));
955 rotations.push(variant(&main, x, y, r.uvlock, r.weight));
956 }
957 }
958 if let Some(c) = &s.connect {
959 if let Some(am) = model(cx, loc, &sat.key("connect").key("arm"), &c.arm, false) {
960 let mut json = am.json;
961 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json) {
962 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
963 }
964 emit(cx, format!("models/block/{p}_arm.json"), &json);
965 arm = Some((format!("{ns}:block/{p}_arm"), c.uvlock));
966 }
967 entry.block.connects = true;
968 if entry.block.connect_groups.is_empty() {
969 entry.block.connect_groups.push(id.clone());
970 }
971 }
972 if s.facing.is_some() || s.attach || s.waterlogged || !s.properties.0.is_empty() {
973 if s.rotations.is_some() || s.connect.is_some() {
974 cx.err_at(loc, &sat, "facing, attach, waterlogged and properties can't go with rotations or connect");
975 }
976 let b = &mut entry.block;
977 use yog_formats::content::{Facing, Placement};
978 if s.attach {
979 if s.facing == Some(Facing::All) {
980 cx.err_at(loc, &sat.key("facing"), "an attached block turns four ways — leave facing out");
981 }
982 b.attach = true;
983 b.facing = "horizontal".into();
984 } else if let Some(f) = s.facing {
985 b.facing = if f == Facing::All { "all" } else { "horizontal" }.into();
986 }
987 if !b.facing.is_empty() {
988 b.placement = if s.placement == Some(Placement::Away) { "away" } else { "toward" }.into();
989 } else if s.placement.is_some() {
990 cx.err_at(loc, &sat.key("placement"), "placement needs facing (or attach)");
991 }
992 b.waterlogged = s.waterlogged;
993 for (name, values) in &s.properties.0 {
994 let here = sat.key("properties").key(name);
995 let word = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
996 if !word(name) {
997 cx.err_at(loc, &here, format!("`{name}`: a property's name is lowercase letters, digits and _"));
998 } else if ["facing", "face", "waterlogged"].contains(&name.as_str()) {
999 cx.err_at(loc, &here, format!("`{name}` comes from facing, attach or waterlogged — name yours otherwise"));
1000 }
1001 let mut seen = std::collections::HashSet::new();
1002 if values.len() < 2 || !values.iter().all(|v| word(v) && seen.insert(v.as_str())) {
1003 cx.err_at(loc, &here, "two or more different values, lowercase letters, digits and _");
1004 }
1005 b.properties.push((name.clone(), values.clone()));
1006 }
1007 } else if s.placement.is_some() || !s.models.0.is_empty() || !s.variants.0.is_empty() {
1008 cx.err_at(loc, &sat, "placement, models and variants need facing, attach, waterlogged or properties");
1009 }
1010 }
1011 let states: Vec<(String, Vec<String>)> = {
1014 let b = &entry.block;
1015 let words = |ws: &[&str]| ws.iter().map(|w| w.to_string()).collect::<Vec<_>>();
1016 let mut all = Vec::new();
1017 match b.facing.as_str() {
1018 "all" => all.push(("facing".to_string(), words(&["north", "east", "south", "west", "up", "down"]))),
1019 "horizontal" => all.push(("facing".to_string(), words(&["north", "east", "south", "west"]))),
1020 _ => {}
1021 }
1022 if b.attach {
1023 all.push(("face".to_string(), words(&["floor", "wall", "ceiling"])));
1024 }
1025 if b.waterlogged {
1026 all.push(("waterlogged".to_string(), words(&["false", "true"])));
1027 }
1028 all.extend(b.properties.iter().cloned());
1029 all.sort_by(|a, b| a.0.cmp(&b.0));
1030 all
1031 };
1032 let yes_no = |name: &str| states.iter().any(|(n, v)| n == name && *v == ["false", "true"]);
1034 for (key, value) in [("powered", &b.powered), ("comparator", &b.comparator)] {
1035 if let Some(name) = value {
1036 if yes_no(name) {
1037 if key == "powered" {
1038 entry.block.powered = Some(name.clone());
1039 } else {
1040 entry.block.comparator = Some(name.clone());
1041 }
1042 } else {
1043 cx.err_at(loc, &bat.key(key), format!("`{name}`: a yes/no property of states.properties ([\"false\", \"true\"])"));
1044 }
1045 }
1046 }
1047 let mut rules: Vec<(Vec<(String, String)>, Vec<String>)> = Vec::new();
1049 if let Some(s) = &parts.states {
1050 let sat = root.key("states");
1051 let mut named = std::collections::HashMap::new();
1052 for (name, m) in &s.models.0 {
1053 if let Some(mut m) = model(cx, loc, &sat.key("models").key(name), m, false) {
1054 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1055 o.insert("render_type".into(), Value::String(format!("minecraft:{layer}")));
1056 }
1057 emit(cx, format!("models/block/{p}/{name}.json"), &m.json);
1058 named.insert(name.clone(), format!("{ns}:block/{p}/{name}"));
1059 }
1060 }
1061 for (key, names) in &s.variants.0 {
1062 let here = sat.key("variants").key(key);
1063 let mut when = Vec::new();
1064 for pair in key.split(',').filter(|p| !p.is_empty()) {
1065 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1066 if states.iter().any(|(n, vs)| n == k && vs.iter().any(|x| x == v)) {
1067 when.push((k.to_string(), v.to_string()));
1068 } else {
1069 cx.err_at(loc, &here, format!("`{pair}`: no such property and value"));
1070 }
1071 }
1072 let mut models = Vec::new();
1073 for name in &names.0 {
1074 match named.get(name) {
1075 Some(m) => models.push(m.clone()),
1076 None => cx.err_at(loc, &here, format!("`{name}`: not in states.models")),
1077 }
1078 }
1079 if names.0.is_empty() {
1080 cx.err_at(loc, &here, "a model name, or a list of them");
1081 } else if !models.is_empty() {
1082 rules.push((when, models));
1083 }
1084 }
1085 }
1086 let base: Value = match rotations.len() {
1087 0 => variant(&main, 0.0, 0.0, false, None),
1088 1 => rotations.remove(0),
1089 _ => Value::Array(rotations),
1090 };
1091 let uvlock = parts.states.as_ref().is_some_and(|s| s.uvlock);
1092 let blockstate = match &arm {
1093 None if !states.is_empty() => {
1095 let mut variants = Map::new();
1096 let mut combo: Vec<usize> = vec![0; states.len()];
1097 loop {
1098 let at: Vec<(&str, &str)> = states.iter().zip(&combo).map(|((n, vs), &i)| (n.as_str(), vs[i].as_str())).collect();
1099 let value = |k: &str| at.iter().find(|(n, _)| *n == k).map(|(_, v)| *v);
1100 let models = rules
1101 .iter()
1102 .find(|(when, _)| when.iter().all(|(k, v)| value(k) == Some(v.as_str())))
1103 .map_or(vec![main.clone()], |(_, m)| m.clone());
1104 let (x, y) = turn(value("facing"), value("face"));
1105 let key: Vec<String> = at.iter().map(|(n, v)| format!("{n}={v}")).collect();
1106 let picks: Vec<Value> = models.iter().map(|m| variant(m, x, y, uvlock, None)).collect();
1107 let v = if picks.len() == 1 { picks.into_iter().next().unwrap() } else { Value::Array(picks) };
1108 variants.insert(key.join(","), v);
1109 let mut i = states.len();
1111 loop {
1112 if i == 0 {
1113 break;
1114 }
1115 i -= 1;
1116 combo[i] += 1;
1117 if combo[i] < states[i].1.len() {
1118 break;
1119 }
1120 combo[i] = 0;
1121 }
1122 if combo.iter().all(|&c| c == 0) {
1123 break;
1124 }
1125 }
1126 json!({ "variants": variants })
1127 }
1128 None => json!({ "variants": { "": base } }),
1129 Some((arm, uvlock)) => {
1130 let mut parts = vec![json!({ "apply": base })];
1131 for (dir, x, y) in [("north", 0.0, 0.0), ("east", 0.0, 90.0), ("south", 0.0, 180.0), ("west", 0.0, 270.0), ("up", 270.0, 0.0), ("down", 90.0, 0.0)] {
1132 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1133 }
1134 json!({ "multipart": parts })
1135 }
1136 };
1137 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1138
1139 let item_model = match &parts.item_model {
1141 None => Some(json!({ "parent": main })),
1142 Some(im) => model(cx, loc, &root.key("item_model"), im, true).map(|m| m.json),
1143 };
1144 if let Some(im) = item_model {
1145 emit(cx, format!("models/item/{p}.json"), &im);
1146 }
1147 emit(cx, format!("items/{p}.json"), &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }));
1148
1149 if !drops.is_null() {
1150 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1151 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1152 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1153 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set drops: \"none\""));
1154 }
1155 out.outputs.push((full, pretty(&drops)));
1156 }
1157 for tag in &entry.block.tools {
1160 let (tns, tpath) = tag.split_once(':').unwrap_or(("minecraft", tag));
1161 out.tags.entry(format!("data/{tns}/tags/block/{tpath}.json")).or_default().insert(id.clone(), true);
1162 }
1163 Some(entry)
1164}
1165
1166fn drops_table(item: &str, count: Option<yog_formats::content::DropCount>) -> Value {
1168 let mut entry = json!({ "type": "minecraft:item", "name": item });
1169 if let Some(c) = count {
1170 let count = match c {
1171 yog_formats::content::DropCount::Exactly(n) => num_value(n),
1172 yog_formats::content::DropCount::Between(min, max) => json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) }),
1173 };
1174 entry["functions"] = json!([{ "function": "minecraft:set_count", "count": count }]);
1175 }
1176 json!({
1177 "type": "minecraft:block",
1178 "pools": [{
1179 "rolls": 1,
1180 "entries": [entry],
1181 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1182 }]
1183 })
1184}
1185
1186#[derive(Debug, Clone)]
1190enum RecipeKind {
1191 Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1194 Shapeless(Vec<Vec<String>>),
1196 Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1198 Stonecutting(Vec<String>),
1199 Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1200 Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
1204}
1205
1206struct BookFile {
1208 id: String,
1209 data: Vec<u8>,
1212}
1213
1214struct EntityFile {
1216 id: String,
1217 file: String,
1218 model: Option<String>,
1220 animations: Option<String>,
1221 data: Vec<u8>,
1223}
1224
1225impl EntityFile {
1226 fn path(&self) -> String {
1227 format!("entities/{}.ydata", self.id.replace([':', '/'], "__"))
1228 }
1229}
1230
1231fn compile_entity(cx: &mut Ctx, text: &str, path: &Path, mod_ns: Option<&str>, out: &mut Compiled) -> Option<EntityFile> {
1234 use yog_data::Value;
1235 let mut value = match yog_data::text::parse_value(text) {
1236 Ok(v) => v,
1237 Err(e) => {
1238 cx.err(e.at.line as usize, e.message);
1239 return None;
1240 }
1241 };
1242 let Value::Map(map) = &mut value else {
1243 cx.err(1, "an entity is an object: { \"kind\": \"mob\", \"width\": 0.6, \"height\": 1.8, … }");
1244 return None;
1245 };
1246 let given = map.get("id").and_then(Value::as_str).map(str::to_owned);
1247 let (ns, p) = file_id(cx, 1, given.as_deref(), path, mod_ns)?;
1248 let id = format!("{ns}:{p}");
1249 map.insert("id", Value::from(id.as_str()));
1250 if let Some(name) = map.remove("name") {
1252 let key = format!("entity.{ns}.{}", p.replace('/', "."));
1253 match yog_data::from_value::<yog_data::Text>(&name) {
1254 Ok(read) => match read.value {
1255 yog_data::Text::Literal(s) => {
1256 out.lang.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string())).or_default().insert(key, s);
1257 }
1258 yog_data::Text::Lang(k) => out.lang_aliases.push((k, key)),
1259 yog_data::Text::NameOf(r) => {
1260 let (rns, rp) = r.id.split_once(':').unwrap_or(("minecraft", &r.id));
1261 out.lang_aliases.push((format!("{}.{rns}.{}", r.kind, rp.replace('/', ".")), key));
1262 }
1263 yog_data::Text::Res(r) => match cx.refs.texts.get(&format!("{}.{}", r.set, r.key)) {
1264 Some((k, _)) => out.lang_aliases.push((k.clone(), key)),
1265 None => cx.err(1, format!("name `@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1266 },
1267 },
1268 Err(problems) => {
1269 for pr in problems {
1270 cx.err(1, format!("name: {}", pr.message));
1271 }
1272 }
1273 }
1274 }
1275 let def = match yog_data::from_value::<yog_registry::EntityDef>(&value) {
1276 Ok(read) => read.value,
1277 Err(problems) => {
1278 for pr in problems {
1279 cx.err(1, format!("{}: {}", pr.path, pr.message));
1280 }
1281 return None;
1282 }
1283 };
1284 let qualify = |s: &Option<String>| s.as_ref().map(|s| if s.contains(':') { s.clone() } else { format!("{ns}:{s}") });
1285 let mut def = def;
1286 def.model = qualify(&def.model);
1287 def.animations = qualify(&def.animations);
1288 Some(EntityFile {
1289 id,
1290 file: cx.file.clone(),
1291 model: def.model.clone(),
1292 animations: def.animations.clone(),
1293 data: yog_data::binary::encode(&yog_data::Schema::write(&def)),
1294 })
1295}
1296
1297struct BodyFile {
1299 model: bool,
1300 id: String,
1302 data: Vec<u8>,
1304}
1305
1306impl BodyFile {
1307 fn path(&self) -> String {
1309 let dir = if self.model { "models" } else { "anims" };
1310 format!("body/{dir}/{}.ydata", self.id.replace([':', '/'], "__"))
1311 }
1312}
1313
1314fn compile_body(shown: &str, text: &str, kind: Kind, root: &Path, path: &Path, ns: &str, errors: &mut Vec<String>) -> Option<BodyFile> {
1317 let rel = path.strip_prefix(root.join(CONTENT_DIR)).unwrap_or(path).with_extension("");
1318 let id = format!("{ns}:{}", rel.to_string_lossy().replace('\\', "/"));
1319 let report = |errors: &mut Vec<String>, e: yog_data::Errors| {
1320 for l in e.0 {
1321 errors.push(format!("{shown}:{l}"));
1322 }
1323 };
1324 let value = if kind == Kind::Model {
1325 let file = match yog_data::from_text::<yog_body::format::ModelFile>(text) {
1326 Ok(r) => r.value,
1327 Err(e) => {
1328 report(errors, e);
1329 return None;
1330 }
1331 };
1332 if let Err(e) = yog_body::Model::new(&file, ns) {
1333 errors.push(format!("{shown}: {e}"));
1334 return None;
1335 }
1336 yog_data::Schema::write(&file)
1337 } else {
1338 let file = match yog_data::from_text::<yog_body::format::AnimFile>(text) {
1339 Ok(r) => r.value,
1340 Err(e) => {
1341 report(errors, e);
1342 return None;
1343 }
1344 };
1345 if let Err(e) = yog_body::anim::Clips::new(&file) {
1346 errors.push(format!("{shown}: {e}"));
1347 return None;
1348 }
1349 yog_data::Schema::write(&file)
1350 };
1351 Some(BodyFile { model: kind == Kind::Model, id, data: yog_data::binary::encode(&value) })
1352}
1353
1354fn dimension_path(id: &str) -> String {
1356 format!("dimensions/{}.ydata", id.replace(':', "__"))
1357}
1358
1359impl BookFile {
1360 fn path(&self) -> String {
1362 format!("books/{}.ydata", self.id.replace(':', "__"))
1363 }
1364}
1365
1366fn compile_book(
1375 cx: &mut Ctx,
1376 text: &str,
1377 mod_ns: Option<&str>,
1378 known: &refs::Known,
1379 layouts: &[yog_book::layout::RecipeLayout],
1380 recipe_kinds: &HashMap<String, String>,
1381 page_types: &[yog_book::page_type::PageType],
1382) -> Option<BookFile> {
1383 use refs::RefKind;
1384 use yog_data::schema::{Resolve, Text};
1385 use yog_data::{Path, Ref, Res};
1386
1387 struct Check<'a> {
1388 known: &'a refs::Known,
1389 texts: &'a HashMap<String, (String, String)>,
1390 entries: HashSet<String>,
1391 categories: HashSet<String>,
1392 text_refs: bool,
1393 }
1394
1395 impl Resolve for Check<'_> {
1396 fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1397 let own = match r.kind.as_str() {
1398 "entry" => Some(&self.entries),
1399 "category" => Some(&self.categories),
1400 _ => None,
1401 };
1402 match (own, RefKind::from_word(&r.kind)) {
1403 (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1404 (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1405 (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1406 (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1407 }
1408 }
1409
1410 fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1411 match t {
1412 Text::Literal(s) => Ok(s.clone()),
1413 Text::NameOf(r) => {
1415 if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1416 return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1417 }
1418 self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1419 let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1420 self.text_refs = true;
1421 Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1422 }
1423 Text::Lang(key) => {
1425 self.text_refs = true;
1426 Ok(key.clone())
1427 }
1428 Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1429 Some((key, _)) => {
1430 self.text_refs = true;
1431 Ok(key.clone())
1432 }
1433 None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1434 },
1435 }
1436 }
1437
1438 fn words(&mut self, r: &Res) -> Option<String> {
1439 self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1440 }
1441 }
1442
1443 let doc = match yog_data::text::parse(text) {
1445 Ok(d) => d,
1446 Err(e) => {
1447 cx.err(e.at.line as usize, e.message);
1448 return None;
1449 }
1450 };
1451 let ids = |key: &str| -> HashSet<String> {
1452 doc.value.get(key).and_then(|v| v.as_list()).unwrap_or_default().iter().filter_map(|e| e.get("id")?.as_str().map(str::to_string)).collect()
1453 };
1454 let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1455 let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1456 Ok(read) => read.value,
1457 Err(errors) => {
1458 for e in errors.0 {
1459 let line = e.at.map_or(1, |a| a.line as usize);
1460 if e.path.is_root() {
1461 cx.err(line, e.message);
1462 } else {
1463 cx.err(line, format!("{}: {}", e.path, e.message));
1464 }
1465 }
1466 return None;
1467 }
1468 };
1469 if !book.id.contains(':') {
1470 book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1471 }
1472 let ns = mod_ns.unwrap_or("minecraft");
1474 let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1475 .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1476 .filter(|(_, id)| !id.contains(':'))
1477 .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1478 .collect();
1479 if !unnamespaced.is_empty() {
1480 for e in unnamespaced {
1481 cx.err(1, e);
1482 }
1483 return None;
1484 }
1485 if !valid_id(&book.id) {
1486 cx.err(1, format!("book id `{}`: not a valid id", book.id));
1487 return None;
1488 }
1489 let mut used: Vec<yog_book::page_type::PageType> = Vec::new();
1492 let mut fields_ok = true;
1493 for (e, entry) in book.entries.iter_mut().enumerate() {
1494 for (p, page) in entry.pages.iter_mut().enumerate() {
1495 let yog_book::BookPage::Custom(c) = page else { continue };
1496 use yog_book::page_type::PropKind as K;
1497 use yog_data::with::With;
1498 let at = Path::root().key("entries").index(e).key("pages").index(p);
1499 let line = |path: &Path| doc.locate(text, path).line as usize;
1500 let Some(t) = page_types.iter().find(|t| t.id == c.kind) else {
1501 if c.kind.split(':').next() == mod_ns {
1502 cx.err(line(&at.key("type")), format!("{}.type: no page type {} — a .yui component with book_page=\"{}\"", at, c.kind, c.kind));
1503 fields_ok = false;
1504 } else {
1505 cx.warn(line(&at.key("type")), format!("{}.type: {} is another mod's page type — not checked here", at, c.kind));
1506 }
1507 continue;
1508 };
1509 for (k, _) in c.fields.iter() {
1510 if k != "anchor" && !t.properties.iter().any(|p| p.name == k) {
1511 let names: Vec<&str> = t.properties.iter().map(|p| p.name.as_str()).collect();
1512 cx.err(line(&at.key(k)), format!("{}: {} has no field `{k}` ({})", at.key(k), c.kind, names.join(", ")));
1513 fields_ok = false;
1514 }
1515 }
1516 for prop in &t.properties {
1517 let path = at.key(&prop.name);
1518 let Some(v) = c.fields.get(&prop.name).cloned() else {
1519 if prop.default.is_none() {
1520 cx.err(line(&at), format!("{at}: missing `{}` ({})", prop.name, c.kind));
1521 fields_ok = false;
1522 }
1523 continue;
1524 };
1525 let arena = yog_data::Bump::new();
1526 let r = v.borrow(&arena);
1527 let mut reader = yog_data::Reader::with_resolver(&mut check);
1528 let read = match prop.kind {
1529 K::Text => yog_data::with::TextOf::read(&r, &mut reader).map(yog_data::Value::Str),
1530 K::BookText => yog_book::refs::BookText::read(&r, &mut reader).map(yog_data::Value::Str),
1531 K::Item => yog_data::with::RefTo::<(yog_data::kinds::Item, yog_data::kinds::Block)>::read(&r, &mut reader).map(yog_data::Value::Str),
1532 K::Image => yog_book::refs::Icon::read(&r, &mut reader).map(yog_data::Value::Str),
1533 K::Number => <f64 as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Float),
1534 K::Bool => <bool as yog_data::Schema>::read(&r, &mut reader).map(yog_data::Value::Bool),
1535 K::Data => Some(v.clone()),
1536 };
1537 let problems = std::mem::take(&mut reader.problems);
1538 drop(reader);
1539 for pr in problems {
1540 cx.err(line(&path), format!("{path}: {}", pr.message));
1541 fields_ok = false;
1542 }
1543 if let Some(out) = read {
1544 c.fields.insert(prop.name.as_str(), out);
1545 }
1546 }
1547 if !used.contains(t) {
1548 used.push(t.clone());
1549 }
1550 }
1551 }
1552 if !fields_ok {
1553 return None;
1554 }
1555 book.page_types = used;
1556 book.i18n |= check.text_refs;
1557 let mut ok = true;
1559 for (e, entry) in book.entries.iter().enumerate() {
1560 for (p, page) in entry.pages.iter().enumerate() {
1561 if let yog_book::BookPage::Recipe { recipe, .. } = page
1562 && let Some(kind) = recipe_kinds.get(recipe)
1563 && yog_book::layout::find(layouts, kind).is_none()
1564 {
1565 let path = yog_data::Path::root().key("entries").index(e).key("pages").index(p).key("recipe");
1566 let short = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
1567 cx.err(
1568 doc.locate(text, &path).line as usize,
1569 format!("{path}: no layout for recipe kind {kind} — lay it out in a .yui: <Component recipe_layout=\"{short}\" width=\"…\" height=\"…\"> (see yog_book::layout)"),
1570 );
1571 ok = false;
1572 }
1573 }
1574 }
1575 if !ok {
1576 return None;
1577 }
1578 book.layouts = layouts.to_vec();
1579 Some(BookFile { id: book.id.clone(), data: yog_data::binary::encode(&yog_data::Schema::write(&book)) })
1580}
1581
1582fn compile_layout(
1586 cx: &mut Ctx,
1587 text: &str,
1588 mod_ns: Option<&str>,
1589 known: &refs::Known,
1590 ores: &mut Vec<OreOut>,
1591 dimensions: &mut Vec<(String, Vec<u8>)>,
1592 tables: &mut Vec<table::Table>,
1593 loot: &mut Vec<(String, Vec<u8>)>,
1594 out: &mut Compiled,
1595) -> Option<yog_formats::def::RecipeKindDef> {
1596 use yog_formats::def::Def;
1597 let doc = match yog_data::text::parse(text) {
1598 Ok(d) => d,
1599 Err(e) => {
1600 cx.err(e.at.line as usize, e.message);
1601 return None;
1602 }
1603 };
1604 let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1605 Ok(r) => r,
1606 Err(errors) => {
1607 for e in errors.0 {
1608 let l = e.at.map_or(1, |a| a.line as usize);
1609 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1610 }
1611 return None;
1612 }
1613 };
1614 let mut ok = true;
1615 for f in &read.refs {
1616 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1618 continue;
1619 }
1620 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1621 && let Err(e) = known.check(kind, &f.value.id)
1622 {
1623 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1624 ok = false;
1625 }
1626 }
1627 for f in &read.tags {
1628 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1629 continue;
1630 }
1631 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
1632 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1633 ok = false;
1634 }
1635 }
1636 let ns = mod_ns.unwrap_or("minecraft");
1637 let kind = match read.value {
1638 Def::RecipeKind(mut k) => {
1639 if !k.id.contains(':') {
1640 k.id = format!("{ns}:{}", k.id);
1641 }
1642 if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1643 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own kind, `{ns}:<kind>`", k.id));
1644 ok = false;
1645 }
1646 k
1647 }
1648 Def::Ore(o) => {
1649 if ok {
1650 ores.push(ore(o, ns));
1651 }
1652 return None;
1653 }
1654 Def::Dimension(d) => {
1655 if ok {
1656 dimensions.push(dimension(d, ns));
1657 }
1658 return None;
1659 }
1660 Def::Tag(t) => {
1661 let line = doc.locate(text, &yog_data::Path::root().key("values")).line as usize;
1662 match tag_entries(&t, ns) {
1663 Ok((path, entries)) => out.tags.entry(path).or_default().extend(entries),
1664 Err(problems) => problems.into_iter().for_each(|p| cx.err(line, p)),
1665 }
1666 return None;
1667 }
1668 Def::DamageType(d) => {
1669 match damage_type(&d, ns) {
1670 Ok((file, tags)) => {
1671 out.outputs.push(file);
1672 for (path, id) in tags {
1673 out.tags.entry(path).or_default().insert(id, true);
1674 }
1675 }
1676 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1677 }
1678 return None;
1679 }
1680 Def::Tree(t) => {
1681 match tree(&t, ns) {
1682 Ok(file) => {
1683 if ok {
1684 out.outputs.push(file);
1685 }
1686 }
1687 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1688 }
1689 return None;
1690 }
1691 Def::LootModifier(mut m) => {
1692 m.id = full_id(&m.id, ns);
1693 if m.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1694 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own, `{ns}:<name>`", m.id));
1695 ok = false;
1696 }
1697 if ok {
1698 use yog_data::Schema;
1699 loot.push((m.id.clone(), yog_data::binary::encode(&m.write())));
1700 }
1701 return None;
1702 }
1703 Def::Table(t) => {
1704 match table::check(&cx.file, t, cx.refs, known) {
1705 Ok(t) => tables.push(t),
1706 Err(errors) => {
1707 for e in errors {
1708 let path = e.split_once(':').map_or("", |(p, _)| p);
1710 let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1711 cx.err(doc.locate(text, &at).line as usize, e);
1712 }
1713 }
1714 }
1715 return None;
1716 }
1717 _ => return None,
1718 };
1719 ok.then_some(kind)
1720}
1721
1722fn item_props(p: &ItemProps) -> String {
1725 let mut b = String::new();
1726 if let Some(s) = p.stack_size {
1727 let _ = write!(b, "\n .max_stack({s})");
1728 }
1729 if p.durability > 0 {
1730 let _ = write!(b, "\n .max_damage({})", p.durability);
1731 }
1732 if p.fire_resistant {
1733 b.push_str("\n .fire_resistant()");
1734 }
1735 if p.fuel_ticks > 0 {
1736 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
1737 }
1738 if let Some((kind, material, d, s)) = &p.tool {
1739 let _ = write!(b, "\n .tool({kind:?}, {material:?}, {d:?}, {s:?})");
1740 }
1741 if let Some(slot) = &p.wearable {
1742 let _ = write!(b, "\n .wearable({slot:?})");
1743 }
1744 if let Some((slot, material)) = &p.armor {
1745 let _ = write!(b, "\n .armor({slot:?}, {material:?})");
1746 }
1747 if let Some((n, s, always)) = p.food {
1748 let _ = write!(
1749 b,
1750 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
1751 float(s),
1752 if always { ".can_always_eat()" } else { "" }
1753 );
1754 }
1755 b
1756}
1757
1758fn loot_path(id: &str) -> String {
1760 format!("loot/{}.ydata", id.replace([':', '/'], "__"))
1761}
1762
1763fn full_id(id: &str, ns: &str) -> String {
1765 if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") }
1766}
1767
1768const TAG_REF: &str = "tag:";
1770
1771fn tag_entries(t: &yog_formats::def::TagDef, ns: &str) -> Result<(String, BTreeMap<String, bool>), Vec<String>> {
1774 use yog_data::AnyRefOrTag;
1775 let id = full_id(&t.id, ns);
1776 let (tns, tpath) = id.split_once(':').unwrap_or(("minecraft", &id));
1777 let mut problems = Vec::new();
1778 let mut entries = BTreeMap::new();
1779 for (list, required) in [(&t.values, true), (&t.optional, false)] {
1780 for v in list {
1781 if v.kind() != t.of {
1782 problems.push(format!("`{v}` in a tag of {}", t.of));
1783 continue;
1784 }
1785 let (entry, required) = match v {
1786 AnyRefOrTag::One(r) => (full_id(&r.id, "minecraft"), required),
1787 AnyRefOrTag::Tag(r) => {
1789 let tag = full_id(&r.id, "minecraft");
1790 let everywhere = !matches!(refs::tag_hosts(refs::tag_registry(&t.of), &tag), Some(Some(_)));
1791 (format!("#{tag}"), required && everywhere)
1792 }
1793 };
1794 entries.insert(entry, required);
1795 }
1796 }
1797 if problems.is_empty() {
1798 Ok((format!("data/{tns}/tags/{}/{tpath}.json", refs::tag_registry(&t.of)), entries))
1799 } else {
1800 Err(problems)
1801 }
1802}
1803
1804fn tree(t: &yog_formats::def::TreeDef, ns: &str) -> Result<Output, String> {
1806 use serde_json::json;
1807 let id = full_id(&t.id, ns);
1808 let (tns, tpath) = id.split_once(':').unwrap_or((ns, &id));
1809 if tns != ns || !valid_path(tpath) {
1810 return Err(format!("id: `{}`: the mod's own tree, `{ns}:<name>`", t.id));
1811 }
1812 let provider = |blocks: &[yog_formats::def::WeightedBlock]| match blocks {
1814 [one] => json!({ "type": "minecraft:simple_state_provider", "state": { "Name": one.block.id } }),
1815 many => json!({
1816 "type": "minecraft:weighted_state_provider",
1817 "entries": many.iter().map(|b| json!({ "data": { "Name": b.block.id }, "weight": b.weight })).collect::<Vec<_>>()
1818 }),
1819 };
1820 let placer = |shape: &str, what: &str| if shape.contains(':') { shape.to_string() } else { format!("minecraft:{shape}_{what}_placer") };
1822 if t.trunk.blocks.is_empty() || t.leaves.blocks.is_empty() {
1823 return Err("a tree needs trunk and leaves blocks".into());
1824 }
1825 let mut foliage = json!({ "type": placer(&t.leaves.shape, "foliage"), "radius": t.leaves.radius, "offset": t.leaves.offset });
1826 if let Some(h) = t.leaves.height {
1827 foliage["height"] = h.into();
1828 }
1829 let mut size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 1, "lower_size": 0, "upper_size": 1 });
1830 if let Some(h) = t.min_clipped_height {
1831 size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 0, "lower_size": 0, "upper_size": 0, "min_clipped_height": h });
1832 }
1833 let dirt = t.dirt.as_ref().map_or("minecraft:dirt", |b| b.id.as_str());
1834 let v = json!({
1835 "type": "minecraft:tree",
1836 "config": {
1837 "trunk_provider": provider(&t.trunk.blocks),
1838 "trunk_placer": { "type": placer(&t.trunk.shape, "trunk"), "base_height": t.trunk.base_height, "height_rand_a": t.trunk.random_a, "height_rand_b": t.trunk.random_b },
1839 "foliage_provider": provider(&t.leaves.blocks),
1840 "foliage_placer": foliage,
1841 "dirt_provider": { "type": "minecraft:simple_state_provider", "state": { "Name": dirt } },
1842 "minimum_size": size,
1843 "decorators": [],
1844 "ignore_vines": t.ignore_vines,
1845 "force_dirt": t.force_dirt
1846 }
1847 });
1848 Ok((format!("data/{tns}/worldgen/configured_feature/{tpath}.json"), pretty(&v)))
1849}
1850
1851fn damage_type(d: &yog_formats::def::DamageTypeDef, ns: &str) -> Result<(Output, Vec<(String, String)>), String> {
1854 let id = full_id(&d.id, ns);
1855 let (dns, dpath) = id.split_once(':').unwrap_or((ns, &id));
1856 if dns != ns || !valid_path(dpath) {
1857 return Err(format!("id: `{}`: the mod's own damage type, `{ns}:<name>`", d.id));
1858 }
1859 let mut v = serde_json::json!({
1860 "message_id": d.message_id.clone().unwrap_or_else(|| format!("{dns}.{dpath}")),
1861 "scaling": d.scaling,
1862 "exhaustion": d.exhaustion,
1863 });
1864 if let Some(e) = &d.effects {
1865 v["effects"] = Value::String(e.clone());
1866 }
1867 if let Some(m) = &d.death_message_type {
1868 v["death_message_type"] = Value::String(m.clone());
1869 }
1870 let tags = d
1871 .tags
1872 .iter()
1873 .map(|t| {
1874 let t = full_id(t, "minecraft");
1875 let (tns, tpath) = t.split_once(':').unwrap_or(("minecraft", &t));
1876 (format!("data/{tns}/tags/damage_type/{tpath}.json"), id.clone())
1877 })
1878 .collect();
1879 Ok(((format!("data/{dns}/damage_type/{dpath}.json"), pretty(&v)), tags))
1880}
1881
1882fn recipe_layouts(
1886 root: &Path,
1887 ns: &str,
1888 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1889 known: &refs::Known,
1890 errors: &mut Vec<String>,
1891) -> Vec<yog_book::layout::RecipeLayout> {
1892 use yog_book::layout::LayoutPart;
1893 let mut laid_out: HashMap<String, String> = HashMap::new();
1894 let mut out = Vec::new();
1895 for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
1896 let mut ok = true;
1897 for k in &l.kinds {
1898 let game = yog_formats::recipe::TYPES.contains(&k.as_str());
1899 if !game && !kinds.contains_key(k) {
1900 errors.push(format!(
1901 "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
1902 yog_formats::recipe::TYPES.join(", ")
1903 ));
1904 ok = false;
1905 }
1906 if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
1907 errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
1908 ok = false;
1909 }
1910 }
1911 for part in &l.parts {
1912 match part {
1913 LayoutPart::Slots { slot, .. } => {
1914 for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
1915 if !k.slots.0.iter().any(|(s, _)| s == slot) {
1916 let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
1917 errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
1918 ok = false;
1919 }
1920 }
1921 }
1922 LayoutPart::Item { item, .. } => {
1923 if let Err(e) = known.check(refs::RefKind::Item, item) {
1924 errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
1925 ok = false;
1926 }
1927 }
1928 _ => {}
1929 }
1930 }
1931 if ok {
1932 out.push(l);
1933 }
1934 }
1935 out
1936}
1937
1938fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
1942 use yog_data::{Map, Schema, Value as V};
1943 let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
1944 let mut m = Map::new();
1945 m.insert("id", V::from(id.as_str()));
1946 m.insert("dimension_type", d.dimension_type.write());
1947 m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
1948 m.insert("structures", V::Bool(d.structures));
1949 m.insert("features", V::Bool(d.features));
1950 m.insert("mob_spawning", V::Bool(d.mob_spawning));
1951 if let Some(t) = &d.terrain {
1952 m.insert("generator_type", V::from(id.as_str()));
1953 m.insert("terrain", t.write());
1954 } else if let Some(g) = &d.generator {
1955 m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
1956 }
1957 (id, yog_data::binary::encode(&V::Map(m)))
1958}
1959
1960type OreOut = (Vec<Output>, (String, String, String));
1962
1963fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
1967 use serde_json::json;
1968 let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
1969 let id = format!("{ns}:{p}");
1970 let targets: Vec<Value> = o
1971 .targets
1972 .iter()
1973 .map(|t| {
1974 let target = match t.replaces.strip_prefix('#') {
1975 Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
1976 None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
1977 };
1978 json!({ "target": target, "state": { "Name": t.block.id } })
1979 })
1980 .collect();
1981 let configured = json!({
1982 "type": "minecraft:ore",
1983 "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
1984 });
1985 let shape = match o.height.shape {
1986 yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
1987 yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
1988 };
1989 let placed = json!({
1990 "feature": id,
1991 "placement": [
1992 { "type": "minecraft:count", "count": o.per_chunk },
1993 { "type": "minecraft:in_square" },
1994 { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
1995 { "type": "minecraft:biome" }
1996 ]
1997 });
1998 let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
1999 let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
2000 let mut files: Vec<Output> = Vec::new();
2001 let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
2002 put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
2003 put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
2004 for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
2005 put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
2006 }
2007 put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
2008 (files, (id, biomes, "underground_ores".into()))
2009}
2010
2011#[derive(Debug, Clone)]
2013struct Recipe {
2014 id: String,
2015 ty: String,
2017 kind: RecipeKind,
2018 result: String,
2019 count: u32,
2020 group: Option<String>,
2021 category: Option<String>,
2022 requires: Vec<String>,
2024}
2025
2026impl Recipe {
2027 fn registration(&self) -> String {
2029 let list = |items: &[String]| {
2030 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
2031 format!("vec![{}]", quoted.join(", "))
2032 };
2033 let kind = match &self.kind {
2034 RecipeKind::Shaped { pattern, key } => {
2035 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
2036 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
2037 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
2038 }
2039 RecipeKind::Shapeless(ingredients) => {
2040 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
2041 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
2042 }
2043 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
2044 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
2045 list(input),
2046 float(*experience)
2047 ),
2048 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
2049 RecipeKind::Custom { kind, slots, values } => {
2050 let mut o = Map::new();
2052 o.insert("type".into(), kind.clone().into());
2053 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
2054 if !values.is_empty() {
2055 o.insert("values".into(), Value::Object(values.clone()));
2056 }
2057 o.insert("result".into(), self.result.clone().into());
2058 if self.count != 1 {
2059 o.insert("count".into(), self.count.into());
2060 }
2061 if let Some(g) = &self.group {
2062 o.insert("group".into(), g.clone().into());
2063 }
2064 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
2065 }
2066 RecipeKind::Smithing { template, base, addition } => format!(
2067 "Smithing {{ template: {}, base: {}, addition: {} }}",
2068 list(template),
2069 list(base),
2070 list(addition)
2071 ),
2072 };
2073 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
2074 format!(
2075 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
2076 self.id,
2077 self.result,
2078 self.count,
2079 opt(&self.group),
2080 opt(&self.category)
2081 )
2082 }
2083}
2084
2085fn compile_recipe(
2089 cx: &mut Ctx,
2090 text: &str,
2091 path: &Path,
2092 mod_ns: Option<&str>,
2093 known: &refs::Known,
2094 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2095) -> Option<Recipe> {
2096 use yog_formats::recipe::{Body, RecipeFile};
2097 use yog_data::Path as At;
2098
2099 let doc = match yog_data::text::parse(text) {
2100 Ok(d) => d,
2101 Err(e) => {
2102 cx.err(e.at.line as usize, e.message);
2103 return None;
2104 }
2105 };
2106 let line = |p: &At| doc.locate(text, p).line as usize;
2107 let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
2108 Ok(r) => r,
2109 Err(errors) => {
2110 for e in errors.0 {
2111 let l = e.at.map_or(1, |a| a.line as usize);
2112 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
2113 }
2114 return None;
2115 }
2116 };
2117 let mut ok = true;
2118 for f in &read.refs {
2119 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
2120 && let Err(e) = known.check(kind, &f.value.id)
2121 {
2122 cx.err(line(&f.path), format!("{}: {e}", f.path));
2123 ok = false;
2124 }
2125 }
2126 for f in &read.tags {
2127 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
2128 cx.err(line(&f.path), format!("{}: {e}", f.path));
2129 ok = false;
2130 }
2131 }
2132 if ok {
2134 for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
2135 cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
2136 }
2137 }
2138 let RecipeFile { common: c, body } = read.value;
2139 let ty = match &body {
2140 Body::Shaped(_) => "shaped".to_string(),
2141 Body::Shapeless(_) => "shapeless".to_string(),
2142 Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
2143 Body::Stonecutting(_) => "stonecutting".to_string(),
2144 Body::Smithing(_) => "smithing".to_string(),
2145 };
2146 let root = At::root();
2147 if let Some(f) = c.format
2148 && f != 1
2149 {
2150 cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
2151 ok = false;
2152 }
2153 let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
2155 let id_line = line(&root.key("id"));
2156 let (ns, p) = match raw.split_once(':') {
2157 Some((ns, p)) => (ns.to_string(), p.to_string()),
2158 None => match mod_ns {
2159 Some(ns) => (ns.to_string(), raw.clone()),
2160 None => {
2161 cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
2162 return None;
2163 }
2164 },
2165 };
2166 if !valid_namespace(&ns) || !valid_path(&p) {
2167 cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
2168 return None;
2169 }
2170 if !(1..=64).contains(&c.count) {
2171 cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
2172 ok = false;
2173 }
2174 let kind = match body {
2175 Body::Shaped(s) => {
2176 let width = s.pattern.first().map_or(0, |r| r.chars().count());
2177 if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
2178 cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
2179 return None;
2180 }
2181 let mut key = Vec::new();
2182 for (k, ing) in &s.key.0 {
2183 let at = line(&root.key("key").key(k));
2184 let mut chars = k.chars();
2185 match (chars.next(), chars.next()) {
2186 (Some(ch), None) if ch != ' ' => {
2187 if !s.pattern.iter().any(|r| r.contains(ch)) {
2188 cx.warn(at, format!("key {k:?} isn't used by the pattern"));
2189 }
2190 key.push((ch, ing.ids()));
2191 }
2192 _ => {
2193 cx.err(at, format!("key {k:?}: one character other than space"));
2194 ok = false;
2195 }
2196 }
2197 }
2198 for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
2199 if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
2200 cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
2201 return None;
2202 }
2203 }
2204 RecipeKind::Shaped { pattern: s.pattern, key }
2205 }
2206 Body::Shapeless(s) => {
2207 if s.ingredients.is_empty() || s.ingredients.len() > 9 {
2208 cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
2209 return None;
2210 }
2211 RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
2212 }
2213 Body::Cooking(ty, k) => {
2214 let (method, default_time) = match ty.as_str() {
2215 "smelting" => ("Smelting", 200),
2216 "blasting" => ("Blasting", 100),
2217 "smoking" => ("Smoking", 100),
2218 _ => ("Campfire", 600),
2219 };
2220 if !(0.0..=1000.0).contains(&k.experience) {
2221 cx.err(line(&root.key("experience")), "experience: 0–1000");
2222 ok = false;
2223 }
2224 let cook_time = k.cook_time.unwrap_or(default_time);
2225 if !(1..=32767).contains(&cook_time) {
2226 cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
2227 ok = false;
2228 }
2229 RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
2230 }
2231 Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
2232 Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
2233 Body::Custom(kind, k) => {
2234 let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
2236 if !own {
2237 cx.err(
2238 line(&root.key("type")),
2239 format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
2240 );
2241 return None;
2242 }
2243 if c.category.is_some() {
2244 cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
2245 ok = false;
2246 }
2247 let Some(def) = kinds.get(&kind) else {
2249 let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
2250 cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
2251 return None;
2252 };
2253 for (name, ings) in &k.slots.0 {
2254 let at = root.key("slots").key(name);
2255 match def.slots.0.iter().find(|(n, _)| n == name) {
2256 None => {
2257 let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
2258 cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
2259 ok = false;
2260 }
2261 Some((_, spec)) if ings.len() > spec.max as usize => {
2262 cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
2263 ok = false;
2264 }
2265 _ => {}
2266 }
2267 }
2268 for (name, spec) in &def.slots.0 {
2269 if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
2270 cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
2271 ok = false;
2272 }
2273 }
2274 for (name, v) in &k.values.0 {
2275 let at = root.key("values").key(name);
2276 let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
2277 None => {
2278 let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
2279 cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
2280 ok = false;
2281 continue;
2282 }
2283 Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
2284 Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
2285 Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
2286 };
2287 if !fits {
2288 cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
2289 ok = false;
2290 }
2291 }
2292 let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
2293 let values = k.values.0.iter().map(|(name, v)| (name.clone(), serde_json::from_str(&yog_data::text::to_string(v)).unwrap_or(Value::Null))).collect();
2294 RecipeKind::Custom { kind, slots, values }
2295 }
2296 };
2297 ok.then(|| Recipe {
2298 id: format!("{ns}:{p}"),
2299 ty,
2300 kind,
2301 result: c.result.id,
2302 count: c.count,
2303 group: c.group,
2304 category: c.category,
2305 requires: c.requires,
2306 })
2307}
2308
2309fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
2313 use refs::RefKind;
2314 let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
2316 let parent = |p: &yog_data::Path| {
2317 let mut p = p.clone();
2318 if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
2319 p.pop();
2320 }
2321 p.to_string()
2322 };
2323 for f in items {
2324 let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
2325 if !f.value.id.starts_with("minecraft:") {
2326 continue;
2327 }
2328 let on: BTreeSet<String> = refs::hosts()
2329 .iter()
2330 .filter(|h| h.split_once('/').is_some_and(|(_, v)| refs::equivalent(kind, &f.value.id, v).is_some()))
2331 .cloned()
2332 .collect();
2333 let on = (on.len() < refs::hosts().len()).then_some(on);
2334 lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
2335 }
2336 for f in tags {
2337 let Some(on) = refs::tag_hosts(refs::tag_registry(&f.value.kind), &f.value.id) else { continue };
2338 lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
2339 }
2340 let mut out = Vec::new();
2341 for alts in lists.values() {
2342 if alts.iter().any(|(_, on)| on.is_none()) {
2343 continue;
2344 }
2345 let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
2346 let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
2347 if missing.is_empty() {
2348 continue;
2349 }
2350 let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
2351 let hint = alts
2353 .iter()
2354 .filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
2355 .find(|y| refs::tag_hosts("item", y) == Some(None))
2356 .map(|y| format!(" — @tag:item:{y} is on every version and loader"))
2357 .unwrap_or_default();
2358 out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
2359 }
2360 out
2361}
2362
2363struct Defs {
2366 order: BTreeMap<String, Vec<String>>,
2367 sound_groups: Vec<yog_formats::def::SoundGroup>,
2368 tool_materials: Vec<yog_formats::def::ToolMaterial>,
2369 armor_materials: Vec<yog_formats::def::ArmorMaterial>,
2370 biome_features: Vec<(String, String, String)>,
2372 dimensions: Vec<String>,
2374 tables: Vec<table::Table>,
2376 loot_modifiers: Vec<String>,
2378}
2379
2380impl Defs {
2381 fn level(&self, id: &str) -> Option<(&str, usize)> {
2383 self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
2384 }
2385}
2386
2387fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
2390 let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
2391 let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
2392 order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
2393 let ns = mod_ns.unwrap_or("minecraft");
2394 let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
2395 let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
2397 let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
2398 let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
2399 let repair = |r: &yog_data::ItemIdOrTag| match r {
2400 yog_data::OneOrTag::One(i) => i.id.clone(),
2401 yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
2402 };
2403 for (file, text) in defs {
2404 let doc = match yog_data::text::parse(text) {
2405 Ok(d) => d,
2406 Err(e) => {
2407 errors.push(format!("{file}:{}: {}", e.at.line, e.message));
2408 continue;
2409 }
2410 };
2411 match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
2412 Ok(r) => match r.value {
2413 yog_formats::def::Def::Ladder { id } => {
2414 if order.insert(full(&id), Vec::new()).is_some() {
2415 errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
2416 }
2417 }
2418 yog_formats::def::Def::Level { id, ladder, above } => levels.push((file.clone(), doc.locate(text, &At::root().key("id")).line as usize, full(&id), ladder, above)),
2419 yog_formats::def::Def::SoundGroup(mut g) => {
2420 for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
2422 *s = full(s);
2423 }
2424 if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
2425 errors.push(format!("{file}:1: sound group {} is the game's", g.id));
2426 } else if sound_groups.iter().any(|x| x.id == g.id) {
2427 errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
2428 } else {
2429 sound_groups.push(g);
2430 }
2431 }
2432 yog_formats::def::Def::RecipeKind(_) | yog_formats::def::Def::Ore(_) | yog_formats::def::Def::Dimension(_) | yog_formats::def::Def::Table(_) | yog_formats::def::Def::Tag(_) | yog_formats::def::Def::DamageType(_) | yog_formats::def::Def::Tree(_) | yog_formats::def::Def::LootModifier(_) => {}
2434 yog_formats::def::Def::ToolMaterial(mut m) => {
2436 m.id = full(&m.id);
2437 tools.push((file.clone(), m));
2438 }
2439 yog_formats::def::Def::ArmorMaterial(m) => {
2440 let id = full(&m.id);
2441 if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
2442 errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
2443 continue;
2444 }
2445 armor_materials.push(yog_formats::def::ArmorMaterial {
2446 texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
2447 equip_sound: full(&m.equip_sound),
2448 durability: m.durability,
2449 defense: m.defense,
2450 enchantability: m.enchantability,
2451 toughness: m.toughness,
2452 knockback_resistance: m.knockback_resistance,
2453 repair: repair(&m.repair),
2454 id,
2455 });
2456 }
2457 },
2458 Err(es) => {
2459 for e in es.0 {
2460 let l = e.at.map_or(1, |a| a.line as usize);
2461 errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
2462 }
2463 }
2464 }
2465 }
2466 loop {
2468 let before = levels.len();
2469 levels.retain(|(file, line, id, ladder, above)| {
2470 let Some(list) = order.get_mut(ladder) else {
2471 errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
2472 return false;
2473 };
2474 let at = match above {
2475 None => 0,
2476 Some(a) => match list.iter().position(|x| x == a) {
2477 Some(i) => i + 1,
2478 None => return true, },
2480 };
2481 if list.contains(id) {
2482 errors.push(format!("{file}:{line}: level {id} is defined twice"));
2483 } else {
2484 list.insert(at, id.clone());
2485 }
2486 false
2487 });
2488 if levels.is_empty() || levels.len() == before {
2489 break;
2490 }
2491 }
2492 for (file, line, id, ladder, above) in levels {
2493 let above = above.unwrap_or_default();
2494 errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
2495 }
2496 let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
2501 let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
2502 for (file, m) in tools {
2503 if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
2504 errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
2505 continue;
2506 }
2507 let Some(at) = ladder.iter().position(|l| l == &m.level) else {
2508 errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
2509 continue;
2510 };
2511 let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
2512 let game = ["wood", "stone", "iron", "diamond", "netherite"];
2513 let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
2514 Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
2515 None => {
2517 let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
2518 (5, format!("{n}:incorrect_for_{p}_tool"))
2519 }
2520 };
2521 tool_materials.push(yog_formats::def::ToolMaterial {
2522 id: m.id,
2523 durability: m.durability,
2524 speed: m.speed,
2525 attack_damage: m.attack_damage,
2526 enchantability: m.enchantability,
2527 level: m.level,
2528 repair: repair(&m.repair),
2529 level_index,
2530 incorrect,
2531 });
2532 }
2533 Defs { order, sound_groups, tool_materials, armor_materials, biome_features: Vec::new(), dimensions: Vec::new(), tables: Vec::new(), loot_modifiers: Vec::new() }
2534}
2535
2536fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
2541 let Some((ladder, at)) = ladders.level(level) else { return };
2542 if ladder != yog_formats::def::TOOL_LADDER {
2543 err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
2544 return;
2545 }
2546 let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
2547 let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
2548 let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
2550 let mut tag = |path: String| {
2551 out.tags.entry(path).or_default().insert(id.to_string(), true);
2552 };
2553 match able {
2554 Some("wood") => {}
2555 Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
2556 _ => {
2557 tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
2559 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
2560 tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
2561 for loader in ["forge", "neoforge"] {
2562 tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
2563 }
2564 if able.is_none() {
2565 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
2566 warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
2567 }
2568 }
2569 }
2570}
2571
2572fn inventory_screens(dir: &Path) -> BTreeSet<String> {
2575 let mut out = BTreeSet::new();
2576 let Ok(entries) = std::fs::read_dir(dir) else {
2577 return out;
2578 };
2579 for e in entries.flatten() {
2580 let path = e.path();
2581 if path.is_dir() {
2582 out.extend(inventory_screens(&path));
2583 continue;
2584 }
2585 if path.extension().is_none_or(|x| x != "yui") {
2586 continue;
2587 }
2588 let Ok(text) = std::fs::read_to_string(&path) else { continue };
2589 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
2590 continue;
2591 }
2592 if let Some(screen) = text.find("<Screen")
2593 && let Some(at) = text[screen..].find(" id=\"")
2594 {
2595 let rest = &text[screen + at + 5..];
2596 if let Some(end) = rest.find('"') {
2597 out.insert(rest[..end].to_string());
2598 }
2599 }
2600 }
2601 out
2602}
2603
2604pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
2607 let mut out = Compiled::default();
2608 let files = find(root);
2609 if files.is_empty() {
2610 return Ok(out);
2611 }
2612 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
2613 let mut errors: Vec<String> = Vec::new();
2614 let mut warnings: Vec<String> = Vec::new();
2615 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
2616 let mut generated: BTreeMap<String, String> = BTreeMap::new();
2617 let mut entries: Vec<Entry> = Vec::new();
2618 let mut ids: HashMap<String, String> = HashMap::new();
2619 let mut recipes: Vec<Recipe> = Vec::new();
2620 let mut recipe_ids: HashMap<String, String> = HashMap::new();
2621 let mut books: Vec<BookFile> = Vec::new();
2622 let mut book_ids: HashMap<String, String> = HashMap::new();
2623 let mut bodies: Vec<BodyFile> = Vec::new();
2624 let mut entity_files: Vec<EntityFile> = Vec::new();
2625 let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
2626 let mut inventories: Vec<(String, usize, String)> = Vec::new();
2627 let mut pending_books: Vec<(String, String)> = Vec::new();
2628 let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
2629 let mut pending_layouts: Vec<(String, String)> = Vec::new();
2630 let mut pending_defs: Vec<(String, String)> = Vec::new();
2631 let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
2632 for path in &files {
2633 let shown = display(root, path);
2634 let Some(kind) = Kind::of(path) else { continue };
2635 let text = match std::fs::read_to_string(path) {
2636 Ok(t) => t,
2637 Err(e) => {
2638 errors.push(format!("{shown}: {e}"));
2639 continue;
2640 }
2641 };
2642 if matches!(kind, Kind::Model | Kind::Anim) {
2643 if let Some(b) = compile_body(&shown, &text, kind, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
2644 if let Some(other) = body_ids.insert((kind == Kind::Model, b.id.clone()), shown.clone()) {
2645 errors.push(format!("{shown}: {} {} is also defined by {other}", kind.word(), b.id));
2646 }
2647 bodies.push(b);
2648 }
2649 continue;
2650 }
2651 if kind == Kind::Book {
2652 pending_books.push((shown.clone(), text));
2654 continue;
2655 }
2656 if kind == Kind::Recipe {
2657 pending_recipes.push((shown.clone(), text, path.clone()));
2659 continue;
2660 }
2661 if kind == Kind::Def {
2662 let of = yog_data::text::parse_value(&text).ok().and_then(|v| v.get("kind").and_then(|k| k.as_str()).map(str::to_string));
2664 if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table" | "tag" | "damage_type" | "tree")) {
2665 pending_layouts.push((shown.clone(), text));
2666 } else {
2667 pending_defs.push((shown.clone(), text));
2668 }
2669 continue;
2670 }
2671 let mut cx = Ctx {
2672 root,
2673 refs,
2674 file: shown.clone(),
2675 ns: mod_ns.unwrap_or("minecraft").to_string(),
2676 errors: &mut errors,
2677 warnings: &mut warnings,
2678 textures: &mut textures,
2679 inventories: &mut inventories,
2680 deferred: &mut deferred,
2681 };
2682 if kind == Kind::Entity {
2683 if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
2684 if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
2685 errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
2686 }
2687 entity_files.push(e);
2688 }
2689 continue;
2690 }
2691 if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
2692 {
2693 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2694 errors.push(format!(
2695 "{shown}: id {} is also defined by {other}",
2696 entry.id
2697 ));
2698 }
2699 entries.push(entry);
2700 }
2701 }
2702 let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
2704 let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
2705 let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
2706 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
2707 for e in &entries {
2708 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2709 }
2710 for e in &entity_files {
2711 known.add(refs::RefKind::Entity, e.id.clone());
2712 for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
2714 if let Some(id) = wanted
2715 && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
2716 && !bodies.iter().any(|b| b.model == model && &b.id == id)
2717 {
2718 errors.push(format!("{}: {what} `{id}`: no such file in content/ (`{}`)", e.file, if model { ".ymodel" } else { ".yanim" }));
2719 }
2720 }
2721 }
2722 let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
2725 let mut ores: Vec<OreOut> = Vec::new();
2726 let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
2727 let mut tables: Vec<table::Table> = Vec::new();
2728 let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
2730 for (shown, text) in &pending_layouts {
2731 let mut cx = Ctx {
2732 root,
2733 refs,
2734 file: shown.clone(),
2735 ns: mod_ns.unwrap_or("minecraft").to_string(),
2736 errors: &mut errors,
2737 warnings: &mut warnings,
2738 textures: &mut textures,
2739 inventories: &mut inventories,
2740 deferred: &mut deferred,
2741 };
2742 let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut out) else { continue };
2743 if recipe_defs.contains_key(&k.id) {
2744 errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
2745 }
2746 recipe_defs.insert(k.id.clone(), k);
2747 }
2748 let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
2750 for (shown, text, path) in &pending_recipes {
2751 let mut cx = Ctx {
2752 root,
2753 refs,
2754 file: shown.clone(),
2755 ns: mod_ns.unwrap_or("minecraft").to_string(),
2756 errors: &mut errors,
2757 warnings: &mut warnings,
2758 textures: &mut textures,
2759 inventories: &mut inventories,
2760 deferred: &mut deferred,
2761 };
2762 if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs) {
2763 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2764 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2765 }
2766 recipes.push(r);
2767 }
2768 }
2769 for r in &recipes {
2770 known.add(refs::RefKind::Recipe, r.id.clone());
2771 }
2772 let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
2774 for (files, feature) in ores {
2775 out.outputs.extend(files);
2776 defs.biome_features.push(feature);
2777 }
2778 for (file, line, path, r) in &deferred {
2780 let found = match r.kind.as_str() {
2781 "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
2782 "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
2783 "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
2784 .then_some(())
2785 .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
2786 "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
2787 .then_some(())
2788 .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
2789 "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
2790 .then_some(())
2791 .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
2792 kind if kind.starts_with(TAG_REF) => {
2793 let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
2795 let first = found.next().unwrap_or(Ok(()));
2796 if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
2797 }
2798 kind => match refs::RefKind::from_word(kind) {
2799 Some(k) => known.check(k, &r.id),
2800 None => Ok(()),
2801 },
2802 };
2803 if let Err(e) = found {
2804 errors.push(format!("{file}:{line}: {path}: {e}"));
2805 }
2806 }
2807 for e in &entries {
2808 if let Some((level, line)) = &e.block.tool_level {
2809 tool_level_tags(&e.id, level, &defs, &mut out, &mut |m| errors.push(format!("{}:{line}: block.tool_level: {m}", e.file)), &mut |m| warnings.push(format!("{}:{line}: block.tool_level: {m}", e.file)));
2810 }
2811 }
2812 let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
2813 let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
2815 for (shown, text) in &pending_books {
2816 let mut cx = Ctx {
2817 root,
2818 refs,
2819 file: shown.clone(),
2820 ns: mod_ns.unwrap_or("minecraft").to_string(),
2821 errors: &mut errors,
2822 warnings: &mut warnings,
2823 textures: &mut textures,
2824 inventories: &mut inventories,
2825 deferred: &mut deferred,
2826 };
2827 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
2828 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2829 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2830 }
2831 books.push(b);
2832 }
2833 }
2834 if let Some(ns) = mod_ns {
2837 let screens = inventory_screens(&root.join("src"));
2838 for (file, line, id) in &inventories {
2839 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2840 errors.push(format!(
2841 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2842 ));
2843 }
2844 }
2845 }
2846 for (target, source) in &textures {
2847 match std::fs::read(source) {
2848 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2849 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2850 }
2851 }
2852 out.warnings = warnings;
2853 if !errors.is_empty() {
2854 return Err(format!(
2855 "invalid blocks/items in content/:\n {}",
2856 errors.join("\n ")
2857 ));
2858 }
2859 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2860 out.items = entries.len() - out.blocks;
2861 out.recipes = recipes.len();
2862 out.books = books.len();
2863 if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entity_files.is_empty() || !defs.sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !dimensions.is_empty() || !loot.is_empty())
2864 && !mentions_content_macro(&root.join("src"))
2865 {
2866 out.warnings.push(format!(
2867 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2868 CONTENT_DIR
2869 ));
2870 }
2871 out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
2872 out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
2873 out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
2874 out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
2875 out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
2876 defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
2877 for m in &defs.tool_materials {
2879 if let Some((n, p)) = m.incorrect.split_once(':')
2880 && n != "minecraft"
2881 {
2882 out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
2883 }
2884 }
2885 defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
2886 defs.tables = tables;
2887 out.source = Some(rust_source(&entries, &recipes, &books, &bodies, &entity_files, &defs));
2888 version_overlays(&mut out, &known.datapack);
2889 out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
2890 Ok(out)
2891}
2892
2893fn registrations(entries: &[Entry], entities: &[EntityFile], dimensions: &[(String, Vec<u8>)], defs: &Defs) -> String {
2898 fn hash(text: &str) -> String {
2899 let h = text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
2901 format!("{h:016x}")
2902 }
2903 let mut lines = Vec::new();
2904 for e in entries {
2905 let what = match e.kind {
2906 Kind::Block => {
2907 let b = BlockProps { tools: Vec::new(), tool_level: None, ..e.block.clone() };
2909 format!("{b:?} {:?} {:?}", e.item, e.requires)
2910 }
2911 _ => format!("{:?} {:?}", e.item, e.requires),
2912 };
2913 lines.push(format!("{} {} {}", e.kind.word(), e.id, hash(&what)));
2914 }
2915 for e in entities {
2916 lines.push(format!("entity {} {}", e.id, hash(&format!("{:?}", e.data))));
2917 }
2918 for (id, data) in dimensions {
2919 lines.push(format!("dimension {id} {}", hash(&format!("{data:?}"))));
2920 }
2921 for g in &defs.sound_groups {
2922 lines.push(format!("sound_group {} {}", g.id, hash(&format!("{g:?}"))));
2923 }
2924 for m in &defs.tool_materials {
2925 lines.push(format!("tool_material {} {}", m.id, hash(&format!("{m:?}"))));
2926 }
2927 for m in &defs.armor_materials {
2928 lines.push(format!("armor_material {} {}", m.id, hash(&format!("{m:?}"))));
2929 }
2930 lines.sort();
2931 lines.join("\n") + "\n"
2932}
2933
2934fn version_overlays(out: &mut Compiled, datapack: &str) {
2939 fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
2940 match v {
2941 Value::String(s) if s.starts_with("minecraft:") => {
2942 let id = s.trim_start_matches('#').to_string();
2943 let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
2944 match eq {
2945 Some(e) if e != id => {
2946 *s = e;
2947 true
2948 }
2949 Some(_) => false,
2950 None => {
2952 if refs::is_vanilla(&id) {
2953 missing.insert(id);
2954 }
2955 false
2956 }
2957 }
2958 }
2959 Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
2960 Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
2961 _ => false,
2962 }
2963 }
2964 let mut overlays = Vec::new();
2965 for (path, bytes) in &out.outputs {
2966 if !path.starts_with("data/") || !path.ends_with(".json") {
2967 continue;
2968 }
2969 let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
2970 for version in refs::versions().iter().filter(|v| *v != datapack) {
2971 let mut copy = json.clone();
2972 let mut missing = BTreeSet::new();
2973 if map(&mut copy, version, &mut missing) {
2974 overlays.push((format!("when/any/{version}/{path}"), pretty(©)));
2975 }
2976 for id in missing {
2977 out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
2978 }
2979 }
2980 }
2981 out.outputs.extend(overlays);
2982}
2983
2984
2985fn mentions_content_macro(dir: &Path) -> bool {
2987 let Ok(entries) = std::fs::read_dir(dir) else {
2988 return false;
2989 };
2990 entries.flatten().any(|e| {
2991 let p = e.path();
2992 if p.is_dir() {
2993 mentions_content_macro(&p)
2994 } else {
2995 p.extension().is_some_and(|x| x == "rs")
2996 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
2997 }
2998 })
2999}
3000
3001fn rust_ident(path: &str) -> String {
3003 let mut s: String = path
3004 .chars()
3005 .map(|c| {
3006 if c.is_ascii_alphanumeric() {
3007 c.to_ascii_lowercase()
3008 } else {
3009 '_'
3010 }
3011 })
3012 .collect();
3013 if s.starts_with(|c: char| c.is_ascii_digit()) {
3014 s.insert(0, '_');
3015 }
3016 s
3017}
3018
3019fn float(n: f64) -> String {
3020 let s = format!("{n:?}");
3021 if s.contains('.') || s.contains('e') {
3022 s
3023 } else {
3024 format!("{s}.0")
3025 }
3026}
3027
3028fn when_loaded(requires: &[String], stmt: &str) -> String {
3030 if requires.is_empty() {
3031 return stmt.to_string();
3032 }
3033 let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
3034 format!("if {} {{ {stmt} }}", cond.join(" && "))
3035}
3036
3037fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], bodies: &[BodyFile], entities: &[EntityFile], defs: &Defs) -> String {
3038 let sound_groups = &defs.sound_groups;
3039 let mut code = String::from(
3040 "// <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",
3041 );
3042 if !entities.is_empty() {
3043 code.push_str("\n/// The entity types of `content/` (`.yentity`), by id.\n#[allow(dead_code)]\npub struct Entities;\n\n#[allow(dead_code, clippy::all)]\nimpl Entities {\n");
3044 let mut used: HashMap<String, usize> = HashMap::new();
3045 for e in entities {
3046 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3047 let mut ident = rust_ident(path).to_ascii_uppercase();
3048 let n = used.entry(ident.clone()).or_insert(0);
3049 *n += 1;
3050 if *n > 1 {
3051 let _ = write!(ident, "_{n}");
3052 }
3053 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", e.id);
3054 }
3055 code.push_str("}\n");
3056 }
3057 for (model, set, what) in [(true, "Models", "models (`.ymodel`)"), (false, "Anims", "animations (`.yanim`)")] {
3058 let mine: Vec<&BodyFile> = bodies.iter().filter(|b| b.model == model).collect();
3059 if mine.is_empty() {
3060 continue;
3061 }
3062 let _ = writeln!(code, "\n/// The {what} of `content/`, by id (`yog_api::body::model`/`clip`).\n#[allow(dead_code)]\npub struct {set};\n\n#[allow(dead_code, clippy::all)]\nimpl {set} {{");
3063 let mut used: HashMap<String, usize> = HashMap::new();
3064 for b in mine {
3065 let path = b.id.split_once(':').map_or(b.id.as_str(), |(_, p)| p);
3066 let mut ident = rust_ident(path).to_ascii_uppercase();
3067 let n = used.entry(ident.clone()).or_insert(0);
3068 *n += 1;
3069 if *n > 1 {
3070 let _ = write!(ident, "_{n}");
3071 }
3072 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", b.id);
3073 }
3074 code.push_str("}\n");
3075 }
3076 for (kind, set, def) in [
3077 (Kind::Block, "Blocks", "BlockDef"),
3078 (Kind::Item, "Items", "ItemDef"),
3079 ] {
3080 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
3081 let _ = writeln!(
3082 code,
3083 "\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} {{",
3084 if kind == Kind::Block {
3085 "blocks"
3086 } else {
3087 "items"
3088 }
3089 );
3090 let mut used: HashMap<String, usize> = HashMap::new();
3091 for e in mine {
3092 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3093 let mut ident = rust_ident(path);
3094 let n = used.entry(ident.clone()).or_insert(0);
3095 *n += 1;
3096 if *n > 1 {
3097 let _ = write!(ident, "_{n}");
3098 }
3099 let upper = ident.to_ascii_uppercase();
3100 let ident = if is_keyword(&ident) {
3101 format!("r#{ident}")
3102 } else {
3103 ident
3104 };
3105 let _ = writeln!(
3106 code,
3107 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
3108 e.id, e.file, e.id
3109 );
3110 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
3111 match kind {
3112 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3113 Kind::Block => {
3114 let p = &e.block;
3115 if p.hardness.is_some() || p.resistance.is_some() {
3116 let _ = write!(
3117 b,
3118 "\n .strength({}, {})",
3119 float(p.hardness.unwrap_or(1.5)),
3120 float(p.resistance.unwrap_or(6.0))
3121 );
3122 }
3123 if let Some(s) = &p.sound {
3124 let _ = write!(b, "\n .sound({s:?})");
3125 }
3126 if p.light > 0 {
3127 let _ = write!(b, "\n .light_level({})", p.light);
3128 }
3129 if p.requires_tool {
3130 b.push_str("\n .requires_tool()");
3131 }
3132 if p.no_collision {
3133 b.push_str("\n .no_collision()");
3134 }
3135 if let Some(s) = p.slipperiness {
3136 let _ = write!(b, "\n .slipperiness({})", float(s));
3137 }
3138 if let Some(s) = p.shape {
3139 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
3140 let _ = write!(b, "\n .shape({})", args.join(", "));
3141 }
3142 if p.connects {
3143 b.push_str("\n .connects_to_neighbors()");
3144 }
3145 if !p.connect_groups.is_empty() {
3146 let groups: Vec<String> =
3147 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
3148 let _ =
3149 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
3150 }
3151 if let Some(inv) = &p.inventory {
3152 let _ = write!(b, "\n .inventory({inv:?})");
3153 }
3154 if p.redstone {
3155 b.push_str("\n .redstone_source()");
3156 }
3157 if !p.facing.is_empty() {
3158 let _ = write!(b, "\n .facing({:?}, {:?})", p.facing, p.placement);
3159 }
3160 if p.attach {
3161 b.push_str("\n .attach()");
3162 }
3163 if p.waterlogged {
3164 b.push_str("\n .waterlogged()");
3165 }
3166 for (name, values) in &p.properties {
3167 let vs: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
3168 let _ = write!(b, "\n .property({name:?}, &[{}])", vs.join(", "));
3169 }
3170 if let Some(name) = &p.powered {
3171 let _ = write!(b, "\n .powered({name:?})");
3172 }
3173 if let Some(name) = &p.comparator {
3174 let _ = write!(b, "\n .comparator({name:?})");
3175 }
3176 if p.accepts_items {
3177 b.push_str("\n .accepts_items()");
3178 }
3179 }
3180 Kind::Item => b.push_str(&item_props(&e.item)),
3181 }
3182 let _ = writeln!(
3183 code,
3184 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
3185 e.file
3186 );
3187 }
3188 code.push_str("}\n");
3189 }
3190
3191 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");
3192 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
3193 for e in entries {
3194 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3195 let mut ident = rust_ident(path);
3196 let n = used.entry((e.kind, ident.clone())).or_insert(0);
3197 *n += 1;
3198 if *n > 1 {
3199 let _ = write!(ident, "_{n}");
3200 }
3201 let ident = if is_keyword(&ident) {
3202 format!("r#{ident}")
3203 } else {
3204 ident
3205 };
3206 if !e.auto {
3207 continue;
3208 }
3209 let _ = match e.kind {
3210 Kind::Block => {
3211 let item = item_props(&e.item);
3213 let stmt = if item.is_empty() {
3214 format!("registry.register_block(Blocks::{ident}());")
3215 } else {
3216 format!("{{ registry.register_block(Blocks::{ident}()); registry.register_item(::yog_api::ItemDef::new({:?}){}); }}", e.id, item.replace("\n ", ""))
3217 };
3218 writeln!(code, " {}", when_loaded(&e.requires, &stmt))
3219 }
3220 Kind::Item => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
3221 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3222 };
3223 }
3224 for g in sound_groups {
3225 let _ = writeln!(
3226 code,
3227 " registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
3228 g.id,
3229 g.break_sound,
3230 g.step,
3231 g.place,
3232 g.hit,
3233 g.fall,
3234 float(g.volume as f64),
3235 float(g.pitch as f64)
3236 );
3237 }
3238 for id in &defs.dimensions {
3239 let _ = writeln!(
3240 code,
3241 " registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3242 dimension_path(id)
3243 );
3244 }
3245 for (feature, biomes, step) in &defs.biome_features {
3246 let _ = writeln!(code, " registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
3247 }
3248 for m in &defs.tool_materials {
3249 let _ = writeln!(
3250 code,
3251 " registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
3252 m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
3253 );
3254 }
3255 for m in &defs.armor_materials {
3256 let d = m.defense;
3257 let _ = writeln!(
3258 code,
3259 " registry.register_armor_material(::yog_api::ArmorMaterial {{ id: {:?}.into(), durability: {}, defense: ::yog_api::ArmorDefense {{ head: {}, chest: {}, legs: {}, feet: {} }}, enchantability: {}, equip_sound: {:?}.into(), toughness: {:?}, knockback_resistance: {:?}, repair: {:?}.into(), texture: {:?}.into() }});",
3260 m.id, m.durability, d.head, d.chest, d.legs, d.feet, m.enchantability, m.equip_sound, m.toughness, m.knockback_resistance, m.repair, m.texture
3261 );
3262 }
3263 for r in recipes {
3264 let _ = writeln!(code, " {}", when_loaded(&r.requires, &r.registration()));
3265 }
3266 for b in books {
3267 let _ = writeln!(
3268 code,
3269 " registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3270 b.id,
3271 b.path()
3272 );
3273 }
3274 for id in &defs.loot_modifiers {
3275 let _ = writeln!(
3276 code,
3277 " registry.add_loot_modifier_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3278 loot_path(id)
3279 );
3280 }
3281 for e in entities {
3282 let _ = writeln!(
3283 code,
3284 " registry.register_entity_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3285 e.id,
3286 e.path()
3287 );
3288 }
3289 for b in bodies {
3290 let _ = writeln!(
3291 code,
3292 " registry.register_body_{}({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3293 if b.model { "model" } else { "anims" },
3294 b.id,
3295 b.path()
3296 );
3297 }
3298 code.push_str("}\n");
3299 for t in &defs.tables {
3300 code.push_str(&table::rust(t));
3301 }
3302 if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entities.is_empty() || !sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !defs.dimensions.is_empty() {
3303 code.push_str(
3304 "\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",
3305 );
3306 }
3307 code
3308}
3309
3310fn is_keyword(s: &str) -> bool {
3311 const KW: &[&str] = &[
3312 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
3313 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
3314 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
3315 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
3316 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
3317 ];
3318 KW.contains(&s)
3319}
3320
3321pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
3324 write_tags(root, compiled)?;
3325 let out = root.join(GENERATED_FILE);
3326 let Some(code) = &compiled.source else {
3327 let _ = std::fs::remove_file(&out);
3328 return Ok(());
3329 };
3330 crate::write_if_changed(&out, code)?;
3331 let dir = out.parent().unwrap_or(root);
3332 for (path, bytes) in &compiled.data_files {
3333 let file = dir.join(path);
3334 if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
3335 if let Some(parent) = file.parent() {
3336 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
3337 }
3338 std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
3339 }
3340 }
3341 Ok(())
3342}
3343
3344pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
3347
3348fn write_tags(root: &Path, compiled: &Compiled) -> Result<(), String> {
3350 let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
3351 for (path, entries) in &compiled.tags {
3352 let data = path.find("data/").map_or(path.as_str(), |i| &path[i + 5..]);
3354 let Some((ns, rest)) = data.split_once("/tags/") else { continue };
3355 let Some((kind, tag)) = rest.split_once('/') else { continue };
3356 let tag = tag.trim_end_matches(".json");
3357 by_kind.entry(kind.to_string()).or_default().entry(format!("{ns}:{tag}")).or_default().extend(entries.keys().cloned());
3358 }
3359 let text = serde_json::to_string_pretty(&by_kind).map_err(|e| e.to_string())?;
3360 crate::write_if_changed(&root.join(TAGS_FILE), &text)
3361}
3362
3363#[derive(Default)]
3366pub struct OutputSet {
3367 items: Vec<Option<Output>>,
3368 at: HashMap<String, usize>,
3369}
3370
3371impl OutputSet {
3372 pub fn get(&self, path: &str) -> Option<&[u8]> {
3373 let i = *self.at.get(path)?;
3374 self.items[i].as_ref().map(|(_, b)| b.as_slice())
3375 }
3376
3377 pub fn contains(&self, path: &str) -> bool {
3378 self.at.contains_key(path)
3379 }
3380
3381 pub fn paths(&self) -> impl Iterator<Item = &String> {
3382 self.at.keys()
3383 }
3384
3385 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
3387 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
3388 self.items[i] = None;
3389 }
3390 self.items.push(Some((path, bytes)));
3391 }
3392
3393 pub fn into_vec(self) -> Vec<Output> {
3394 self.items.into_iter().flatten().collect()
3395 }
3396}
3397
3398impl From<Vec<Output>> for OutputSet {
3399 fn from(outputs: Vec<Output>) -> Self {
3400 let mut set = OutputSet::default();
3401 for (path, bytes) in outputs {
3402 set.put(path, bytes);
3403 }
3404 set
3405 }
3406}
3407
3408fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
3411 let read;
3412 let bytes = match outputs.get(path) {
3413 Some(b) => b,
3414 None => match std::fs::read(root.join(path)) {
3415 Ok(b) => {
3416 read = b;
3417 &read
3418 }
3419 Err(_) => return Ok(Map::new()),
3420 },
3421 };
3422 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
3423}
3424
3425pub fn merge_outputs(
3429 root: &Path,
3430 outputs: &mut Vec<Output>,
3431 compiled: Compiled,
3432) -> Result<(), String> {
3433 let mut set = OutputSet::from(std::mem::take(outputs));
3434 let mut errors = Vec::new();
3435 if !compiled.lang_aliases.is_empty() {
3438 let mut lang_files: BTreeSet<String> =
3439 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
3440 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
3441 for ns in dirs.flatten() {
3442 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
3443 for f in files.flatten() {
3444 if let Ok(rel) = f.path().strip_prefix(root) {
3445 let rel = rel.to_string_lossy().replace('\\', "/");
3446 if is_lang_path(&rel) {
3447 lang_files.insert(rel);
3448 }
3449 }
3450 }
3451 }
3452 }
3453 }
3454 for path in lang_files {
3455 let mut map = match json_output(&set, root, &path) {
3456 Ok(m) => m,
3457 Err(e) => {
3458 errors.push(e);
3459 continue;
3460 }
3461 };
3462 let mut changed = false;
3463 for (from, to) in &compiled.lang_aliases {
3464 if let Some(text) = map.get(from).cloned()
3465 && !map.contains_key(to)
3466 {
3467 map.insert(to.clone(), text);
3468 changed = true;
3469 }
3470 }
3471 if changed {
3472 set.put(path, pretty(&Value::Object(map)));
3473 }
3474 }
3475 }
3476 for ((ns, locale), entries) in &compiled.lang {
3477 let path = format!("assets/{ns}/lang/{locale}.json");
3478 let mut map = match json_output(&set, root, &path) {
3479 Ok(m) => m,
3480 Err(e) => {
3481 errors.push(e);
3482 continue;
3483 }
3484 };
3485 for (k, v) in entries {
3486 map.entry(k.clone())
3487 .or_insert_with(|| Value::String(v.clone()));
3488 }
3489 set.put(path, pretty(&Value::Object(map)));
3490 }
3491 for (path, ids) in &compiled.tags {
3492 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
3495 let path = if !set.contains(path) && root.join(&plural).is_file() {
3496 plural
3497 } else {
3498 path.clone()
3499 };
3500 let mut map = match json_output(&set, root, &path) {
3501 Ok(m) => m,
3502 Err(e) => {
3503 errors.push(e);
3504 continue;
3505 }
3506 };
3507 let values = map
3508 .entry("values")
3509 .or_insert_with(|| Value::Array(Vec::new()));
3510 if let Value::Array(list) = values {
3511 for (id, &required) in ids {
3512 let listed = |v: &Value| v.as_str().or_else(|| v.get("id").and_then(Value::as_str)) == Some(id.as_str());
3513 if !list.iter().any(listed) {
3514 list.push(if required { Value::String(id.clone()) } else { serde_json::json!({ "id": id, "required": false }) });
3515 }
3516 }
3517 }
3518 map.entry("replace").or_insert(Value::Bool(false));
3519 set.put(path, pretty(&Value::Object(map)));
3520 }
3521 for (path, bytes) in compiled.outputs {
3522 set.put(path, bytes);
3523 }
3524 *outputs = set.into_vec();
3525 if errors.is_empty() {
3526 Ok(())
3527 } else {
3528 Err(format!(
3529 "merging blocks/items into the mod's files:\n {}",
3530 errors.join("\n ")
3531 ))
3532 }
3533}
3534
3535fn is_lang_path(p: &str) -> bool {
3536 let parts: Vec<&str> = p.split('/').collect();
3537 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
3538}
3539
3540#[cfg(test)]
3541mod tests {
3542 use super::*;
3543
3544 fn project(files: &[(&str, &str)]) -> PathBuf {
3545 use std::sync::atomic::{AtomicUsize, Ordering};
3546 static N: AtomicUsize = AtomicUsize::new(0);
3547 let dir = std::env::temp_dir().join(format!(
3548 "yog-content-test-{}-{}",
3549 std::process::id(),
3550 N.fetch_add(1, Ordering::SeqCst)
3551 ));
3552 let _ = std::fs::remove_dir_all(&dir);
3553 for (path, text) in files {
3554 let p = dir.join(path);
3555 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3556 std::fs::write(p, text).unwrap();
3557 }
3558 dir
3559 }
3560
3561 fn output(c: &Compiled, path: &str) -> Value {
3562 let (_, b) = c
3563 .outputs
3564 .iter()
3565 .find(|(p, _)| p == path)
3566 .unwrap_or_else(|| {
3567 panic!(
3568 "no {path} in {:?}",
3569 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
3570 )
3571 });
3572 serde_json::from_slice(b).unwrap()
3573 }
3574
3575 #[test]
3576 fn compiles_a_cube_block() {
3577 let root = project(&[
3578 ("resources/block/ruby_ore.png", "png"),
3579 ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
3580 (
3581 "content/blocks/ruby_ore.yblock",
3582 r#"{
3583 "name": "Ruby Ore",
3584 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
3585 "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
3586 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
3587}"#,
3588 ),
3589 ]);
3590 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3591 assert_eq!(c.blocks, 1);
3592 assert_eq!(
3593 output(&c, "assets/mymod/models/block/ruby_ore.json"),
3594 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
3595 );
3596 assert_eq!(
3597 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
3598 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
3599 );
3600 assert_eq!(
3601 output(&c, "assets/mymod/models/item/ruby_ore.json"),
3602 json!({ "parent": "mymod:block/ruby_ore" })
3603 );
3604 assert_eq!(
3605 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
3606 json!("mymod:item/ruby_ore")
3607 );
3608 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
3609 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
3610 assert_eq!(
3611 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
3612 json!(3)
3613 );
3614 assert!(
3615 c.outputs
3616 .iter()
3617 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
3618 );
3619 let src = c.source.as_ref().unwrap();
3620 assert!(
3621 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
3622 "{src}"
3623 );
3624 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
3625 assert!(src.contains(".requires_tool()"), "{src}");
3626 assert!(
3627 src.contains("registry.register_block(Blocks::ruby_ore());"),
3628 "{src}"
3629 );
3630
3631 let mut outputs = Vec::new();
3632 merge_outputs(&root, &mut outputs, c).unwrap();
3633 let find = |p: &str| -> Value {
3634 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
3635 };
3636 assert_eq!(
3637 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
3638 json!("Ruby Ore")
3639 );
3640 assert_eq!(
3641 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
3642 json!(["mymod:ruby_ore"])
3643 );
3644 assert_eq!(
3645 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
3646 json!(["mymod:ruby_ore"])
3647 );
3648 let _ = std::fs::remove_dir_all(root);
3649 }
3650
3651 #[test]
3652 fn raw_parent_with_elements_gets_a_particle() {
3653 let root = project(&[(
3654 "content/cable.yblock",
3655 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
3656 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
3657 )]);
3658 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3659 let m = output(&c, "assets/mymod/models/block/cable.json");
3660 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3661 let _ = std::fs::remove_dir_all(root);
3662 }
3663
3664 #[test]
3665 fn a_facing_block_turns_to_its_placer() {
3666 let root = project(&[(
3667 "content/screen.yblock",
3668 r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
3669 "states": { "facing": "horizontal" } }"##,
3670 )]);
3671 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3672 let bs = output(&c, "assets/mymod/blockstates/screen.json");
3673 assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
3674 assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
3675 assert!(c.source.unwrap().contains(".facing(\"horizontal\", \"toward\")"));
3676 let _ = std::fs::remove_dir_all(root);
3677 }
3678
3679 #[test]
3680 fn compiles_elements_shape_and_connect() {
3681 let root = project(&[(
3682 "content/pipe.yblock",
3683 r##"{
3684 "block": { "render_layer": "cutout" },
3685 "model": {
3686 "textures": { "0": "minecraft:block/stone" },
3687 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
3688 "rotation": { "axis": "y", "angle": 22.5 },
3689 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
3690 },
3691 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
3692 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
3693}"##,
3694 )]);
3695 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3696 let m = output(&c, "assets/mymod/models/block/pipe.json");
3697 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3698 assert_eq!(m["render_type"], json!("minecraft:cutout"));
3699 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
3700 assert_eq!(m["parent"], json!("minecraft:block/block"), "shown in a slot and held as a block");
3701 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
3702 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
3703 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
3704 let src = c.source.unwrap();
3705 assert!(
3706 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
3707 "{src}"
3708 );
3709 assert!(src.contains(".connects_to_neighbors()"), "{src}");
3710 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
3711 let _ = std::fs::remove_dir_all(root);
3712 }
3713
3714 #[test]
3715 fn sound_groups_are_the_games_or_a_mods_ydef() {
3716 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3717 let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
3718 place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
3719 let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
3720 let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
3721 let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
3722 let src = c.source.unwrap();
3723 assert!(src.contains(r#"registry.register_sound_group(::yog_api::SoundGroup::new("mymod:crystal").sounds("mymod:crystal.break", "minecraft:block.glass.step", "mymod:crystal.place", "mymod:crystal.hit", "mymod:crystal.fall").volume(1.0).pitch(1.5));"#), "{src}");
3724 assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
3725
3726 let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
3727 let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
3728 let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3729 assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
3730 assert!(e.contains("no sound group mymod:nope"), "{e}");
3731 }
3732
3733 #[test]
3734 fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
3735 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3736 let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3737 let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
3738 let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
3739 for tag in [
3740 "data/minecraft/tags/block/mineable/pickaxe.json",
3741 "data/mymod/tags/block/mineable/hammer.json",
3742 "data/minecraft/tags/block/needs_diamond_tool.json",
3744 "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
3745 "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
3746 "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
3747 ] {
3748 assert!(c.tags.get(tag).is_some_and(|ids| ids.contains_key("mymod:ore")), "{tag}: {:?}", c.tags);
3749 }
3750 let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
3752 let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
3753 assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains_key("mymod:ore"));
3754 assert_eq!(c.tags.len(), 2);
3755 let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
3757 let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3758 assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
3759 assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
3760 let volts = r#"{ kind: "ladder", id: "voltage" }"#;
3761 let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
3762 let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
3763 let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
3764 let e = compile(&project(&[("content/v.ydef", volts), ("content/lv.ydef", lv), ("content/hv.ydef", loop_), ("content/ore.yblock", &wrong)]), "mymod", &yres::Refs::default()).unwrap_err();
3765 assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
3766 assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
3767 assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
3768 }
3769
3770 #[test]
3771 fn reports_errors_with_lines() {
3772 let root = project(&[(
3774 "content/bad.yblock",
3775 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 99],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"nort\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
3776 )]);
3777 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3778 for want in [
3779 "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
3780 "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
3781 "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
3782 "content/bad.yblock:10: bogus: unknown field `bogus`",
3783 ] {
3784 assert!(e.contains(want), "missing {want}\n{e}");
3785 }
3786 let _ = std::fs::remove_dir_all(root);
3787 let root = project(&[(
3789 "content/bad.yblock",
3790 "{\n model: {\n textures: { \"0\": \"minecraft:block/stone\" },\n elements: [ {\n from: [0, 0, 0], to: [16, 16, 16],\n rotation: { axis: \"x\", angle: 30 },\n faces: { north: { texture: \"#1\" } }\n } ]\n },\n}",
3791 )]);
3792 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3793 for want in [
3794 "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
3795 "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
3796 ] {
3797 assert!(e.contains(want), "missing {want}\n{e}");
3798 }
3799 let _ = std::fs::remove_dir_all(root);
3800 }
3801
3802 #[test]
3803 fn compiles_recipes() {
3804 let root = project(&[
3805 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
3806 (
3807 "content/recipes/ruby_block.yrecipe",
3808 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
3809 ),
3810 (
3811 "content/recipes/ruby.yrecipe",
3812 r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
3813 ),
3814 (
3815 "content/recipes/smelt_ruby.yrecipe",
3816 r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
3817 ),
3818 (
3819 "content/recipes/cut.yrecipe",
3820 r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
3821 ),
3822 ]);
3823 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3824 assert_eq!(c.recipes, 4);
3825 let src = c.source.clone().unwrap();
3826 for want in [
3827 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"#,
3828 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
3829 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
3830 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
3831 r##"group: Some("rubies".into())"##,
3832 ] {
3833 assert!(src.contains(want), "missing {want}\n{src}");
3834 }
3835 let _ = std::fs::remove_dir_all(root);
3836 }
3837
3838 #[test]
3839 fn recipe_mistakes() {
3840 let bad = |recipe: &str| {
3841 let root = project(&[("content/r.yrecipe", recipe)]);
3842 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3843 let _ = std::fs::remove_dir_all(root);
3844 e
3845 };
3846 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
3847 assert!(e.contains("pattern uses 'B'"), "{e}");
3848 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
3849 assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
3850 let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
3851 assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
3852 let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
3853 assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
3854 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
3855 assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
3856 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
3857 assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
3858 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
3859 assert!(e.contains("unknown field `pattern`"), "{e}");
3860 let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
3861 assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
3862 }
3863
3864 #[test]
3865 fn block_inventory_names_a_screen() {
3866 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
3867 let root = project(&[
3868 ("content/bench.yblock", block),
3869 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
3870 ]);
3871 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3872 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
3873 let _ = std::fs::remove_dir_all(root);
3874
3875 let root = project(&[("content/bench.yblock", block)]);
3876 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3877 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
3878 let _ = std::fs::remove_dir_all(root);
3879 }
3880
3881 #[test]
3882 fn compiles_a_book() {
3883 let book = r#"{
3884 "id": "guide",
3885 "name": "@Book.title",
3886 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
3887 "entries": [{
3888 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
3889 "pages": [
3890 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
3891 { "type": "spotlight", "item": "@item:mymod:ruby" },
3892 { "type": "entity", "entity": "@entity:minecraft:allay" }
3893 ]
3894 }]
3895 }"#;
3896 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
3897 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
3898 let mut refs = yres::Refs::default();
3899 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
3900 let out = compile(&root, "mymod", &refs).unwrap();
3901 assert_eq!(out.books, 1);
3902 let src = out.source.unwrap();
3903 assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
3904 assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
3906 let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
3907 let text = yog_data::text::to_string(&data);
3908 assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
3909 assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
3910 let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
3911 assert_eq!(read.entries[0].category, "mymod:basics");
3912 assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
3913
3914 let bad = r#"{ "id": "x",
3916 "categories": [{ "id": "c", "name": "C" }],
3917 "entries": [{ "id": "e", "name": "E", "category": "c",
3918 "pages": [
3919 { "type": "spotlight", "item": "minecraft:stick" },
3920 { "type": "spotlight", "item": "@item:minecraft:grass" },
3921 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
3922 { "type": "entity", "entity": "@entity:create:train" }
3923 ] }] }"#;
3924 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3925 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
3926 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
3927 assert!(e.contains("minecraft:grass isn't in Minecraft 1.21.1 (the mod's datapack; it's in 1.20.1) — there it's minecraft:short_grass"), "{e}");
3928 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
3929 assert!(e.contains("[external.create]"), "{e}");
3930
3931 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
3932 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3933 assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
3934 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
3935 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
3936 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
3937 }
3938
3939 #[test]
3940 fn books_use_the_mods_own_page_types() {
3941 let ty = r#"<Component name="PatternPage" book_page="pattern">
3942 <Property name="name" type="book_text"/>
3943 <Property name="patterns" type="data" default="[]"/>
3944 <BookHeader text="{x:Bind name}"/>
3945 <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
3946 </Component>"#;
3947 let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
3948 entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
3949 let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
3950 let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
3951 let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
3952 assert_eq!(b.page_types.len(), 1, "the type goes with the book");
3953 let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
3954 assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
3955 assert!(b.i18n);
3956
3957 let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
3958 let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
3959 assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
3960 let missing = book(r#"{ type: "mymod:nope" }"#);
3961 let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3962 assert!(e.contains("no page type mymod:nope"), "{e}");
3963 }
3964
3965 #[test]
3966 fn tools_and_armor_of_the_games_materials_or_the_mods() {
3967 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
3968 let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3969 let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
3970 level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
3971 let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
3972 enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
3973 let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
3974 let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
3975 let files = [("content/mythril.ydef", level), ("content/mythril_tools.ydef", tool), ("content/mythril_armor.ydef", armor), ("content/pick.yitem", pick.as_str()), ("content/helm.yitem", helm.as_str())];
3976 let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
3977 let src = c.source.unwrap();
3978 assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
3979 assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
3980 assert!(src.contains(r##"level: "mymod:mythril".into(), repair: "#c:ingots".into(), level_index: 4, incorrect: "minecraft:incorrect_for_netherite_tool".into()"##), "above diamond: netherite's on both versions: {src}");
3981 assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
3982
3983 let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
3984 let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3985 assert!(e.contains("`spoon`: one of pickaxe, axe, shovel, hoe, sword") && e.contains("`bronze`: one of the game's (wood, stone, iron, diamond, gold, netherite)"), "{e}");
3986 let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
3987 let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3988 assert!(e.contains("no armor material mymod:nope"), "{e}");
3989 }
3990
3991 #[test]
3992 fn data_naming_renamed_content_gets_each_versions_name() {
3993 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3994 let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
3995 let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
3996 let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
3997 assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
3998 let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
3999 assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
4000 let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
4001 let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
4002 assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
4003 }
4004
4005 #[test]
4006 fn ores_become_features_in_biomes_on_every_loader() {
4007 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4008 let block = format!(r#"{{ {model} }}"#);
4009 let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
4010 { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
4011 height: { min: -48, max: 16, shape: "triangle" } }"#;
4012 let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
4013 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4014 let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
4015 assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
4016 assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
4017 let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
4018 assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
4019 assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
4020 assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
4021 let src = c.source.unwrap();
4022 assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
4023 let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4024 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4025 assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
4026 let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4027 let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4028 assert!(e.contains("no block mymod:nope"), "{e}");
4029 }
4030
4031 #[test]
4032 fn a_block_says_how_its_item_stacks() {
4033 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4034 let block = format!(r#"{{ item: {{ stack_size: 16, fire_resistant: true, fuel_ticks: 300 }}, {model} }}"#);
4035 let plain = format!(r#"{{ {model} }}"#);
4036 let c = compile(&project(&[("content/log.yblock", &block), ("content/stone.yblock", &plain)]), "mymod", &yres::Refs::default()).unwrap();
4037 let src = c.source.unwrap();
4038 assert!(src.contains(r#"registry.register_item(::yog_api::ItemDef::new("mymod:log").max_stack(16).fire_resistant().fuel(300));"#), "{src}");
4039 assert!(!src.contains(r#"ItemDef::new("mymod:stone")"#), "no item form of its own: the host's default");
4040 }
4041
4042 #[test]
4043 fn blocks_and_items_join_their_tags() {
4044 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4045 let log = format!(r#"{{ tags: ["minecraft:logs", "edified_logs"], {model} }}"#);
4046 let staff = r#"{ tags: ["staves"], model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } } }"#;
4047 let c = compile(&project(&[("content/log.yblock", &log), ("content/staff.yitem", staff)]), "mymod", &yres::Refs::default()).unwrap();
4048 for t in ["data/minecraft/tags/block/logs.json", "data/minecraft/tags/item/logs.json", "data/mymod/tags/block/edified_logs.json", "data/mymod/tags/item/edified_logs.json"] {
4049 assert!(c.tags[t].contains_key("mymod:log"), "{t}");
4050 }
4051 assert!(c.tags["data/mymod/tags/item/staves.json"].contains_key("mymod:staff"));
4052 assert!(!c.tags.contains_key("data/mymod/tags/block/staves.json"), "an item has no block tag");
4053 }
4054
4055 #[test]
4056 fn trees_are_definitions() {
4057 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4058 let block = format!(r#"{{ {model} }}"#);
4059 let tree = r#"{ kind: "tree", id: "glass_tree", min_clipped_height: 6,
4060 trunk: { blocks: [{ block: "@block:mymod:log", weight: 8 }, { block: "@block:minecraft:oak_log" }], shape: "fancy", base_height: 5, random_a: 5, random_b: 3 },
4061 leaves: { blocks: [{ block: "@block:mymod:leaves" }], shape: "fancy", radius: 1, offset: 5, height: 5 } }"#;
4062 let c = compile(&project(&[("content/log.yblock", &block), ("content/leaves.yblock", &block), ("content/glass_tree.ydef", tree)]), "mymod", &yres::Refs::default()).unwrap();
4063 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4064 let f = &get("data/mymod/worldgen/configured_feature/glass_tree.json")["config"];
4065 assert_eq!(f["trunk_placer"]["type"], "minecraft:fancy_trunk_placer");
4066 assert_eq!(f["trunk_provider"]["entries"][0]["weight"], 8);
4067 assert_eq!(f["foliage_provider"]["state"]["Name"], "mymod:leaves");
4068 assert_eq!((f["foliage_placer"]["height"].as_u64(), f["minimum_size"]["min_clipped_height"].as_u64()), (Some(5), Some(6)));
4069 let bad = r#"{ kind: "tree", id: "t", trunk: { blocks: [{ block: "@block:mymod:nope" }], shape: "straight", base_height: 4 },
4070 leaves: { blocks: [{ block: "@block:minecraft:oak_leaves" }], shape: "blob", radius: 2 } }"#;
4071 let e = compile(&project(&[("content/t.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4072 assert!(e.contains("no block mymod:nope"), "{e}");
4073 }
4074
4075 #[test]
4076 fn what_a_version_or_loader_hasnt_got_keeps_a_recipe_out_of_it() {
4077 let recipe = |key: &str| format!(r#"{{ type: "shaped", pattern: ["D"], key: {{ D: {key} }}, result: "@item:minecraft:stick" }}"#);
4078 let check = |key: &str| {
4079 let c = compile(&project(&[("content/r.yrecipe", &recipe(key))]), "mymod", &yres::Refs::default());
4080 c.map(|c| c.warnings.iter().filter(|w| w.contains("left out")).cloned().collect::<Vec<_>>().join("\n"))
4081 };
4082 let w = check(r#""@tag:item:minecraft:music_discs""#).unwrap();
4083 assert!(w.contains("left out on Minecraft 1.21.1: #minecraft:music_discs isn't there — @tag:item:yog:music_discs is on every version and loader"), "{w}");
4084 assert_eq!(check(r#""@tag:item:yog:music_discs""#).unwrap(), "");
4085 assert_eq!(check(r#"["@tag:item:c:iron_ingots", "@item:minecraft:iron_ingot"]"#).unwrap(), "", "an alternative that's everywhere");
4086 let w = check(r#""@tag:item:c:iron_ingots""#).unwrap();
4087 assert!(w.contains("left out on forge 1.20.1, neoforge 1.20.1, Minecraft 1.21.1") && w.contains("@tag:item:yog:ingots/iron"), "{w}");
4088 let e = check(r#""@tag:item:c:nope""#).unwrap_err();
4089 assert!(e.contains("no item tag c:nope in Minecraft or its loaders"), "{e}");
4090 let e = check(r#""@tag:item:yog:nope""#).unwrap_err();
4091 assert!(e.contains("no item tag yog:nope among Yog's common tags"), "{e}");
4092 assert!(check(r#""@tag:item:othermod:anything""#).is_ok(), "another mod's tags aren't known here");
4093 let block = r#"{ tags: ["yog:planks", "yog:nope"], model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } } }"#;
4094 let e = compile(&project(&[("content/p.yblock", block)]), "mymod", &yres::Refs::default()).unwrap_err();
4095 assert!(e.contains("tags[1]: no block tag yog:nope") && !e.contains("yog:planks"), "{e}");
4096 }
4097
4098 #[test]
4099 fn tags_and_damage_types_are_definitions() {
4100 let tag = r#"{ kind: "tag", of: "entity", id: "sticky_teleporters", values: ["@entity:minecraft:pig", "@tag:entity:c:bosses"],
4101 optional: ["@entity:create:seat"] }"#;
4102 let logs = r#"{ kind: "tag", of: "block", id: "minecraft:logs", values: ["@block:minecraft:stone"] }"#;
4103 let dmg = r#"{ kind: "damage_type", id: "overcast", scaling: "never", tags: ["minecraft:bypasses_armor", "bypasses_effects"] }"#;
4104 let c = compile(&project(&[("content/a.ydef", tag), ("content/b.ydef", logs), ("content/c.ydef", dmg)]), "mymod", &yres::Refs::default()).unwrap();
4105 let sticky = &c.tags["data/mymod/tags/entity_type/sticky_teleporters.json"];
4106 assert_eq!(sticky.iter().map(|(k, v)| (k.as_str(), *v)).collect::<Vec<_>>(), vec![("#c:bosses", false), ("create:seat", false), ("minecraft:pig", true)], "c:bosses isn't on every loader: where it isn't, the tag still loads");
4107 assert!(c.tags["data/minecraft/tags/block/logs.json"].contains_key("minecraft:stone"));
4108 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap()).unwrap_or_else(|| panic!("no {p}"));
4109 let d = get("data/mymod/damage_type/overcast.json");
4110 assert_eq!((d["message_id"].as_str(), d["scaling"].as_str()), (Some("mymod.overcast"), Some("never")));
4111 for t in ["data/minecraft/tags/damage_type/bypasses_armor.json", "data/minecraft/tags/damage_type/bypasses_effects.json"] {
4112 assert!(c.tags[t].contains_key("mymod:overcast"), "{t}");
4113 }
4114 let bad = r#"{ kind: "tag", of: "entity", id: "x", values: ["@entity:minecraft:nope", "@block:minecraft:stone"] }"#;
4115 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4116 assert!(e.contains("nope") && e.contains("`@block:minecraft:stone` in a tag of entity"), "{e}");
4117 }
4118
4119 #[test]
4120 fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
4121 let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
4122 terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
4123 layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
4124 let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
4125 let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
4126 assert_eq!(path, "dimensions/mymod__flatlands.ydata");
4127 let v = yog_data::binary::decode(data).unwrap();
4128 assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
4129 assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
4130 let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
4131 assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
4132 let src = c.source.unwrap();
4133 assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
4134 let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
4135 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4136 assert!(e.contains("no block minecraft:nope"), "{e}");
4137 }
4138
4139 #[test]
4140 fn content_for_other_mods_is_registered_only_with_them() {
4141 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
4142 let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
4143 let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
4144 let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
4145 let src = c.source.unwrap();
4146 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
4147 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
4148 }
4149
4150 #[test]
4151 fn books_lay_out_the_mods_own_recipe_kinds() {
4152 let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
4153 let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
4154 entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
4155 pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
4156 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
4157 let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
4158 <RecipeSlot slot="core" x="4" y="4" frame="true"/>
4159 <ItemSlot item="minecraft:beacon" x="40" y="4"/>
4160 <RecipeResult x="76" y="4" frame="true"/>
4161 </Component>"#;
4162 let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
4163 let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
4164 assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
4165 let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
4166 let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4167 assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
4168 let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
4170 <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
4171 let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4172 assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
4173 assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
4174 assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
4175 }
4176
4177 #[test]
4178 fn compiles_a_recipe_of_the_mods_own_kind() {
4179 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
4180 let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
4181 values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
4182 let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
4183 let src = c.source.unwrap();
4184 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
4185 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
4186
4187 let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
4189 values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
4190 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4191 for want in ["slots.ring: at most 2 ingredient(s), found 3", "slots.nope: mymod:infusion has no slot `nope` (core, ring)", "slots: mymod:infusion needs `core`", "values.time: a number", "values.heat: mymod:infusion has no value `heat` (time)"] {
4192 assert!(e.contains(want), "{want}: {e}");
4193 }
4194 let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4195 let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
4196 assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
4197 let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
4198 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4199 assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
4200 let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4202 let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
4203 assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
4204 }
4205
4206 #[test]
4207 fn compiles_an_item_with_resource_refs() {
4208 let root = project(&[(
4209 "content/items/ruby.yitem",
4210 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
4211 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
4212 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
4213 )]);
4214 let mut refs = yres::Refs::default();
4215 refs.texts.insert(
4216 "Items.ruby".into(),
4217 ("item.mymod.ruby".into(), "Ruby".into()),
4218 );
4219 refs.texts.insert(
4220 "Strings.ruby_tip".into(),
4221 ("mymod.ruby_tip".into(), "Shiny".into()),
4222 );
4223 refs.images
4224 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
4225 let c = compile(&root, "mymod", &refs).unwrap();
4226 assert_eq!(
4227 output(&c, "assets/mymod/models/item/ruby.json"),
4228 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
4229 );
4230 let src = c.source.clone().unwrap();
4231 assert!(src.contains(".max_stack(16)"), "{src}");
4232 assert!(
4233 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
4234 "{src}"
4235 );
4236 let mut outputs = vec![(
4238 "assets/mymod/lang/ru_ru.json".to_string(),
4239 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
4240 .as_bytes()
4241 .to_vec(),
4242 )];
4243 merge_outputs(&root, &mut outputs, c).unwrap();
4244 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
4245 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
4246 let _ = std::fs::remove_dir_all(root);
4247 }
4248
4249 #[test]
4250 fn namespace_needed_for_odd_mod_ids() {
4251 let root = project(&[(
4252 "content/x.yitem",
4253 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
4254 )]);
4255 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
4256 assert!(e.contains("give the namespace"), "{e}");
4257 let _ = std::fs::remove_dir_all(root);
4258 }
4259
4260 #[test]
4261 fn body_files_compile_and_register() {
4262 let model = r#"{ "texture": "misc/wings.png", "bones": [
4263 { "name": "wing", "pivot": [5, 0, 0], "cubes": [{ "origin": [-10, 0, 0], "size": [10, 20, 2], "uv": [22, 0] }] } ] }"#;
4264 let anim = r#"{ "spread": { "length": 0.5, "bones": { "wing": { "rotation": { "0": [0, 0, 0], "0.5": [0, 0, -90] } } } } }"#;
4265 let out = compile(&project(&[("content/wings.ymodel", model), ("content/wings.yanim", anim)]), "mymod", &yres::Refs::default()).unwrap();
4266 assert!(out.data_files.iter().any(|(p, _)| p == "body/models/mymod__wings.ydata"));
4267 let src = out.source.unwrap();
4268 assert!(src.contains("registry.register_body_model(\"mymod:wings\""), "{src}");
4269 assert!(src.contains("registry.register_body_anims(\"mymod:wings\""), "{src}");
4270 assert!(src.contains("pub const WINGS: &'static str = \"mymod:wings\";"), "{src}");
4271 let bad = r#"{ "texture": "x.png", "bones": [{ "name": "a", "parent": "b" }] }"#;
4272 let e = compile(&project(&[("content/bad.ymodel", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4273 assert!(e.contains("must come before it"), "{e}");
4274 }
4275
4276 #[test]
4277 fn entities_compile_register_and_name_their_models() {
4278 let model = r#"{ "texture": "entity/wisp.png", "bones": [{ "name": "body", "cubes": [{ "origin": [-2, 0, -2], "size": [4, 4, 4] }] }] }"#;
4279 let wisp = r#"{ "name": "Wisp", "kind": "mob", "width": 0.5, "height": 0.5, "model": "wisp", "goals": ["float", "wander"],
4280 "attributes": { "max_health": 6, "movement_speed": 0.3 } }"#;
4281 let out = compile(&project(&[("content/wisp.ymodel", model), ("content/wisp.yentity", wisp)]), "mymod", &yres::Refs::default()).unwrap();
4282 let src = out.source.as_ref().unwrap();
4283 assert!(src.contains("registry.register_entity_data(\"mymod:wisp\""), "{src}");
4284 assert!(src.contains("pub const WISP: &'static str = \"mymod:wisp\";"), "{src}");
4285 assert!(out.data_files.iter().any(|(p, _)| p == "entities/mymod__wisp.ydata"));
4286 let (_, data) = out.data_files.iter().find(|(p, _)| p == "entities/mymod__wisp.ydata").unwrap();
4287 let def: yog_registry::EntityDef = yog_data::read_bytes(data).unwrap();
4288 assert_eq!((def.kind, def.model.as_deref()), (yog_registry::EntityKind::Mob, Some("mymod:wisp")));
4289 let missing = r#"{ "kind": "object", "width": 1, "height": 1, "model": "nope" }"#;
4290 let e = compile(&project(&[("content/thing.yentity", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4291 assert!(e.contains("model `mymod:nope`: no such file"), "{e}");
4292 }
4293
4294}