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