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