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