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 out: &mut Compiled,
1596) -> Option<yog_formats::def::RecipeKindDef> {
1597 use yog_formats::def::Def;
1598 let doc = match yog_data::text::parse(text) {
1599 Ok(d) => d,
1600 Err(e) => {
1601 cx.err(e.at.line as usize, e.message);
1602 return None;
1603 }
1604 };
1605 let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1606 Ok(r) => r,
1607 Err(errors) => {
1608 for e in errors.0 {
1609 let l = e.at.map_or(1, |a| a.line as usize);
1610 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1611 }
1612 return None;
1613 }
1614 };
1615 let mut ok = true;
1616 for f in &read.refs {
1617 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1619 continue;
1620 }
1621 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1622 && let Err(e) = known.check(kind, &f.value.id)
1623 {
1624 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1625 ok = false;
1626 }
1627 }
1628 for f in &read.tags {
1629 if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1630 continue;
1631 }
1632 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
1633 cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1634 ok = false;
1635 }
1636 }
1637 let ns = mod_ns.unwrap_or("minecraft");
1638 let kind = match read.value {
1639 Def::RecipeKind(mut k) => {
1640 if !k.id.contains(':') {
1641 k.id = format!("{ns}:{}", k.id);
1642 }
1643 if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1644 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));
1645 ok = false;
1646 }
1647 k
1648 }
1649 Def::Ore(o) => {
1650 if ok {
1651 ores.push(ore(o, ns));
1652 }
1653 return None;
1654 }
1655 Def::Dimension(d) => {
1656 if ok {
1657 dimensions.push(dimension(d, ns));
1658 }
1659 return None;
1660 }
1661 Def::Tag(t) => {
1662 let line = doc.locate(text, &yog_data::Path::root().key("values")).line as usize;
1663 match tag_entries(&t, ns) {
1664 Ok((path, entries)) => out.tags.entry(path).or_default().extend(entries),
1665 Err(problems) => problems.into_iter().for_each(|p| cx.err(line, p)),
1666 }
1667 return None;
1668 }
1669 Def::DamageType(d) => {
1670 match damage_type(&d, ns) {
1671 Ok((file, tags)) => {
1672 out.outputs.push(file);
1673 for (path, id) in tags {
1674 out.tags.entry(path).or_default().insert(id, true);
1675 }
1676 }
1677 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1678 }
1679 return None;
1680 }
1681 Def::Tree(t) => {
1682 match tree(&t, ns) {
1683 Ok(file) => {
1684 if ok {
1685 out.outputs.push(file);
1686 }
1687 }
1688 Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1689 }
1690 return None;
1691 }
1692 Def::LootModifier(mut m) => {
1693 m.id = full_id(&m.id, ns);
1694 if m.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1695 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own, `{ns}:<name>`", m.id));
1696 ok = false;
1697 }
1698 if ok {
1699 use yog_data::Schema;
1700 loot.push((m.id.clone(), yog_data::binary::encode(&m.write())));
1701 }
1702 return None;
1703 }
1704 Def::Attribute(mut a) => {
1708 a.id = full_id(&a.id, ns);
1709 content_def(cx, &doc, text, ns, "attribute", a.id.clone(), &a, ok, definitions);
1710 return None;
1711 }
1712 Def::Effect(mut e) => {
1713 e.id = full_id(&e.id, ns);
1714 content_def(cx, &doc, text, ns, "effect", e.id.clone(), &e, ok, definitions);
1715 return None;
1716 }
1717 Def::Potion(mut p) => {
1718 p.id = full_id(&p.id, ns);
1719 content_def(cx, &doc, text, ns, "potion", p.id.clone(), &p, ok, definitions);
1720 return None;
1721 }
1722 Def::LootTable(t) => {
1723 let id = full_id(&t.id, ns);
1724 let Some(path) = id.strip_prefix(&format!("{ns}:")).filter(|p| valid_path(p)) else {
1725 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1726 return None;
1727 };
1728 if ok {
1729 use yog_data::Schema;
1730 out.outputs.push((format!("data/{ns}/loot_table/{path}.json"), pretty(&json!({ "type": "minecraft:generic", "pools": [] }))));
1731 let fill = yog_registry::LootModifier { id: id.clone(), tables: vec![id.clone()], add: t.add, scale: Vec::new() };
1732 loot.push((id, yog_data::binary::encode(&fill.write())));
1733 }
1734 return None;
1735 }
1736 Def::Table(t) => {
1737 match table::check(&cx.file, t, cx.refs, known) {
1738 Ok(t) => tables.push(t),
1739 Err(errors) => {
1740 for e in errors {
1741 let path = e.split_once(':').map_or("", |(p, _)| p);
1743 let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1744 cx.err(doc.locate(text, &at).line as usize, e);
1745 }
1746 }
1747 }
1748 return None;
1749 }
1750 _ => return None,
1751 };
1752 ok.then_some(kind)
1753}
1754
1755fn item_props(p: &ItemProps) -> String {
1758 let mut b = String::new();
1759 if let Some(s) = p.stack_size {
1760 let _ = write!(b, "\n .max_stack({s})");
1761 }
1762 if p.durability > 0 {
1763 let _ = write!(b, "\n .max_damage({})", p.durability);
1764 }
1765 if p.fire_resistant {
1766 b.push_str("\n .fire_resistant()");
1767 }
1768 if p.fuel_ticks > 0 {
1769 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
1770 }
1771 if let Some((kind, material, d, s)) = &p.tool {
1772 let _ = write!(b, "\n .tool({kind:?}, {material:?}, {d:?}, {s:?})");
1773 }
1774 if let Some(slot) = &p.wearable {
1775 let _ = write!(b, "\n .wearable({slot:?})");
1776 }
1777 if let Some((slot, material)) = &p.armor {
1778 let _ = write!(b, "\n .armor({slot:?}, {material:?})");
1779 }
1780 if let Some((n, s, always)) = p.food {
1781 let _ = write!(
1782 b,
1783 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
1784 float(s),
1785 if always { ".can_always_eat()" } else { "" }
1786 );
1787 }
1788 b
1789}
1790
1791#[allow(clippy::too_many_arguments)]
1794fn content_def<T: yog_data::Schema>(
1795 cx: &mut Ctx,
1796 doc: &yog_data::text::Doc,
1797 text: &str,
1798 ns: &str,
1799 kind: &'static str,
1800 id: String,
1801 def: &T,
1802 ok: bool,
1803 definitions: &mut Vec<(&'static str, String, Vec<u8>)>,
1804) {
1805 if id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1806 cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1807 return;
1808 }
1809 if ok {
1810 definitions.push((kind, id, yog_data::binary::encode(&def.write())));
1811 }
1812}
1813
1814fn definition_path(kind: &str, id: &str) -> String {
1816 format!("{kind}/{}.ydata", id.replace([':', '/'], "__"))
1817}
1818
1819fn loot_path(id: &str) -> String {
1821 format!("loot/{}.ydata", id.replace([':', '/'], "__"))
1822}
1823
1824fn full_id(id: &str, ns: &str) -> String {
1826 if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") }
1827}
1828
1829const TAG_REF: &str = "tag:";
1831
1832fn tag_entries(t: &yog_formats::def::TagDef, ns: &str) -> Result<(String, BTreeMap<String, bool>), Vec<String>> {
1835 use yog_data::AnyRefOrTag;
1836 let id = full_id(&t.id, ns);
1837 let (tns, tpath) = id.split_once(':').unwrap_or(("minecraft", &id));
1838 let mut problems = Vec::new();
1839 let mut entries = BTreeMap::new();
1840 for (list, required) in [(&t.values, true), (&t.optional, false)] {
1841 for v in list {
1842 if v.kind() != t.of {
1843 problems.push(format!("`{v}` in a tag of {}", t.of));
1844 continue;
1845 }
1846 let (entry, required) = match v {
1847 AnyRefOrTag::One(r) => (full_id(&r.id, "minecraft"), required),
1848 AnyRefOrTag::Tag(r) => {
1850 let tag = full_id(&r.id, "minecraft");
1851 let everywhere = !matches!(refs::tag_hosts(refs::tag_registry(&t.of), &tag), Some(Some(_)));
1852 (format!("#{tag}"), required && everywhere)
1853 }
1854 };
1855 entries.insert(entry, required);
1856 }
1857 }
1858 if problems.is_empty() {
1859 Ok((format!("data/{tns}/tags/{}/{tpath}.json", refs::tag_registry(&t.of)), entries))
1860 } else {
1861 Err(problems)
1862 }
1863}
1864
1865fn tree(t: &yog_formats::def::TreeDef, ns: &str) -> Result<Output, String> {
1867 use serde_json::json;
1868 let id = full_id(&t.id, ns);
1869 let (tns, tpath) = id.split_once(':').unwrap_or((ns, &id));
1870 if tns != ns || !valid_path(tpath) {
1871 return Err(format!("id: `{}`: the mod's own tree, `{ns}:<name>`", t.id));
1872 }
1873 let provider = |blocks: &[yog_formats::def::WeightedBlock]| match blocks {
1875 [one] => json!({ "type": "minecraft:simple_state_provider", "state": { "Name": one.block.id } }),
1876 many => json!({
1877 "type": "minecraft:weighted_state_provider",
1878 "entries": many.iter().map(|b| json!({ "data": { "Name": b.block.id }, "weight": b.weight })).collect::<Vec<_>>()
1879 }),
1880 };
1881 let placer = |shape: &str, what: &str| if shape.contains(':') { shape.to_string() } else { format!("minecraft:{shape}_{what}_placer") };
1883 if t.trunk.blocks.is_empty() || t.leaves.blocks.is_empty() {
1884 return Err("a tree needs trunk and leaves blocks".into());
1885 }
1886 let mut foliage = json!({ "type": placer(&t.leaves.shape, "foliage"), "radius": t.leaves.radius, "offset": t.leaves.offset });
1887 if let Some(h) = t.leaves.height {
1888 foliage["height"] = h.into();
1889 }
1890 let mut size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 1, "lower_size": 0, "upper_size": 1 });
1891 if let Some(h) = t.min_clipped_height {
1892 size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 0, "lower_size": 0, "upper_size": 0, "min_clipped_height": h });
1893 }
1894 let dirt = t.dirt.as_ref().map_or("minecraft:dirt", |b| b.id.as_str());
1895 let v = json!({
1896 "type": "minecraft:tree",
1897 "config": {
1898 "trunk_provider": provider(&t.trunk.blocks),
1899 "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 },
1900 "foliage_provider": provider(&t.leaves.blocks),
1901 "foliage_placer": foliage,
1902 "dirt_provider": { "type": "minecraft:simple_state_provider", "state": { "Name": dirt } },
1903 "minimum_size": size,
1904 "decorators": [],
1905 "ignore_vines": t.ignore_vines,
1906 "force_dirt": t.force_dirt
1907 }
1908 });
1909 Ok((format!("data/{tns}/worldgen/configured_feature/{tpath}.json"), pretty(&v)))
1910}
1911
1912fn damage_type(d: &yog_formats::def::DamageTypeDef, ns: &str) -> Result<(Output, Vec<(String, String)>), String> {
1915 let id = full_id(&d.id, ns);
1916 let (dns, dpath) = id.split_once(':').unwrap_or((ns, &id));
1917 if dns != ns || !valid_path(dpath) {
1918 return Err(format!("id: `{}`: the mod's own damage type, `{ns}:<name>`", d.id));
1919 }
1920 let mut v = serde_json::json!({
1921 "message_id": d.message_id.clone().unwrap_or_else(|| format!("{dns}.{dpath}")),
1922 "scaling": d.scaling,
1923 "exhaustion": d.exhaustion,
1924 });
1925 if let Some(e) = &d.effects {
1926 v["effects"] = Value::String(e.clone());
1927 }
1928 if let Some(m) = &d.death_message_type {
1929 v["death_message_type"] = Value::String(m.clone());
1930 }
1931 let tags = d
1932 .tags
1933 .iter()
1934 .map(|t| {
1935 let t = full_id(t, "minecraft");
1936 let (tns, tpath) = t.split_once(':').unwrap_or(("minecraft", &t));
1937 (format!("data/{tns}/tags/damage_type/{tpath}.json"), id.clone())
1938 })
1939 .collect();
1940 Ok(((format!("data/{dns}/damage_type/{dpath}.json"), pretty(&v)), tags))
1941}
1942
1943fn recipe_layouts(
1947 root: &Path,
1948 ns: &str,
1949 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1950 known: &refs::Known,
1951 errors: &mut Vec<String>,
1952) -> Vec<yog_book::layout::RecipeLayout> {
1953 use yog_book::layout::LayoutPart;
1954 let mut laid_out: HashMap<String, String> = HashMap::new();
1955 let mut out = Vec::new();
1956 for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
1957 let mut ok = true;
1958 for k in &l.kinds {
1959 let game = yog_formats::recipe::TYPES.contains(&k.as_str());
1960 if !game && !kinds.contains_key(k) {
1961 errors.push(format!(
1962 "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
1963 yog_formats::recipe::TYPES.join(", ")
1964 ));
1965 ok = false;
1966 }
1967 if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
1968 errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
1969 ok = false;
1970 }
1971 }
1972 for part in &l.parts {
1973 match part {
1974 LayoutPart::Slots { slot, .. } => {
1975 for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
1976 if !k.slots.0.iter().any(|(s, _)| s == slot) {
1977 let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
1978 errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
1979 ok = false;
1980 }
1981 }
1982 }
1983 LayoutPart::Item { item, .. } => {
1984 if let Err(e) = known.check(refs::RefKind::Item, item) {
1985 errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
1986 ok = false;
1987 }
1988 }
1989 _ => {}
1990 }
1991 }
1992 if ok {
1993 out.push(l);
1994 }
1995 }
1996 out
1997}
1998
1999fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
2003 use yog_data::{Map, Schema, Value as V};
2004 let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
2005 let mut m = Map::new();
2006 m.insert("id", V::from(id.as_str()));
2007 m.insert("dimension_type", d.dimension_type.write());
2008 m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
2009 m.insert("structures", V::Bool(d.structures));
2010 m.insert("features", V::Bool(d.features));
2011 m.insert("mob_spawning", V::Bool(d.mob_spawning));
2012 if let Some(t) = &d.terrain {
2013 m.insert("generator_type", V::from(id.as_str()));
2014 m.insert("terrain", t.write());
2015 } else if let Some(g) = &d.generator {
2016 m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
2017 }
2018 (id, yog_data::binary::encode(&V::Map(m)))
2019}
2020
2021type OreOut = (Vec<Output>, (String, String, String));
2023
2024fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
2028 use serde_json::json;
2029 let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
2030 let id = format!("{ns}:{p}");
2031 let targets: Vec<Value> = o
2032 .targets
2033 .iter()
2034 .map(|t| {
2035 let target = match t.replaces.strip_prefix('#') {
2036 Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
2037 None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
2038 };
2039 json!({ "target": target, "state": { "Name": t.block.id } })
2040 })
2041 .collect();
2042 let configured = json!({
2043 "type": "minecraft:ore",
2044 "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
2045 });
2046 let shape = match o.height.shape {
2047 yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
2048 yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
2049 };
2050 let placed = json!({
2051 "feature": id,
2052 "placement": [
2053 { "type": "minecraft:count", "count": o.per_chunk },
2054 { "type": "minecraft:in_square" },
2055 { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
2056 { "type": "minecraft:biome" }
2057 ]
2058 });
2059 let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
2060 let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
2061 let mut files: Vec<Output> = Vec::new();
2062 let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
2063 put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
2064 put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
2065 for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
2066 put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
2067 }
2068 put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
2069 (files, (id, biomes, "underground_ores".into()))
2070}
2071
2072#[derive(Debug, Clone)]
2074struct Recipe {
2075 id: String,
2076 ty: String,
2078 kind: RecipeKind,
2079 result: String,
2080 count: u32,
2081 group: Option<String>,
2082 category: Option<String>,
2083 requires: Vec<String>,
2085}
2086
2087impl Recipe {
2088 fn registration(&self) -> String {
2090 let list = |items: &[String]| {
2091 let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
2092 format!("vec![{}]", quoted.join(", "))
2093 };
2094 let kind = match &self.kind {
2095 RecipeKind::Shaped { pattern, key } => {
2096 let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
2097 let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
2098 format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
2099 }
2100 RecipeKind::Shapeless(ingredients) => {
2101 let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
2102 format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
2103 }
2104 RecipeKind::Cooking { method, input, experience, cook_time } => format!(
2105 "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
2106 list(input),
2107 float(*experience)
2108 ),
2109 RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
2110 RecipeKind::Custom { kind, slots, values } => {
2111 let mut o = Map::new();
2113 o.insert("type".into(), kind.clone().into());
2114 o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
2115 if !values.is_empty() {
2116 o.insert("values".into(), Value::Object(values.clone()));
2117 }
2118 o.insert("result".into(), self.result.clone().into());
2119 if self.count != 1 {
2120 o.insert("count".into(), self.count.into());
2121 }
2122 if let Some(g) = &self.group {
2123 o.insert("group".into(), g.clone().into());
2124 }
2125 return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
2126 }
2127 RecipeKind::Smithing { template, base, addition } => format!(
2128 "Smithing {{ template: {}, base: {}, addition: {} }}",
2129 list(template),
2130 list(base),
2131 list(addition)
2132 ),
2133 };
2134 let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
2135 format!(
2136 "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
2137 self.id,
2138 self.result,
2139 self.count,
2140 opt(&self.group),
2141 opt(&self.category)
2142 )
2143 }
2144}
2145
2146fn compile_recipe(
2150 cx: &mut Ctx,
2151 text: &str,
2152 path: &Path,
2153 mod_ns: Option<&str>,
2154 known: &refs::Known,
2155 kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2156) -> Option<Recipe> {
2157 use yog_formats::recipe::{Body, RecipeFile};
2158 use yog_data::Path as At;
2159
2160 let doc = match yog_data::text::parse(text) {
2161 Ok(d) => d,
2162 Err(e) => {
2163 cx.err(e.at.line as usize, e.message);
2164 return None;
2165 }
2166 };
2167 let line = |p: &At| doc.locate(text, p).line as usize;
2168 let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
2169 Ok(r) => r,
2170 Err(errors) => {
2171 for e in errors.0 {
2172 let l = e.at.map_or(1, |a| a.line as usize);
2173 if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
2174 }
2175 return None;
2176 }
2177 };
2178 let mut ok = true;
2179 for f in &read.refs {
2180 if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
2181 && let Err(e) = known.check(kind, &f.value.id)
2182 {
2183 cx.err(line(&f.path), format!("{}: {e}", f.path));
2184 ok = false;
2185 }
2186 }
2187 for f in &read.tags {
2188 if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
2189 cx.err(line(&f.path), format!("{}: {e}", f.path));
2190 ok = false;
2191 }
2192 }
2193 if ok {
2195 for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
2196 cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
2197 }
2198 }
2199 let RecipeFile { common: c, body } = read.value;
2200 let ty = match &body {
2201 Body::Shaped(_) => "shaped".to_string(),
2202 Body::Shapeless(_) => "shapeless".to_string(),
2203 Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
2204 Body::Stonecutting(_) => "stonecutting".to_string(),
2205 Body::Smithing(_) => "smithing".to_string(),
2206 };
2207 let root = At::root();
2208 if let Some(f) = c.format
2209 && f != 1
2210 {
2211 cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
2212 ok = false;
2213 }
2214 let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
2216 let id_line = line(&root.key("id"));
2217 let (ns, p) = match raw.split_once(':') {
2218 Some((ns, p)) => (ns.to_string(), p.to_string()),
2219 None => match mod_ns {
2220 Some(ns) => (ns.to_string(), raw.clone()),
2221 None => {
2222 cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
2223 return None;
2224 }
2225 },
2226 };
2227 if !valid_namespace(&ns) || !valid_path(&p) {
2228 cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
2229 return None;
2230 }
2231 if !(1..=64).contains(&c.count) {
2232 cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
2233 ok = false;
2234 }
2235 let kind = match body {
2236 Body::Shaped(s) => {
2237 let width = s.pattern.first().map_or(0, |r| r.chars().count());
2238 if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
2239 cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
2240 return None;
2241 }
2242 let mut key = Vec::new();
2243 for (k, ing) in &s.key.0 {
2244 let at = line(&root.key("key").key(k));
2245 let mut chars = k.chars();
2246 match (chars.next(), chars.next()) {
2247 (Some(ch), None) if ch != ' ' => {
2248 if !s.pattern.iter().any(|r| r.contains(ch)) {
2249 cx.warn(at, format!("key {k:?} isn't used by the pattern"));
2250 }
2251 key.push((ch, ing.ids()));
2252 }
2253 _ => {
2254 cx.err(at, format!("key {k:?}: one character other than space"));
2255 ok = false;
2256 }
2257 }
2258 }
2259 for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
2260 if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
2261 cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
2262 return None;
2263 }
2264 }
2265 RecipeKind::Shaped { pattern: s.pattern, key }
2266 }
2267 Body::Shapeless(s) => {
2268 if s.ingredients.is_empty() || s.ingredients.len() > 9 {
2269 cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
2270 return None;
2271 }
2272 RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
2273 }
2274 Body::Cooking(ty, k) => {
2275 let (method, default_time) = match ty.as_str() {
2276 "smelting" => ("Smelting", 200),
2277 "blasting" => ("Blasting", 100),
2278 "smoking" => ("Smoking", 100),
2279 _ => ("Campfire", 600),
2280 };
2281 if !(0.0..=1000.0).contains(&k.experience) {
2282 cx.err(line(&root.key("experience")), "experience: 0–1000");
2283 ok = false;
2284 }
2285 let cook_time = k.cook_time.unwrap_or(default_time);
2286 if !(1..=32767).contains(&cook_time) {
2287 cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
2288 ok = false;
2289 }
2290 RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
2291 }
2292 Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
2293 Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
2294 Body::Custom(kind, k) => {
2295 let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
2297 if !own {
2298 cx.err(
2299 line(&root.key("type")),
2300 format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
2301 );
2302 return None;
2303 }
2304 if c.category.is_some() {
2305 cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
2306 ok = false;
2307 }
2308 let Some(def) = kinds.get(&kind) else {
2310 let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
2311 cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
2312 return None;
2313 };
2314 for (name, ings) in &k.slots.0 {
2315 let at = root.key("slots").key(name);
2316 match def.slots.0.iter().find(|(n, _)| n == name) {
2317 None => {
2318 let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
2319 cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
2320 ok = false;
2321 }
2322 Some((_, spec)) if ings.len() > spec.max as usize => {
2323 cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
2324 ok = false;
2325 }
2326 _ => {}
2327 }
2328 }
2329 for (name, spec) in &def.slots.0 {
2330 if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
2331 cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
2332 ok = false;
2333 }
2334 }
2335 for (name, v) in &k.values.0 {
2336 let at = root.key("values").key(name);
2337 let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
2338 None => {
2339 let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
2340 cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
2341 ok = false;
2342 continue;
2343 }
2344 Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
2345 Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
2346 Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
2347 };
2348 if !fits {
2349 cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
2350 ok = false;
2351 }
2352 }
2353 let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
2354 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();
2355 RecipeKind::Custom { kind, slots, values }
2356 }
2357 };
2358 ok.then(|| Recipe {
2359 id: format!("{ns}:{p}"),
2360 ty,
2361 kind,
2362 result: c.result.id,
2363 count: c.count,
2364 group: c.group,
2365 category: c.category,
2366 requires: c.requires,
2367 })
2368}
2369
2370fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
2374 use refs::RefKind;
2375 let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
2377 let parent = |p: &yog_data::Path| {
2378 let mut p = p.clone();
2379 if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
2380 p.pop();
2381 }
2382 p.to_string()
2383 };
2384 for f in items {
2385 let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
2386 if !f.value.id.starts_with("minecraft:") {
2387 continue;
2388 }
2389 let on: BTreeSet<String> = refs::hosts()
2390 .iter()
2391 .filter(|h| h.split_once('/').is_some_and(|(_, v)| refs::equivalent(kind, &f.value.id, v).is_some()))
2392 .cloned()
2393 .collect();
2394 let on = (on.len() < refs::hosts().len()).then_some(on);
2395 lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
2396 }
2397 for f in tags {
2398 let Some(on) = refs::tag_hosts(refs::tag_registry(&f.value.kind), &f.value.id) else { continue };
2399 lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
2400 }
2401 let mut out = Vec::new();
2402 for alts in lists.values() {
2403 if alts.iter().any(|(_, on)| on.is_none()) {
2404 continue;
2405 }
2406 let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
2407 let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
2408 if missing.is_empty() {
2409 continue;
2410 }
2411 let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
2412 let hint = alts
2414 .iter()
2415 .filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
2416 .find(|y| refs::tag_hosts("item", y) == Some(None))
2417 .map(|y| format!(" — @tag:item:{y} is on every version and loader"))
2418 .unwrap_or_default();
2419 out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
2420 }
2421 out
2422}
2423
2424struct Defs {
2427 order: BTreeMap<String, Vec<String>>,
2428 sound_groups: Vec<yog_formats::def::SoundGroup>,
2429 tool_materials: Vec<yog_formats::def::ToolMaterial>,
2430 armor_materials: Vec<yog_formats::def::ArmorMaterial>,
2431 biome_features: Vec<(String, String, String)>,
2433 dimensions: Vec<String>,
2435 tables: Vec<table::Table>,
2437 loot_modifiers: Vec<String>,
2439 definitions: Vec<(&'static str, String)>,
2441}
2442
2443impl Defs {
2444 fn level(&self, id: &str) -> Option<(&str, usize)> {
2446 self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
2447 }
2448}
2449
2450fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
2453 let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
2454 let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
2455 order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
2456 let ns = mod_ns.unwrap_or("minecraft");
2457 let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
2458 let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
2460 let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
2461 let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
2462 let repair = |r: &yog_data::ItemIdOrTag| match r {
2463 yog_data::OneOrTag::One(i) => i.id.clone(),
2464 yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
2465 };
2466 for (file, text) in defs {
2467 let doc = match yog_data::text::parse(text) {
2468 Ok(d) => d,
2469 Err(e) => {
2470 errors.push(format!("{file}:{}: {}", e.at.line, e.message));
2471 continue;
2472 }
2473 };
2474 match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
2475 Ok(r) => match r.value {
2476 yog_formats::def::Def::Ladder { id } => {
2477 if order.insert(full(&id), Vec::new()).is_some() {
2478 errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
2479 }
2480 }
2481 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)),
2482 yog_formats::def::Def::SoundGroup(mut g) => {
2483 for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
2485 *s = full(s);
2486 }
2487 if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
2488 errors.push(format!("{file}:1: sound group {} is the game's", g.id));
2489 } else if sound_groups.iter().any(|x| x.id == g.id) {
2490 errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
2491 } else {
2492 sound_groups.push(g);
2493 }
2494 }
2495 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(_) => {}
2497 yog_formats::def::Def::ToolMaterial(mut m) => {
2499 m.id = full(&m.id);
2500 tools.push((file.clone(), m));
2501 }
2502 yog_formats::def::Def::ArmorMaterial(m) => {
2503 let id = full(&m.id);
2504 if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
2505 errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
2506 continue;
2507 }
2508 armor_materials.push(yog_formats::def::ArmorMaterial {
2509 texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
2510 equip_sound: full(&m.equip_sound),
2511 durability: m.durability,
2512 defense: m.defense,
2513 enchantability: m.enchantability,
2514 toughness: m.toughness,
2515 knockback_resistance: m.knockback_resistance,
2516 repair: repair(&m.repair),
2517 id,
2518 });
2519 }
2520 },
2521 Err(es) => {
2522 for e in es.0 {
2523 let l = e.at.map_or(1, |a| a.line as usize);
2524 errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
2525 }
2526 }
2527 }
2528 }
2529 loop {
2531 let before = levels.len();
2532 levels.retain(|(file, line, id, ladder, above)| {
2533 let Some(list) = order.get_mut(ladder) else {
2534 errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
2535 return false;
2536 };
2537 let at = match above {
2538 None => 0,
2539 Some(a) => match list.iter().position(|x| x == a) {
2540 Some(i) => i + 1,
2541 None => return true, },
2543 };
2544 if list.contains(id) {
2545 errors.push(format!("{file}:{line}: level {id} is defined twice"));
2546 } else {
2547 list.insert(at, id.clone());
2548 }
2549 false
2550 });
2551 if levels.is_empty() || levels.len() == before {
2552 break;
2553 }
2554 }
2555 for (file, line, id, ladder, above) in levels {
2556 let above = above.unwrap_or_default();
2557 errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
2558 }
2559 let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
2564 let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
2565 for (file, m) in tools {
2566 if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
2567 errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
2568 continue;
2569 }
2570 let Some(at) = ladder.iter().position(|l| l == &m.level) else {
2571 errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
2572 continue;
2573 };
2574 let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
2575 let game = ["wood", "stone", "iron", "diamond", "netherite"];
2576 let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
2577 Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
2578 None => {
2580 let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
2581 (5, format!("{n}:incorrect_for_{p}_tool"))
2582 }
2583 };
2584 tool_materials.push(yog_formats::def::ToolMaterial {
2585 id: m.id,
2586 durability: m.durability,
2587 speed: m.speed,
2588 attack_damage: m.attack_damage,
2589 enchantability: m.enchantability,
2590 level: m.level,
2591 repair: repair(&m.repair),
2592 level_index,
2593 incorrect,
2594 });
2595 }
2596 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() }
2597}
2598
2599fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
2604 let Some((ladder, at)) = ladders.level(level) else { return };
2605 if ladder != yog_formats::def::TOOL_LADDER {
2606 err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
2607 return;
2608 }
2609 let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
2610 let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
2611 let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
2613 let mut tag = |path: String| {
2614 out.tags.entry(path).or_default().insert(id.to_string(), true);
2615 };
2616 match able {
2617 Some("wood") => {}
2618 Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
2619 _ => {
2620 tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
2622 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
2623 tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
2624 for loader in ["forge", "neoforge"] {
2625 tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
2626 }
2627 if able.is_none() {
2628 tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
2629 warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
2630 }
2631 }
2632 }
2633}
2634
2635fn inventory_screens(dir: &Path) -> BTreeSet<String> {
2638 let mut out = BTreeSet::new();
2639 let Ok(entries) = std::fs::read_dir(dir) else {
2640 return out;
2641 };
2642 for e in entries.flatten() {
2643 let path = e.path();
2644 if path.is_dir() {
2645 out.extend(inventory_screens(&path));
2646 continue;
2647 }
2648 if path.extension().is_none_or(|x| x != "yui") {
2649 continue;
2650 }
2651 let Ok(text) = std::fs::read_to_string(&path) else { continue };
2652 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
2653 continue;
2654 }
2655 if let Some(screen) = text.find("<Screen")
2656 && let Some(at) = text[screen..].find(" id=\"")
2657 {
2658 let rest = &text[screen + at + 5..];
2659 if let Some(end) = rest.find('"') {
2660 out.insert(rest[..end].to_string());
2661 }
2662 }
2663 }
2664 out
2665}
2666
2667pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
2670 let mut out = Compiled::default();
2671 let files = find(root);
2672 if files.is_empty() {
2673 return Ok(out);
2674 }
2675 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
2676 let mut errors: Vec<String> = Vec::new();
2677 let mut warnings: Vec<String> = Vec::new();
2678 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
2679 let mut generated: BTreeMap<String, String> = BTreeMap::new();
2680 let mut entries: Vec<Entry> = Vec::new();
2681 let mut ids: HashMap<String, String> = HashMap::new();
2682 let mut recipes: Vec<Recipe> = Vec::new();
2683 let mut recipe_ids: HashMap<String, String> = HashMap::new();
2684 let mut books: Vec<BookFile> = Vec::new();
2685 let mut book_ids: HashMap<String, String> = HashMap::new();
2686 let mut bodies: Vec<BodyFile> = Vec::new();
2687 let mut entity_files: Vec<EntityFile> = Vec::new();
2688 let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
2689 let mut inventories: Vec<(String, usize, String)> = Vec::new();
2690 let mut pending_books: Vec<(String, String)> = Vec::new();
2691 let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
2692 let mut pending_layouts: Vec<(String, String)> = Vec::new();
2693 let mut pending_defs: Vec<(String, String)> = Vec::new();
2694 let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
2695 for path in &files {
2696 let shown = display(root, path);
2697 let Some(kind) = Kind::of(path) else { continue };
2698 let text = match std::fs::read_to_string(path) {
2699 Ok(t) => t,
2700 Err(e) => {
2701 errors.push(format!("{shown}: {e}"));
2702 continue;
2703 }
2704 };
2705 if matches!(kind, Kind::Model | Kind::Anim) {
2706 if let Some(b) = compile_body(&shown, &text, kind, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
2707 if let Some(other) = body_ids.insert((kind == Kind::Model, b.id.clone()), shown.clone()) {
2708 errors.push(format!("{shown}: {} {} is also defined by {other}", kind.word(), b.id));
2709 }
2710 bodies.push(b);
2711 }
2712 continue;
2713 }
2714 if kind == Kind::Book {
2715 pending_books.push((shown.clone(), text));
2717 continue;
2718 }
2719 if kind == Kind::Recipe {
2720 pending_recipes.push((shown.clone(), text, path.clone()));
2722 continue;
2723 }
2724 if kind == Kind::Def {
2725 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));
2727 if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table" | "tag" | "damage_type" | "tree" | "loot_modifier" | "loot_table" | "attribute" | "effect" | "potion")) {
2728 pending_layouts.push((shown.clone(), text));
2729 } else {
2730 pending_defs.push((shown.clone(), text));
2731 }
2732 continue;
2733 }
2734 let mut cx = Ctx {
2735 root,
2736 refs,
2737 file: shown.clone(),
2738 ns: mod_ns.unwrap_or("minecraft").to_string(),
2739 errors: &mut errors,
2740 warnings: &mut warnings,
2741 textures: &mut textures,
2742 inventories: &mut inventories,
2743 deferred: &mut deferred,
2744 };
2745 if kind == Kind::Entity {
2746 if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
2747 if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
2748 errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
2749 }
2750 entity_files.push(e);
2751 }
2752 continue;
2753 }
2754 if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
2755 {
2756 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2757 errors.push(format!(
2758 "{shown}: id {} is also defined by {other}",
2759 entry.id
2760 ));
2761 }
2762 entries.push(entry);
2763 }
2764 }
2765 let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
2767 let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
2768 let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
2769 let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
2770 for e in &entries {
2771 known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2772 }
2773 for e in &entity_files {
2774 known.add(refs::RefKind::Entity, e.id.clone());
2775 for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
2777 if let Some(id) = wanted
2778 && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
2779 && !bodies.iter().any(|b| b.model == model && &b.id == id)
2780 {
2781 errors.push(format!("{}: {what} `{id}`: no such file in content/ (`{}`)", e.file, if model { ".ymodel" } else { ".yanim" }));
2782 }
2783 }
2784 }
2785 let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
2788 let mut ores: Vec<OreOut> = Vec::new();
2789 let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
2790 let mut tables: Vec<table::Table> = Vec::new();
2791 let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
2793 let mut definitions: Vec<(&'static str, String, Vec<u8>)> = Vec::new();
2795 for (shown, text) in &pending_layouts {
2796 let mut cx = Ctx {
2797 root,
2798 refs,
2799 file: shown.clone(),
2800 ns: mod_ns.unwrap_or("minecraft").to_string(),
2801 errors: &mut errors,
2802 warnings: &mut warnings,
2803 textures: &mut textures,
2804 inventories: &mut inventories,
2805 deferred: &mut deferred,
2806 };
2807 let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut definitions, &mut out) else { continue };
2808 if recipe_defs.contains_key(&k.id) {
2809 errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
2810 }
2811 recipe_defs.insert(k.id.clone(), k);
2812 }
2813 let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
2815 for (shown, text, path) in &pending_recipes {
2816 let mut cx = Ctx {
2817 root,
2818 refs,
2819 file: shown.clone(),
2820 ns: mod_ns.unwrap_or("minecraft").to_string(),
2821 errors: &mut errors,
2822 warnings: &mut warnings,
2823 textures: &mut textures,
2824 inventories: &mut inventories,
2825 deferred: &mut deferred,
2826 };
2827 if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs) {
2828 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2829 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2830 }
2831 recipes.push(r);
2832 }
2833 }
2834 for r in &recipes {
2835 known.add(refs::RefKind::Recipe, r.id.clone());
2836 }
2837 let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
2839 for (files, feature) in ores {
2840 out.outputs.extend(files);
2841 defs.biome_features.push(feature);
2842 }
2843 for (file, line, path, r) in &deferred {
2845 let found = match r.kind.as_str() {
2846 "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
2847 "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
2848 "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
2849 .then_some(())
2850 .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
2851 "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
2852 .then_some(())
2853 .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
2854 "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
2855 .then_some(())
2856 .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
2857 kind if kind.starts_with(TAG_REF) => {
2858 let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
2860 let first = found.next().unwrap_or(Ok(()));
2861 if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
2862 }
2863 kind => match refs::RefKind::from_word(kind) {
2864 Some(k) => known.check(k, &r.id),
2865 None => Ok(()),
2866 },
2867 };
2868 if let Err(e) = found {
2869 errors.push(format!("{file}:{line}: {path}: {e}"));
2870 }
2871 }
2872 for e in &entries {
2873 if let Some((level, line)) = &e.block.tool_level {
2874 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)));
2875 }
2876 }
2877 let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
2878 let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
2880 for (shown, text) in &pending_books {
2881 let mut cx = Ctx {
2882 root,
2883 refs,
2884 file: shown.clone(),
2885 ns: mod_ns.unwrap_or("minecraft").to_string(),
2886 errors: &mut errors,
2887 warnings: &mut warnings,
2888 textures: &mut textures,
2889 inventories: &mut inventories,
2890 deferred: &mut deferred,
2891 };
2892 if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
2893 if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2894 errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2895 }
2896 books.push(b);
2897 }
2898 }
2899 if let Some(ns) = mod_ns {
2902 let screens = inventory_screens(&root.join("src"));
2903 for (file, line, id) in &inventories {
2904 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2905 errors.push(format!(
2906 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2907 ));
2908 }
2909 }
2910 }
2911 for (target, source) in &textures {
2912 match std::fs::read(source) {
2913 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2914 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2915 }
2916 }
2917 out.warnings = warnings;
2918 if !errors.is_empty() {
2919 return Err(format!(
2920 "invalid blocks/items in content/:\n {}",
2921 errors.join("\n ")
2922 ));
2923 }
2924 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2925 out.items = entries.len() - out.blocks;
2926 out.recipes = recipes.len();
2927 out.books = books.len();
2928 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())
2929 && !mentions_content_macro(&root.join("src"))
2930 {
2931 out.warnings.push(format!(
2932 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2933 CONTENT_DIR
2934 ));
2935 }
2936 out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
2937 out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
2938 out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
2939 out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
2940 out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
2941 out.data_files.extend(definitions.iter().map(|(kind, id, data)| (definition_path(kind, id), data.clone())));
2942 defs.definitions = definitions.iter().map(|(kind, id, _)| (*kind, id.clone())).collect();
2943 defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
2944 for m in &defs.tool_materials {
2946 if let Some((n, p)) = m.incorrect.split_once(':')
2947 && n != "minecraft"
2948 {
2949 out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
2950 }
2951 }
2952 defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
2953 defs.tables = tables;
2954 out.source = Some(rust_source(&entries, &recipes, &books, &bodies, &entity_files, &defs));
2955 version_overlays(&mut out, &known.datapack);
2956 out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
2957 Ok(out)
2958}
2959
2960fn registrations(entries: &[Entry], entities: &[EntityFile], dimensions: &[(String, Vec<u8>)], defs: &Defs) -> String {
2965 fn hash(text: &str) -> String {
2966 let h = text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
2968 format!("{h:016x}")
2969 }
2970 let mut lines = Vec::new();
2971 for e in entries {
2972 let what = match e.kind {
2973 Kind::Block => {
2974 let b = BlockProps { tools: Vec::new(), tool_level: None, ..e.block.clone() };
2976 format!("{b:?} {:?} {:?}", e.item, e.requires)
2977 }
2978 _ => format!("{:?} {:?}", e.item, e.requires),
2979 };
2980 lines.push(format!("{} {} {}", e.kind.word(), e.id, hash(&what)));
2981 }
2982 for e in entities {
2983 lines.push(format!("entity {} {}", e.id, hash(&format!("{:?}", e.data))));
2984 }
2985 for (id, data) in dimensions {
2986 lines.push(format!("dimension {id} {}", hash(&format!("{data:?}"))));
2987 }
2988 for g in &defs.sound_groups {
2989 lines.push(format!("sound_group {} {}", g.id, hash(&format!("{g:?}"))));
2990 }
2991 for m in &defs.tool_materials {
2992 lines.push(format!("tool_material {} {}", m.id, hash(&format!("{m:?}"))));
2993 }
2994 for m in &defs.armor_materials {
2995 lines.push(format!("armor_material {} {}", m.id, hash(&format!("{m:?}"))));
2996 }
2997 lines.sort();
2998 lines.join("\n") + "\n"
2999}
3000
3001fn version_overlays(out: &mut Compiled, datapack: &str) {
3006 fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
3007 match v {
3008 Value::String(s) if s.starts_with("minecraft:") => {
3009 let id = s.trim_start_matches('#').to_string();
3010 let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
3011 match eq {
3012 Some(e) if e != id => {
3013 *s = e;
3014 true
3015 }
3016 Some(_) => false,
3017 None => {
3019 if refs::is_vanilla(&id) {
3020 missing.insert(id);
3021 }
3022 false
3023 }
3024 }
3025 }
3026 Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
3027 Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
3028 _ => false,
3029 }
3030 }
3031 let mut overlays = Vec::new();
3032 for (path, bytes) in &out.outputs {
3033 if !path.starts_with("data/") || !path.ends_with(".json") {
3034 continue;
3035 }
3036 let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
3037 for version in refs::versions().iter().filter(|v| *v != datapack) {
3038 let mut copy = json.clone();
3039 let mut missing = BTreeSet::new();
3040 if map(&mut copy, version, &mut missing) {
3041 overlays.push((format!("when/any/{version}/{path}"), pretty(©)));
3042 }
3043 for id in missing {
3044 out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
3045 }
3046 }
3047 }
3048 out.outputs.extend(overlays);
3049}
3050
3051
3052fn mentions_content_macro(dir: &Path) -> bool {
3054 let Ok(entries) = std::fs::read_dir(dir) else {
3055 return false;
3056 };
3057 entries.flatten().any(|e| {
3058 let p = e.path();
3059 if p.is_dir() {
3060 mentions_content_macro(&p)
3061 } else {
3062 p.extension().is_some_and(|x| x == "rs")
3063 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
3064 }
3065 })
3066}
3067
3068fn rust_ident(path: &str) -> String {
3070 let mut s: String = path
3071 .chars()
3072 .map(|c| {
3073 if c.is_ascii_alphanumeric() {
3074 c.to_ascii_lowercase()
3075 } else {
3076 '_'
3077 }
3078 })
3079 .collect();
3080 if s.starts_with(|c: char| c.is_ascii_digit()) {
3081 s.insert(0, '_');
3082 }
3083 s
3084}
3085
3086fn float(n: f64) -> String {
3087 let s = format!("{n:?}");
3088 if s.contains('.') || s.contains('e') {
3089 s
3090 } else {
3091 format!("{s}.0")
3092 }
3093}
3094
3095fn when_loaded(requires: &[String], stmt: &str) -> String {
3097 if requires.is_empty() {
3098 return stmt.to_string();
3099 }
3100 let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
3101 format!("if {} {{ {stmt} }}", cond.join(" && "))
3102}
3103
3104fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], bodies: &[BodyFile], entities: &[EntityFile], defs: &Defs) -> String {
3105 let sound_groups = &defs.sound_groups;
3106 let mut code = String::from(
3107 "// <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",
3108 );
3109 if !entities.is_empty() {
3110 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");
3111 let mut used: HashMap<String, usize> = HashMap::new();
3112 for e in entities {
3113 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3114 let mut ident = rust_ident(path).to_ascii_uppercase();
3115 let n = used.entry(ident.clone()).or_insert(0);
3116 *n += 1;
3117 if *n > 1 {
3118 let _ = write!(ident, "_{n}");
3119 }
3120 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", e.id);
3121 }
3122 code.push_str("}\n");
3123 }
3124 for (model, set, what) in [(true, "Models", "models (`.ymodel`)"), (false, "Anims", "animations (`.yanim`)")] {
3125 let mine: Vec<&BodyFile> = bodies.iter().filter(|b| b.model == model).collect();
3126 if mine.is_empty() {
3127 continue;
3128 }
3129 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} {{");
3130 let mut used: HashMap<String, usize> = HashMap::new();
3131 for b in mine {
3132 let path = b.id.split_once(':').map_or(b.id.as_str(), |(_, p)| p);
3133 let mut ident = rust_ident(path).to_ascii_uppercase();
3134 let n = used.entry(ident.clone()).or_insert(0);
3135 *n += 1;
3136 if *n > 1 {
3137 let _ = write!(ident, "_{n}");
3138 }
3139 let _ = writeln!(code, " pub const {ident}: &'static str = {:?};", b.id);
3140 }
3141 code.push_str("}\n");
3142 }
3143 for (kind, set, def) in [
3144 (Kind::Block, "Blocks", "BlockDef"),
3145 (Kind::Item, "Items", "ItemDef"),
3146 ] {
3147 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
3148 let _ = writeln!(
3149 code,
3150 "\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} {{",
3151 if kind == Kind::Block {
3152 "blocks"
3153 } else {
3154 "items"
3155 }
3156 );
3157 let mut used: HashMap<String, usize> = HashMap::new();
3158 for e in mine {
3159 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3160 let mut ident = rust_ident(path);
3161 let n = used.entry(ident.clone()).or_insert(0);
3162 *n += 1;
3163 if *n > 1 {
3164 let _ = write!(ident, "_{n}");
3165 }
3166 let upper = ident.to_ascii_uppercase();
3167 let ident = if is_keyword(&ident) {
3168 format!("r#{ident}")
3169 } else {
3170 ident
3171 };
3172 let _ = writeln!(
3173 code,
3174 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
3175 e.id, e.file, e.id
3176 );
3177 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
3178 match kind {
3179 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3180 Kind::Block => {
3181 let p = &e.block;
3182 if p.hardness.is_some() || p.resistance.is_some() {
3183 let _ = write!(
3184 b,
3185 "\n .strength({}, {})",
3186 float(p.hardness.unwrap_or(1.5)),
3187 float(p.resistance.unwrap_or(6.0))
3188 );
3189 }
3190 if let Some(s) = &p.sound {
3191 let _ = write!(b, "\n .sound({s:?})");
3192 }
3193 if p.light > 0 {
3194 let _ = write!(b, "\n .light_level({})", p.light);
3195 }
3196 if p.requires_tool {
3197 b.push_str("\n .requires_tool()");
3198 }
3199 if p.no_collision {
3200 b.push_str("\n .no_collision()");
3201 }
3202 if let Some(s) = p.slipperiness {
3203 let _ = write!(b, "\n .slipperiness({})", float(s));
3204 }
3205 if let Some(s) = p.shape {
3206 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
3207 let _ = write!(b, "\n .shape({})", args.join(", "));
3208 }
3209 if p.connects {
3210 b.push_str("\n .connects_to_neighbors()");
3211 }
3212 if !p.connect_groups.is_empty() {
3213 let groups: Vec<String> =
3214 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
3215 let _ =
3216 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
3217 }
3218 if let Some(inv) = &p.inventory {
3219 let _ = write!(b, "\n .inventory({inv:?})");
3220 }
3221 if p.redstone {
3222 b.push_str("\n .redstone_source()");
3223 }
3224 if !p.facing.is_empty() {
3225 let _ = write!(b, "\n .facing({:?}, {:?})", p.facing, p.placement);
3226 }
3227 if p.attach {
3228 b.push_str("\n .attach()");
3229 }
3230 if p.waterlogged {
3231 b.push_str("\n .waterlogged()");
3232 }
3233 for (name, values) in &p.properties {
3234 let vs: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
3235 let _ = write!(b, "\n .property({name:?}, &[{}])", vs.join(", "));
3236 }
3237 if let Some(name) = &p.powered {
3238 let _ = write!(b, "\n .powered({name:?})");
3239 }
3240 if let Some(name) = &p.comparator {
3241 let _ = write!(b, "\n .comparator({name:?})");
3242 }
3243 if p.accepts_items {
3244 b.push_str("\n .accepts_items()");
3245 }
3246 }
3247 Kind::Item => b.push_str(&item_props(&e.item)),
3248 }
3249 let _ = writeln!(
3250 code,
3251 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
3252 e.file
3253 );
3254 }
3255 code.push_str("}\n");
3256 }
3257
3258 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");
3259 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
3260 for e in entries {
3261 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3262 let mut ident = rust_ident(path);
3263 let n = used.entry((e.kind, ident.clone())).or_insert(0);
3264 *n += 1;
3265 if *n > 1 {
3266 let _ = write!(ident, "_{n}");
3267 }
3268 let ident = if is_keyword(&ident) {
3269 format!("r#{ident}")
3270 } else {
3271 ident
3272 };
3273 if !e.auto {
3274 continue;
3275 }
3276 let _ = match e.kind {
3277 Kind::Block => {
3278 let item = item_props(&e.item);
3280 let stmt = if item.is_empty() {
3281 format!("registry.register_block(Blocks::{ident}());")
3282 } else {
3283 format!("{{ registry.register_block(Blocks::{ident}()); registry.register_item(::yog_api::ItemDef::new({:?}){}); }}", e.id, item.replace("\n ", ""))
3284 };
3285 writeln!(code, " {}", when_loaded(&e.requires, &stmt))
3286 }
3287 Kind::Item => writeln!(code, " {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
3288 Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3289 };
3290 }
3291 for g in sound_groups {
3292 let _ = writeln!(
3293 code,
3294 " registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
3295 g.id,
3296 g.break_sound,
3297 g.step,
3298 g.place,
3299 g.hit,
3300 g.fall,
3301 float(g.volume as f64),
3302 float(g.pitch as f64)
3303 );
3304 }
3305 for id in &defs.dimensions {
3306 let _ = writeln!(
3307 code,
3308 " registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3309 dimension_path(id)
3310 );
3311 }
3312 for (feature, biomes, step) in &defs.biome_features {
3313 let _ = writeln!(code, " registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
3314 }
3315 for m in &defs.tool_materials {
3316 let _ = writeln!(
3317 code,
3318 " registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
3319 m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
3320 );
3321 }
3322 for m in &defs.armor_materials {
3323 let d = m.defense;
3324 let _ = writeln!(
3325 code,
3326 " 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() }});",
3327 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
3328 );
3329 }
3330 for r in recipes {
3331 let _ = writeln!(code, " {}", when_loaded(&r.requires, &r.registration()));
3332 }
3333 for b in books {
3334 let _ = writeln!(
3335 code,
3336 " registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3337 b.id,
3338 b.path()
3339 );
3340 }
3341 for (kind, id) in &defs.definitions {
3342 let _ = writeln!(
3343 code,
3344 " registry.register_definition_data({kind:?}, {id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3345 definition_path(kind, id)
3346 );
3347 }
3348 for id in &defs.loot_modifiers {
3349 let _ = writeln!(
3350 code,
3351 " registry.add_loot_modifier_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3352 loot_path(id)
3353 );
3354 }
3355 for e in entities {
3356 let _ = writeln!(
3357 code,
3358 " registry.register_entity_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3359 e.id,
3360 e.path()
3361 );
3362 }
3363 for b in bodies {
3364 let _ = writeln!(
3365 code,
3366 " registry.register_body_{}({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3367 if b.model { "model" } else { "anims" },
3368 b.id,
3369 b.path()
3370 );
3371 }
3372 code.push_str("}\n");
3373 for t in &defs.tables {
3374 code.push_str(&table::rust(t));
3375 }
3376 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() {
3377 code.push_str(
3378 "\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",
3379 );
3380 }
3381 code
3382}
3383
3384fn is_keyword(s: &str) -> bool {
3385 const KW: &[&str] = &[
3386 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
3387 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
3388 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
3389 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
3390 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
3391 ];
3392 KW.contains(&s)
3393}
3394
3395pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
3398 write_tags(root, compiled)?;
3399 let out = root.join(GENERATED_FILE);
3400 let Some(code) = &compiled.source else {
3401 let _ = std::fs::remove_file(&out);
3402 return Ok(());
3403 };
3404 crate::write_if_changed(&out, code)?;
3405 let dir = out.parent().unwrap_or(root);
3406 for (path, bytes) in &compiled.data_files {
3407 let file = dir.join(path);
3408 if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
3409 if let Some(parent) = file.parent() {
3410 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
3411 }
3412 std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
3413 }
3414 }
3415 Ok(())
3416}
3417
3418pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
3421
3422fn write_tags(root: &Path, compiled: &Compiled) -> Result<(), String> {
3424 let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
3425 for (path, entries) in &compiled.tags {
3426 let data = path.find("data/").map_or(path.as_str(), |i| &path[i + 5..]);
3428 let Some((ns, rest)) = data.split_once("/tags/") else { continue };
3429 let Some((kind, tag)) = rest.split_once('/') else { continue };
3430 let tag = tag.trim_end_matches(".json");
3431 by_kind.entry(kind.to_string()).or_default().entry(format!("{ns}:{tag}")).or_default().extend(entries.keys().cloned());
3432 }
3433 let text = serde_json::to_string_pretty(&by_kind).map_err(|e| e.to_string())?;
3434 crate::write_if_changed(&root.join(TAGS_FILE), &text)
3435}
3436
3437#[derive(Default)]
3440pub struct OutputSet {
3441 items: Vec<Option<Output>>,
3442 at: HashMap<String, usize>,
3443}
3444
3445impl OutputSet {
3446 pub fn get(&self, path: &str) -> Option<&[u8]> {
3447 let i = *self.at.get(path)?;
3448 self.items[i].as_ref().map(|(_, b)| b.as_slice())
3449 }
3450
3451 pub fn contains(&self, path: &str) -> bool {
3452 self.at.contains_key(path)
3453 }
3454
3455 pub fn paths(&self) -> impl Iterator<Item = &String> {
3456 self.at.keys()
3457 }
3458
3459 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
3461 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
3462 self.items[i] = None;
3463 }
3464 self.items.push(Some((path, bytes)));
3465 }
3466
3467 pub fn into_vec(self) -> Vec<Output> {
3468 self.items.into_iter().flatten().collect()
3469 }
3470}
3471
3472impl From<Vec<Output>> for OutputSet {
3473 fn from(outputs: Vec<Output>) -> Self {
3474 let mut set = OutputSet::default();
3475 for (path, bytes) in outputs {
3476 set.put(path, bytes);
3477 }
3478 set
3479 }
3480}
3481
3482fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
3485 let read;
3486 let bytes = match outputs.get(path) {
3487 Some(b) => b,
3488 None => match std::fs::read(root.join(path)) {
3489 Ok(b) => {
3490 read = b;
3491 &read
3492 }
3493 Err(_) => return Ok(Map::new()),
3494 },
3495 };
3496 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
3497}
3498
3499pub fn merge_outputs(
3503 root: &Path,
3504 outputs: &mut Vec<Output>,
3505 compiled: Compiled,
3506) -> Result<(), String> {
3507 let mut set = OutputSet::from(std::mem::take(outputs));
3508 let mut errors = Vec::new();
3509 if !compiled.lang_aliases.is_empty() {
3512 let mut lang_files: BTreeSet<String> =
3513 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
3514 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
3515 for ns in dirs.flatten() {
3516 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
3517 for f in files.flatten() {
3518 if let Ok(rel) = f.path().strip_prefix(root) {
3519 let rel = rel.to_string_lossy().replace('\\', "/");
3520 if is_lang_path(&rel) {
3521 lang_files.insert(rel);
3522 }
3523 }
3524 }
3525 }
3526 }
3527 }
3528 for path in lang_files {
3529 let mut map = match json_output(&set, root, &path) {
3530 Ok(m) => m,
3531 Err(e) => {
3532 errors.push(e);
3533 continue;
3534 }
3535 };
3536 let mut changed = false;
3537 for (from, to) in &compiled.lang_aliases {
3538 if let Some(text) = map.get(from).cloned()
3539 && !map.contains_key(to)
3540 {
3541 map.insert(to.clone(), text);
3542 changed = true;
3543 }
3544 }
3545 if changed {
3546 set.put(path, pretty(&Value::Object(map)));
3547 }
3548 }
3549 }
3550 for ((ns, locale), entries) in &compiled.lang {
3551 let path = format!("assets/{ns}/lang/{locale}.json");
3552 let mut map = match json_output(&set, root, &path) {
3553 Ok(m) => m,
3554 Err(e) => {
3555 errors.push(e);
3556 continue;
3557 }
3558 };
3559 for (k, v) in entries {
3560 map.entry(k.clone())
3561 .or_insert_with(|| Value::String(v.clone()));
3562 }
3563 set.put(path, pretty(&Value::Object(map)));
3564 }
3565 for (path, ids) in &compiled.tags {
3566 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
3569 let path = if !set.contains(path) && root.join(&plural).is_file() {
3570 plural
3571 } else {
3572 path.clone()
3573 };
3574 let mut map = match json_output(&set, root, &path) {
3575 Ok(m) => m,
3576 Err(e) => {
3577 errors.push(e);
3578 continue;
3579 }
3580 };
3581 let values = map
3582 .entry("values")
3583 .or_insert_with(|| Value::Array(Vec::new()));
3584 if let Value::Array(list) = values {
3585 for (id, &required) in ids {
3586 let listed = |v: &Value| v.as_str().or_else(|| v.get("id").and_then(Value::as_str)) == Some(id.as_str());
3587 if !list.iter().any(listed) {
3588 list.push(if required { Value::String(id.clone()) } else { serde_json::json!({ "id": id, "required": false }) });
3589 }
3590 }
3591 }
3592 map.entry("replace").or_insert(Value::Bool(false));
3593 set.put(path, pretty(&Value::Object(map)));
3594 }
3595 for (path, bytes) in compiled.outputs {
3596 set.put(path, bytes);
3597 }
3598 *outputs = set.into_vec();
3599 if errors.is_empty() {
3600 Ok(())
3601 } else {
3602 Err(format!(
3603 "merging blocks/items into the mod's files:\n {}",
3604 errors.join("\n ")
3605 ))
3606 }
3607}
3608
3609fn is_lang_path(p: &str) -> bool {
3610 let parts: Vec<&str> = p.split('/').collect();
3611 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
3612}
3613
3614#[cfg(test)]
3615mod tests {
3616 use super::*;
3617
3618 fn project(files: &[(&str, &str)]) -> PathBuf {
3619 use std::sync::atomic::{AtomicUsize, Ordering};
3620 static N: AtomicUsize = AtomicUsize::new(0);
3621 let dir = std::env::temp_dir().join(format!(
3622 "yog-content-test-{}-{}",
3623 std::process::id(),
3624 N.fetch_add(1, Ordering::SeqCst)
3625 ));
3626 let _ = std::fs::remove_dir_all(&dir);
3627 for (path, text) in files {
3628 let p = dir.join(path);
3629 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3630 std::fs::write(p, text).unwrap();
3631 }
3632 dir
3633 }
3634
3635 fn output(c: &Compiled, path: &str) -> Value {
3636 let (_, b) = c
3637 .outputs
3638 .iter()
3639 .find(|(p, _)| p == path)
3640 .unwrap_or_else(|| {
3641 panic!(
3642 "no {path} in {:?}",
3643 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
3644 )
3645 });
3646 serde_json::from_slice(b).unwrap()
3647 }
3648
3649 #[test]
3650 fn compiles_a_cube_block() {
3651 let root = project(&[
3652 ("resources/block/ruby_ore.png", "png"),
3653 ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
3654 (
3655 "content/blocks/ruby_ore.yblock",
3656 r#"{
3657 "name": "Ruby Ore",
3658 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
3659 "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
3660 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
3661}"#,
3662 ),
3663 ]);
3664 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3665 assert_eq!(c.blocks, 1);
3666 assert_eq!(
3667 output(&c, "assets/mymod/models/block/ruby_ore.json"),
3668 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
3669 );
3670 assert_eq!(
3671 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
3672 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
3673 );
3674 assert_eq!(
3675 output(&c, "assets/mymod/models/item/ruby_ore.json"),
3676 json!({ "parent": "mymod:block/ruby_ore" })
3677 );
3678 assert_eq!(
3679 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
3680 json!("mymod:item/ruby_ore")
3681 );
3682 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
3683 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
3684 assert_eq!(
3685 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
3686 json!(3)
3687 );
3688 assert!(
3689 c.outputs
3690 .iter()
3691 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
3692 );
3693 let src = c.source.as_ref().unwrap();
3694 assert!(
3695 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
3696 "{src}"
3697 );
3698 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
3699 assert!(src.contains(".requires_tool()"), "{src}");
3700 assert!(
3701 src.contains("registry.register_block(Blocks::ruby_ore());"),
3702 "{src}"
3703 );
3704
3705 let mut outputs = Vec::new();
3706 merge_outputs(&root, &mut outputs, c).unwrap();
3707 let find = |p: &str| -> Value {
3708 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
3709 };
3710 assert_eq!(
3711 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
3712 json!("Ruby Ore")
3713 );
3714 assert_eq!(
3715 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
3716 json!(["mymod:ruby_ore"])
3717 );
3718 assert_eq!(
3719 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
3720 json!(["mymod:ruby_ore"])
3721 );
3722 let _ = std::fs::remove_dir_all(root);
3723 }
3724
3725 #[test]
3726 fn raw_parent_with_elements_gets_a_particle() {
3727 let root = project(&[(
3728 "content/cable.yblock",
3729 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
3730 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
3731 )]);
3732 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3733 let m = output(&c, "assets/mymod/models/block/cable.json");
3734 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3735 let _ = std::fs::remove_dir_all(root);
3736 }
3737
3738 #[test]
3739 fn a_facing_block_turns_to_its_placer() {
3740 let root = project(&[(
3741 "content/screen.yblock",
3742 r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
3743 "states": { "facing": "horizontal" } }"##,
3744 )]);
3745 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3746 let bs = output(&c, "assets/mymod/blockstates/screen.json");
3747 assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
3748 assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
3749 assert!(c.source.unwrap().contains(".facing(\"horizontal\", \"toward\")"));
3750 let _ = std::fs::remove_dir_all(root);
3751 }
3752
3753 #[test]
3754 fn compiles_elements_shape_and_connect() {
3755 let root = project(&[(
3756 "content/pipe.yblock",
3757 r##"{
3758 "block": { "render_layer": "cutout" },
3759 "model": {
3760 "textures": { "0": "minecraft:block/stone" },
3761 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
3762 "rotation": { "axis": "y", "angle": 22.5 },
3763 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
3764 },
3765 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
3766 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
3767}"##,
3768 )]);
3769 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3770 let m = output(&c, "assets/mymod/models/block/pipe.json");
3771 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3772 assert_eq!(m["render_type"], json!("minecraft:cutout"));
3773 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
3774 assert_eq!(m["parent"], json!("minecraft:block/block"), "shown in a slot and held as a block");
3775 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
3776 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
3777 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
3778 let src = c.source.unwrap();
3779 assert!(
3780 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
3781 "{src}"
3782 );
3783 assert!(src.contains(".connects_to_neighbors()"), "{src}");
3784 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
3785 let _ = std::fs::remove_dir_all(root);
3786 }
3787
3788 #[test]
3789 fn sound_groups_are_the_games_or_a_mods_ydef() {
3790 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3791 let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
3792 place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
3793 let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
3794 let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
3795 let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
3796 let src = c.source.unwrap();
3797 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}");
3798 assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
3799
3800 let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
3801 let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
3802 let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3803 assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
3804 assert!(e.contains("no sound group mymod:nope"), "{e}");
3805 }
3806
3807 #[test]
3808 fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
3809 let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3810 let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3811 let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
3812 let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
3813 for tag in [
3814 "data/minecraft/tags/block/mineable/pickaxe.json",
3815 "data/mymod/tags/block/mineable/hammer.json",
3816 "data/minecraft/tags/block/needs_diamond_tool.json",
3818 "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
3819 "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
3820 "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
3821 ] {
3822 assert!(c.tags.get(tag).is_some_and(|ids| ids.contains_key("mymod:ore")), "{tag}: {:?}", c.tags);
3823 }
3824 let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
3826 let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
3827 assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains_key("mymod:ore"));
3828 assert_eq!(c.tags.len(), 2);
3829 let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
3831 let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3832 assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
3833 assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
3834 let volts = r#"{ kind: "ladder", id: "voltage" }"#;
3835 let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
3836 let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
3837 let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
3838 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();
3839 assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
3840 assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
3841 assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
3842 }
3843
3844 #[test]
3845 fn reports_errors_with_lines() {
3846 let root = project(&[(
3848 "content/bad.yblock",
3849 "{\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}",
3850 )]);
3851 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3852 for want in [
3853 "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
3854 "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
3855 "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
3856 "content/bad.yblock:10: bogus: unknown field `bogus`",
3857 ] {
3858 assert!(e.contains(want), "missing {want}\n{e}");
3859 }
3860 let _ = std::fs::remove_dir_all(root);
3861 let root = project(&[(
3863 "content/bad.yblock",
3864 "{\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}",
3865 )]);
3866 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3867 for want in [
3868 "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
3869 "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
3870 ] {
3871 assert!(e.contains(want), "missing {want}\n{e}");
3872 }
3873 let _ = std::fs::remove_dir_all(root);
3874 }
3875
3876 #[test]
3877 fn compiles_recipes() {
3878 let root = project(&[
3879 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
3880 (
3881 "content/recipes/ruby_block.yrecipe",
3882 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
3883 ),
3884 (
3885 "content/recipes/ruby.yrecipe",
3886 r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
3887 ),
3888 (
3889 "content/recipes/smelt_ruby.yrecipe",
3890 r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
3891 ),
3892 (
3893 "content/recipes/cut.yrecipe",
3894 r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
3895 ),
3896 ]);
3897 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3898 assert_eq!(c.recipes, 4);
3899 let src = c.source.clone().unwrap();
3900 for want in [
3901 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"#,
3902 r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
3903 r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
3904 r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
3905 r##"group: Some("rubies".into())"##,
3906 ] {
3907 assert!(src.contains(want), "missing {want}\n{src}");
3908 }
3909 let _ = std::fs::remove_dir_all(root);
3910 }
3911
3912 #[test]
3913 fn recipe_mistakes() {
3914 let bad = |recipe: &str| {
3915 let root = project(&[("content/r.yrecipe", recipe)]);
3916 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3917 let _ = std::fs::remove_dir_all(root);
3918 e
3919 };
3920 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
3921 assert!(e.contains("pattern uses 'B'"), "{e}");
3922 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
3923 assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
3924 let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
3925 assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
3926 let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
3927 assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
3928 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
3929 assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
3930 let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
3931 assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
3932 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
3933 assert!(e.contains("unknown field `pattern`"), "{e}");
3934 let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
3935 assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
3936 }
3937
3938 #[test]
3939 fn block_inventory_names_a_screen() {
3940 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
3941 let root = project(&[
3942 ("content/bench.yblock", block),
3943 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
3944 ]);
3945 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3946 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
3947 let _ = std::fs::remove_dir_all(root);
3948
3949 let root = project(&[("content/bench.yblock", block)]);
3950 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3951 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
3952 let _ = std::fs::remove_dir_all(root);
3953 }
3954
3955 #[test]
3956 fn compiles_a_book() {
3957 let book = r#"{
3958 "id": "guide",
3959 "name": "@Book.title",
3960 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
3961 "entries": [{
3962 "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
3963 "pages": [
3964 { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
3965 { "type": "spotlight", "item": "@item:mymod:ruby" },
3966 { "type": "entity", "entity": "@entity:minecraft:allay" }
3967 ]
3968 }]
3969 }"#;
3970 let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
3971 let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
3972 let mut refs = yres::Refs::default();
3973 refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
3974 let out = compile(&root, "mymod", &refs).unwrap();
3975 assert_eq!(out.books, 1);
3976 let src = out.source.unwrap();
3977 assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
3978 assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
3980 let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
3981 let text = yog_data::text::to_string(&data);
3982 assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
3983 assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
3984 let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
3985 assert_eq!(read.entries[0].category, "mymod:basics");
3986 assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
3987
3988 let bad = r#"{ "id": "x",
3990 "categories": [{ "id": "c", "name": "C" }],
3991 "entries": [{ "id": "e", "name": "E", "category": "c",
3992 "pages": [
3993 { "type": "spotlight", "item": "minecraft:stick" },
3994 { "type": "spotlight", "item": "@item:minecraft:grass" },
3995 { "type": "recipe", "recipe": "@recipe:mymod:nope" },
3996 { "type": "entity", "entity": "@entity:create:train" }
3997 ] }] }"#;
3998 let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3999 assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
4000 assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
4001 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}");
4002 assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
4003 assert!(e.contains("[external.create]"), "{e}");
4004
4005 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
4006 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4007 assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
4008 let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
4009 let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4010 assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
4011 }
4012
4013 #[test]
4014 fn books_use_the_mods_own_page_types() {
4015 let ty = r#"<Component name="PatternPage" book_page="pattern">
4016 <Property name="name" type="book_text"/>
4017 <Property name="patterns" type="data" default="[]"/>
4018 <BookHeader text="{x:Bind name}"/>
4019 <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
4020 </Component>"#;
4021 let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
4022 entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
4023 let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
4024 let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
4025 let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4026 assert_eq!(b.page_types.len(), 1, "the type goes with the book");
4027 let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
4028 assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
4029 assert!(b.i18n);
4030
4031 let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
4032 let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
4033 assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
4034 let missing = book(r#"{ type: "mymod:nope" }"#);
4035 let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4036 assert!(e.contains("no page type mymod:nope"), "{e}");
4037 }
4038
4039 #[test]
4040 fn tools_and_armor_of_the_games_materials_or_the_mods() {
4041 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
4042 let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
4043 let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
4044 level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
4045 let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
4046 enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
4047 let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
4048 let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
4049 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())];
4050 let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
4051 let src = c.source.unwrap();
4052 assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
4053 assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
4054 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}");
4055 assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
4056
4057 let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
4058 let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4059 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}");
4060 let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
4061 let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4062 assert!(e.contains("no armor material mymod:nope"), "{e}");
4063 }
4064
4065 #[test]
4066 fn data_naming_renamed_content_gets_each_versions_name() {
4067 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4068 let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
4069 let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
4070 let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
4071 assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
4072 let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
4073 assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
4074 let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
4075 let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
4076 assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
4077 }
4078
4079 #[test]
4080 fn ores_become_features_in_biomes_on_every_loader() {
4081 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4082 let block = format!(r#"{{ {model} }}"#);
4083 let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
4084 { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
4085 height: { min: -48, max: 16, shape: "triangle" } }"#;
4086 let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
4087 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}"));
4088 let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
4089 assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
4090 assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
4091 let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
4092 assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
4093 assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
4094 assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
4095 let src = c.source.unwrap();
4096 assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
4097 let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4098 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4099 assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
4100 let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4101 let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4102 assert!(e.contains("no block mymod:nope"), "{e}");
4103 }
4104
4105 #[test]
4106 fn a_mod_has_loot_tables_of_its_own() {
4107 let table = r#"{ kind: "loot_table", id: "random_gem", add: [{ item: "@item:minecraft:diamond", count: [1, 3] }] }"#;
4108 let c = compile(&project(&[("content/random_gem.ydef", table)]), "mymod", &yres::Refs::default()).unwrap();
4109 let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap());
4110 assert_eq!(get("data/mymod/loot_table/random_gem.json").expect("the table")["pools"], json!([]));
4111 let src = c.source.unwrap();
4112 assert!(src.contains(r#"registry.add_loot_modifier_data("mymod:random_gem""#), "{src}");
4113 let modifier = r#"{ kind: "loot_modifier", id: "more_gems", tables: ["@loot_table:minecraft:chests/simple_dungeon"], add: [{ item: "@item:minecraft:emerald", chance: 0.5 }] }"#;
4114 let c = compile(&project(&[("content/more_gems.ydef", modifier)]), "mymod", &yres::Refs::default()).unwrap();
4115 assert!(c.source.unwrap().contains(r#"registry.add_loot_modifier_data("mymod:more_gems""#));
4116 let missing = r#"{ kind: "loot_modifier", id: "m", tables: ["@loot_table:minecraft:chests/nope"], add: [] }"#;
4117 assert!(compile(&project(&[("content/m.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err().contains("chests/nope"));
4118 let bad = r#"{ kind: "loot_table", id: "minecraft:nope", add: [] }"#;
4119 assert!(compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err().contains("the mod's own"));
4120 }
4121
4122 #[test]
4123 fn a_block_says_how_its_item_stacks() {
4124 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4125 let block = format!(r#"{{ item: {{ stack_size: 16, fire_resistant: true, fuel_ticks: 300 }}, {model} }}"#);
4126 let plain = format!(r#"{{ {model} }}"#);
4127 let c = compile(&project(&[("content/log.yblock", &block), ("content/stone.yblock", &plain)]), "mymod", &yres::Refs::default()).unwrap();
4128 let src = c.source.unwrap();
4129 assert!(src.contains(r#"registry.register_item(::yog_api::ItemDef::new("mymod:log").max_stack(16).fire_resistant().fuel(300));"#), "{src}");
4130 assert!(!src.contains(r#"ItemDef::new("mymod:stone")"#), "no item form of its own: the host's default");
4131 }
4132
4133 #[test]
4134 fn blocks_and_items_join_their_tags() {
4135 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4136 let log = format!(r#"{{ tags: ["minecraft:logs", "edified_logs"], {model} }}"#);
4137 let staff = r#"{ tags: ["staves"], model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } } }"#;
4138 let c = compile(&project(&[("content/log.yblock", &log), ("content/staff.yitem", staff)]), "mymod", &yres::Refs::default()).unwrap();
4139 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"] {
4140 assert!(c.tags[t].contains_key("mymod:log"), "{t}");
4141 }
4142 assert!(c.tags["data/mymod/tags/item/staves.json"].contains_key("mymod:staff"));
4143 assert!(!c.tags.contains_key("data/mymod/tags/block/staves.json"), "an item has no block tag");
4144 }
4145
4146 #[test]
4147 fn trees_are_definitions() {
4148 let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4149 let block = format!(r#"{{ {model} }}"#);
4150 let tree = r#"{ kind: "tree", id: "glass_tree", min_clipped_height: 6,
4151 trunk: { blocks: [{ block: "@block:mymod:log", weight: 8 }, { block: "@block:minecraft:oak_log" }], shape: "fancy", base_height: 5, random_a: 5, random_b: 3 },
4152 leaves: { blocks: [{ block: "@block:mymod:leaves" }], shape: "fancy", radius: 1, offset: 5, height: 5 } }"#;
4153 let c = compile(&project(&[("content/log.yblock", &block), ("content/leaves.yblock", &block), ("content/glass_tree.ydef", tree)]), "mymod", &yres::Refs::default()).unwrap();
4154 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}"));
4155 let f = &get("data/mymod/worldgen/configured_feature/glass_tree.json")["config"];
4156 assert_eq!(f["trunk_placer"]["type"], "minecraft:fancy_trunk_placer");
4157 assert_eq!(f["trunk_provider"]["entries"][0]["weight"], 8);
4158 assert_eq!(f["foliage_provider"]["state"]["Name"], "mymod:leaves");
4159 assert_eq!((f["foliage_placer"]["height"].as_u64(), f["minimum_size"]["min_clipped_height"].as_u64()), (Some(5), Some(6)));
4160 let bad = r#"{ kind: "tree", id: "t", trunk: { blocks: [{ block: "@block:mymod:nope" }], shape: "straight", base_height: 4 },
4161 leaves: { blocks: [{ block: "@block:minecraft:oak_leaves" }], shape: "blob", radius: 2 } }"#;
4162 let e = compile(&project(&[("content/t.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4163 assert!(e.contains("no block mymod:nope"), "{e}");
4164 }
4165
4166 #[test]
4167 fn what_a_version_or_loader_hasnt_got_keeps_a_recipe_out_of_it() {
4168 let recipe = |key: &str| format!(r#"{{ type: "shaped", pattern: ["D"], key: {{ D: {key} }}, result: "@item:minecraft:stick" }}"#);
4169 let check = |key: &str| {
4170 let c = compile(&project(&[("content/r.yrecipe", &recipe(key))]), "mymod", &yres::Refs::default());
4171 c.map(|c| c.warnings.iter().filter(|w| w.contains("left out")).cloned().collect::<Vec<_>>().join("\n"))
4172 };
4173 let w = check(r#""@tag:item:minecraft:music_discs""#).unwrap();
4174 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}");
4175 assert_eq!(check(r#""@tag:item:yog:music_discs""#).unwrap(), "");
4176 assert_eq!(check(r#"["@tag:item:c:iron_ingots", "@item:minecraft:iron_ingot"]"#).unwrap(), "", "an alternative that's everywhere");
4177 let w = check(r#""@tag:item:c:iron_ingots""#).unwrap();
4178 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}");
4179 let e = check(r#""@tag:item:c:nope""#).unwrap_err();
4180 assert!(e.contains("no item tag c:nope in Minecraft or its loaders"), "{e}");
4181 let e = check(r#""@tag:item:yog:nope""#).unwrap_err();
4182 assert!(e.contains("no item tag yog:nope among Yog's common tags"), "{e}");
4183 assert!(check(r#""@tag:item:othermod:anything""#).is_ok(), "another mod's tags aren't known here");
4184 let block = r#"{ tags: ["yog:planks", "yog:nope"], model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } } }"#;
4185 let e = compile(&project(&[("content/p.yblock", block)]), "mymod", &yres::Refs::default()).unwrap_err();
4186 assert!(e.contains("tags[1]: no block tag yog:nope") && !e.contains("yog:planks"), "{e}");
4187 }
4188
4189 #[test]
4190 fn tags_and_damage_types_are_definitions() {
4191 let tag = r#"{ kind: "tag", of: "entity", id: "sticky_teleporters", values: ["@entity:minecraft:pig", "@tag:entity:c:bosses"],
4192 optional: ["@entity:create:seat"] }"#;
4193 let logs = r#"{ kind: "tag", of: "block", id: "minecraft:logs", values: ["@block:minecraft:stone"] }"#;
4194 let dmg = r#"{ kind: "damage_type", id: "overcast", scaling: "never", tags: ["minecraft:bypasses_armor", "bypasses_effects"] }"#;
4195 let c = compile(&project(&[("content/a.ydef", tag), ("content/b.ydef", logs), ("content/c.ydef", dmg)]), "mymod", &yres::Refs::default()).unwrap();
4196 let sticky = &c.tags["data/mymod/tags/entity_type/sticky_teleporters.json"];
4197 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");
4198 assert!(c.tags["data/minecraft/tags/block/logs.json"].contains_key("minecraft:stone"));
4199 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}"));
4200 let d = get("data/mymod/damage_type/overcast.json");
4201 assert_eq!((d["message_id"].as_str(), d["scaling"].as_str()), (Some("mymod.overcast"), Some("never")));
4202 for t in ["data/minecraft/tags/damage_type/bypasses_armor.json", "data/minecraft/tags/damage_type/bypasses_effects.json"] {
4203 assert!(c.tags[t].contains_key("mymod:overcast"), "{t}");
4204 }
4205 let bad = r#"{ kind: "tag", of: "entity", id: "x", values: ["@entity:minecraft:nope", "@block:minecraft:stone"] }"#;
4206 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4207 assert!(e.contains("nope") && e.contains("`@block:minecraft:stone` in a tag of entity"), "{e}");
4208 }
4209
4210 #[test]
4211 fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
4212 let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
4213 terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
4214 layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
4215 let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
4216 let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
4217 assert_eq!(path, "dimensions/mymod__flatlands.ydata");
4218 let v = yog_data::binary::decode(data).unwrap();
4219 assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
4220 assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
4221 let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
4222 assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
4223 let src = c.source.unwrap();
4224 assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
4225 let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
4226 let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4227 assert!(e.contains("no block minecraft:nope"), "{e}");
4228 }
4229
4230 #[test]
4231 fn content_for_other_mods_is_registered_only_with_them() {
4232 let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
4233 let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
4234 let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
4235 let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
4236 let src = c.source.unwrap();
4237 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
4238 assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
4239 }
4240
4241 #[test]
4242 fn books_lay_out_the_mods_own_recipe_kinds() {
4243 let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
4244 let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
4245 entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
4246 pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
4247 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
4248 let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
4249 <RecipeSlot slot="core" x="4" y="4" frame="true"/>
4250 <ItemSlot item="minecraft:beacon" x="40" y="4"/>
4251 <RecipeResult x="76" y="4" frame="true"/>
4252 </Component>"#;
4253 let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
4254 let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
4255 assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
4256 let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
4257 let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4258 assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
4259 let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
4261 <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
4262 let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4263 assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
4264 assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
4265 assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
4266 }
4267
4268 #[test]
4269 fn compiles_a_recipe_of_the_mods_own_kind() {
4270 let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
4271 let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
4272 values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
4273 let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
4274 let src = c.source.unwrap();
4275 assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
4276 assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
4277
4278 let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
4280 values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
4281 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4282 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)"] {
4283 assert!(e.contains(want), "{want}: {e}");
4284 }
4285 let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4286 let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
4287 assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
4288 let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
4289 let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4290 assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
4291 let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4293 let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
4294 assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
4295 }
4296
4297 #[test]
4298 fn compiles_an_item_with_resource_refs() {
4299 let root = project(&[(
4300 "content/items/ruby.yitem",
4301 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
4302 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
4303 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
4304 )]);
4305 let mut refs = yres::Refs::default();
4306 refs.texts.insert(
4307 "Items.ruby".into(),
4308 ("item.mymod.ruby".into(), "Ruby".into()),
4309 );
4310 refs.texts.insert(
4311 "Strings.ruby_tip".into(),
4312 ("mymod.ruby_tip".into(), "Shiny".into()),
4313 );
4314 refs.images
4315 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
4316 let c = compile(&root, "mymod", &refs).unwrap();
4317 assert_eq!(
4318 output(&c, "assets/mymod/models/item/ruby.json"),
4319 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
4320 );
4321 let src = c.source.clone().unwrap();
4322 assert!(src.contains(".max_stack(16)"), "{src}");
4323 assert!(
4324 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
4325 "{src}"
4326 );
4327 let mut outputs = vec![(
4329 "assets/mymod/lang/ru_ru.json".to_string(),
4330 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
4331 .as_bytes()
4332 .to_vec(),
4333 )];
4334 merge_outputs(&root, &mut outputs, c).unwrap();
4335 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
4336 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
4337 let _ = std::fs::remove_dir_all(root);
4338 }
4339
4340 #[test]
4341 fn namespace_needed_for_odd_mod_ids() {
4342 let root = project(&[(
4343 "content/x.yitem",
4344 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
4345 )]);
4346 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
4347 assert!(e.contains("give the namespace"), "{e}");
4348 let _ = std::fs::remove_dir_all(root);
4349 }
4350
4351 #[test]
4352 fn body_files_compile_and_register() {
4353 let model = r#"{ "texture": "misc/wings.png", "bones": [
4354 { "name": "wing", "pivot": [5, 0, 0], "cubes": [{ "origin": [-10, 0, 0], "size": [10, 20, 2], "uv": [22, 0] }] } ] }"#;
4355 let anim = r#"{ "spread": { "length": 0.5, "bones": { "wing": { "rotation": { "0": [0, 0, 0], "0.5": [0, 0, -90] } } } } }"#;
4356 let out = compile(&project(&[("content/wings.ymodel", model), ("content/wings.yanim", anim)]), "mymod", &yres::Refs::default()).unwrap();
4357 assert!(out.data_files.iter().any(|(p, _)| p == "body/models/mymod__wings.ydata"));
4358 let src = out.source.unwrap();
4359 assert!(src.contains("registry.register_body_model(\"mymod:wings\""), "{src}");
4360 assert!(src.contains("registry.register_body_anims(\"mymod:wings\""), "{src}");
4361 assert!(src.contains("pub const WINGS: &'static str = \"mymod:wings\";"), "{src}");
4362 let bad = r#"{ "texture": "x.png", "bones": [{ "name": "a", "parent": "b" }] }"#;
4363 let e = compile(&project(&[("content/bad.ymodel", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4364 assert!(e.contains("must come before it"), "{e}");
4365 }
4366
4367 #[test]
4368 fn entities_compile_register_and_name_their_models() {
4369 let model = r#"{ "texture": "entity/wisp.png", "bones": [{ "name": "body", "cubes": [{ "origin": [-2, 0, -2], "size": [4, 4, 4] }] }] }"#;
4370 let wisp = r#"{ "name": "Wisp", "kind": "mob", "width": 0.5, "height": 0.5, "model": "wisp", "goals": ["float", "wander"],
4371 "attributes": { "max_health": 6, "movement_speed": 0.3 } }"#;
4372 let out = compile(&project(&[("content/wisp.ymodel", model), ("content/wisp.yentity", wisp)]), "mymod", &yres::Refs::default()).unwrap();
4373 let src = out.source.as_ref().unwrap();
4374 assert!(src.contains("registry.register_entity_data(\"mymod:wisp\""), "{src}");
4375 assert!(src.contains("pub const WISP: &'static str = \"mymod:wisp\";"), "{src}");
4376 assert!(out.data_files.iter().any(|(p, _)| p == "entities/mymod__wisp.ydata"));
4377 let (_, data) = out.data_files.iter().find(|(p, _)| p == "entities/mymod__wisp.ydata").unwrap();
4378 let def: yog_registry::EntityDef = yog_data::read_bytes(data).unwrap();
4379 assert_eq!((def.kind, def.model.as_deref()), (yog_registry::EntityKind::Mob, Some("mymod:wisp")));
4380 let missing = r#"{ "kind": "object", "width": 1, "height": 1, "model": "nope" }"#;
4381 let e = compile(&project(&[("content/thing.yentity", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4382 assert!(e.contains("model `mymod:nope`: no such file"), "{e}");
4383 }
4384
4385}