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