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