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