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