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