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