1mod import;
23pub mod json;
24
25pub use import::import_command;
26
27use crate::yres;
28use json::{J, V, num_value};
29use serde_json::{Map, Value, json};
30use std::collections::{BTreeMap, BTreeSet, HashMap};
31use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34pub const CONTENT_DIR: &str = "content";
36pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
38
39pub type Output = (String, Vec<u8>);
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Kind {
45 Block,
47 Item,
49 Recipe,
51}
52
53impl Kind {
54 pub fn of(path: &Path) -> Option<Kind> {
55 match path.extension()?.to_str()? {
56 "yblock" => Some(Kind::Block),
57 "yitem" => Some(Kind::Item),
58 "yrecipe" => Some(Kind::Recipe),
59 _ => None,
60 }
61 }
62
63 fn word(self) -> &'static str {
64 match self {
65 Kind::Block => "block",
66 Kind::Item => "item",
67 Kind::Recipe => "recipe",
68 }
69 }
70}
71
72#[derive(Debug, Default)]
74pub struct Compiled {
75 pub outputs: Vec<Output>,
77 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
80 lang_aliases: Vec<(String, String)>,
82 tags: BTreeMap<String, BTreeSet<String>>,
84 pub source: Option<String>,
86 pub warnings: Vec<String>,
87 pub blocks: usize,
88 pub items: usize,
89 pub recipes: usize,
90}
91
92pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
95pub const DISPLAY_SLOTS: [&str; 8] = [
96 "thirdperson_righthand",
97 "thirdperson_lefthand",
98 "firstperson_righthand",
99 "firstperson_lefthand",
100 "gui",
101 "head",
102 "ground",
103 "fixed",
104];
105pub const SOUNDS: [&str; 10] = [
107 "stone",
108 "wood",
109 "grass",
110 "gravel",
111 "sand",
112 "snow",
113 "metal",
114 "glass",
115 "wool",
116 "nether_brick",
117];
118pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
119pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
120pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
121pub const RECIPE_TYPES: [&str; 3] = ["shaped", "shapeless", "smelting"];
122
123pub struct Template {
125 pub name: &'static str,
126 pub parent: &'static str,
128 pub slots: &'static [&'static str],
130 pub sprite: bool,
132 pub has_particle: bool,
134}
135
136pub const TEMPLATES: &[Template] = &[
137 Template {
138 name: "cube_all",
139 parent: "minecraft:block/cube_all",
140 slots: &["all"],
141 sprite: false,
142 has_particle: true,
143 },
144 Template {
145 name: "cube_column",
146 parent: "minecraft:block/cube_column",
147 slots: &["end", "side"],
148 sprite: false,
149 has_particle: true,
150 },
151 Template {
152 name: "cube_bottom_top",
153 parent: "minecraft:block/cube_bottom_top",
154 slots: &["top", "bottom", "side"],
155 sprite: false,
156 has_particle: true,
157 },
158 Template {
159 name: "cube",
160 parent: "minecraft:block/cube",
161 slots: &["down", "up", "north", "south", "west", "east"],
162 sprite: false,
163 has_particle: false,
164 },
165 Template {
166 name: "orientable",
167 parent: "minecraft:block/orientable",
168 slots: &["front", "side", "top"],
169 sprite: false,
170 has_particle: true,
171 },
172 Template {
173 name: "cross",
174 parent: "minecraft:block/cross",
175 slots: &["cross"],
176 sprite: false,
177 has_particle: true,
178 },
179 Template {
180 name: "generated",
181 parent: "minecraft:item/generated",
182 slots: &["layer0"],
183 sprite: true,
184 has_particle: true,
185 },
186 Template {
187 name: "handheld",
188 parent: "minecraft:item/handheld",
189 slots: &["layer0"],
190 sprite: true,
191 has_particle: true,
192 },
193];
194
195pub fn template(name: &str) -> Option<&'static Template> {
196 TEMPLATES.iter().find(|t| t.name == name)
197}
198
199pub fn valid_namespace(s: &str) -> bool {
201 !s.is_empty()
202 && s.bytes().all(|b| {
203 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
204 })
205}
206
207pub fn valid_path(s: &str) -> bool {
209 !s.is_empty()
210 && !s.starts_with('/')
211 && !s.ends_with('/')
212 && s.bytes().all(|b| {
213 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
214 })
215}
216
217pub fn valid_id(s: &str) -> bool {
219 match s.split_once(':') {
220 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
221 None => valid_path(s),
222 }
223}
224
225pub fn find(root: &Path) -> Vec<PathBuf> {
229 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
230 let Ok(entries) = std::fs::read_dir(dir) else {
231 return;
232 };
233 for entry in entries.flatten() {
234 let path = entry.path();
235 if path.is_dir() {
236 walk(&path, out);
237 } else if Kind::of(&path).is_some() {
238 out.push(path);
239 }
240 }
241 }
242 let mut out = Vec::new();
243 walk(&root.join(CONTENT_DIR), &mut out);
244 out.sort();
245 out
246}
247
248fn display(root: &Path, p: &Path) -> String {
249 p.strip_prefix(root)
250 .unwrap_or(p)
251 .to_string_lossy()
252 .replace('\\', "/")
253}
254
255#[derive(Debug, Clone)]
259enum Text {
260 Key(String),
262 Literal(String),
263}
264
265#[derive(Debug, Clone, Default)]
266struct BlockProps {
267 hardness: Option<f64>,
268 resistance: Option<f64>,
269 sound: Option<String>,
270 light: u8,
271 tool: Option<String>,
272 tool_level: Option<String>,
273 requires_tool: bool,
274 no_collision: bool,
275 slipperiness: Option<f64>,
276 shape: Option<[f64; 6]>,
278 connect_groups: Vec<String>,
279 connects: bool,
280 inventory: Option<String>,
282 redstone: bool,
284}
285
286#[derive(Debug, Clone, Default)]
287struct ItemProps {
288 stack_size: Option<u8>,
289 durability: u32,
290 fire_resistant: bool,
291 fuel_ticks: u32,
292 food: Option<(u32, f64, bool)>,
294}
295
296#[derive(Debug, Clone)]
298struct Entry {
299 kind: Kind,
300 id: String,
301 file: String,
302 auto: bool,
303 block: BlockProps,
304 item: ItemProps,
305}
306
307struct Ctx<'a> {
309 root: &'a Path,
310 refs: &'a yres::Refs,
311 file: String,
312 ns: String,
313 errors: &'a mut Vec<String>,
314 warnings: &'a mut Vec<String>,
315 textures: &'a mut BTreeMap<String, PathBuf>,
317 inventories: &'a mut Vec<(String, usize, String)>,
320}
321
322impl Ctx<'_> {
323 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
324 self.errors.push(format!("{}:{line}: {msg}", self.file));
325 }
326
327 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
328 self.warnings.push(format!("{}:{line}: {msg}", self.file));
329 }
330
331 fn unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
333 for (k, v) in obj.members() {
334 if !known.contains(&k.as_str()) {
335 self.err(
336 v.line,
337 format!(
338 "unknown key {k:?} in {what} (expected one of: {})",
339 known.join(", ")
340 ),
341 );
342 }
343 }
344 }
345
346 fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
347 match j.num() {
348 Some(n) if (min..=max).contains(&n) => Some(n),
349 Some(n) => {
350 self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
351 None
352 }
353 None => {
354 self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
355 None
356 }
357 }
358 }
359
360 fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
361 let b = j.bool();
362 if b.is_none() {
363 self.err(j.line, format!("{what} must be true or false"));
364 }
365 b
366 }
367
368 fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
369 let s = j.str();
370 if s.is_none() {
371 self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
372 }
373 s
374 }
375
376 fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
377 let s = self.string(j, what)?;
378 if allowed.contains(&s) {
379 Some(s.to_string())
380 } else {
381 self.err(
382 j.line,
383 format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
384 );
385 None
386 }
387 }
388
389 fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
390 let items = j.items();
391 if !j.is_arr() || items.len() != N {
392 self.err(j.line, format!("{what} must be an array of {N} numbers"));
393 return None;
394 }
395 let mut out = [0.0; N];
396 for (i, item) in items.iter().enumerate() {
397 out[i] = self.number(item, what, min, max)?;
398 }
399 Some(out)
400 }
401
402 fn text(&mut self, j: &J, what: &str) -> Option<Text> {
405 let s = self.string(j, what)?;
406 match s.strip_prefix('@') {
407 Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
408 Some(rest) => match self.refs.texts.get(rest) {
409 Some((key, _)) => Some(Text::Key(key.clone())),
410 None => {
411 self.err(
412 j.line,
413 format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
414 );
415 None
416 }
417 },
418 None => Some(Text::Literal(s.to_string())),
419 }
420 }
421
422 fn texture(&mut self, j: &J, what: &str) -> Option<String> {
426 let s = self.string(j, what)?.trim().to_string();
427 if let Some(slot) = s.strip_prefix('#') {
428 if slot.is_empty() {
429 self.err(j.line, format!("{what}: `#` needs a slot name"));
430 return None;
431 }
432 return Some(s);
433 }
434 if let Some(rest) = s.strip_prefix('@') {
435 return match self.refs.images.get(rest) {
436 Some(id) => Some(id.clone()),
437 None => {
438 self.err(
439 j.line,
440 format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
441 );
442 None
443 }
444 };
445 }
446 if let Some(stem) = s.strip_suffix(".png") {
447 let rel = stem.trim_start_matches("./").trim_start_matches('/');
448 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
449 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
450 self.err(
451 j.line,
452 format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
453 );
454 return None;
455 }
456 let file = self
457 .root
458 .join(yres::RESOURCES_DIR)
459 .join(format!("{rel}.png"));
460 if !file.is_file() {
461 self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
462 return None;
463 }
464 let target = format!("assets/{}/textures/{rel}.png", self.ns);
465 if let Some(other) = self.textures.insert(target.clone(), file.clone())
466 && other != file
467 {
468 self.err(
469 j.line,
470 format!(
471 "{what}: {target} also comes from {}",
472 display(self.root, &other)
473 ),
474 );
475 }
476 let meta = self
477 .root
478 .join(yres::RESOURCES_DIR)
479 .join(format!("{rel}.png.mcmeta"));
480 if meta.is_file() {
481 self.textures.insert(format!("{target}.mcmeta"), meta);
482 }
483 return Some(format!("{}:{rel}", self.ns));
484 }
485 if !valid_id(&s) {
486 self.err(
487 j.line,
488 format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
489 );
490 return None;
491 }
492 let id = if s.contains(':') {
493 s
494 } else {
495 format!("minecraft:{s}")
496 };
497 let (ns, path) = id.split_once(':').unwrap_or_default();
498 if ns == self.ns {
499 let file = self
500 .root
501 .join("assets")
502 .join(ns)
503 .join("textures")
504 .join(format!("{path}.png"));
505 let bundled = self
506 .textures
507 .contains_key(&format!("assets/{ns}/textures/{path}.png"))
508 || self.refs.images.values().any(|v| *v == id);
509 if !file.is_file() && !bundled {
510 self.warn(
511 j.line,
512 format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
513 );
514 }
515 }
516 Some(id)
517 }
518}
519
520struct Model {
522 json: Value,
523 bounds: Option<[f64; 6]>,
525}
526
527fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
530 if !j.is_obj() {
531 cx.err(j.line, format!("{what} must be an object"));
532 return None;
533 }
534 cx.unknown_keys(
535 j,
536 what,
537 &[
538 "parent",
539 "textures",
540 "elements",
541 "display",
542 "ambient_occlusion",
543 "gui_light",
544 ],
545 );
546 let mut out = Map::new();
547 let mut tpl: Option<&Template> = None;
548 let mut raw_parent = false;
549 if let Some(p) = j.get("parent") {
550 let name = cx.string(p, &format!("{what}.parent"))?;
551 if let Some(t) = template(name) {
552 if t.sprite && !item {
553 cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
554 }
555 out.insert("parent".into(), Value::String(t.parent.into()));
556 tpl = Some(t);
557 } else if name.contains(':') && valid_id(name) {
558 out.insert("parent".into(), Value::String(name.into()));
559 raw_parent = true;
560 } else {
561 let names: Vec<&str> = TEMPLATES
562 .iter()
563 .filter(|t| item || !t.sprite)
564 .map(|t| t.name)
565 .collect();
566 cx.err(
567 p.line,
568 format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
569 );
570 return None;
571 }
572 }
573
574 let mut slots: Vec<String> = Vec::new();
576 let mut textures = Map::new();
577 if let Some(t) = j.get("textures") {
578 if !t.is_obj() {
579 cx.err(
580 t.line,
581 format!("{what}.textures must be an object of slot → texture"),
582 );
583 }
584 for (slot, v) in t.members() {
585 if slot.is_empty() || slot.starts_with('#') {
586 cx.err(
587 v.line,
588 format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
589 );
590 continue;
591 }
592 if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
593 textures.insert(slot.clone(), Value::String(tex));
594 slots.push(slot.clone());
595 }
596 }
597 }
598 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
599 if let Some(t) = tpl {
600 if t.sprite {
601 if !defined("layer0", &textures) {
602 cx.err(
603 j.line,
604 format!("{what}: parent {:?} needs textures.layer0", t.name),
605 );
606 }
607 for slot in &slots {
608 let n = slot
609 .strip_prefix("layer")
610 .and_then(|n| n.parse::<usize>().ok());
611 match n {
612 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
613 Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
614 None if slot == "particle" => {}
615 None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
616 }
617 }
618 } else {
619 for slot in t.slots {
620 if !defined(slot, &textures) {
621 cx.err(
622 j.line,
623 format!("{what}: parent {:?} needs textures.{slot}", t.name),
624 );
625 }
626 }
627 }
628 }
629
630 let mut bounds: Option<[f64; 6]> = None;
632 if let Some(e) = j.get("elements") {
633 if let Some(t) = tpl {
634 cx.err(
635 e.line,
636 format!(
637 "{what}: elements replace the shape of parent {:?} — remove one of them",
638 t.name
639 ),
640 );
641 }
642 if !e.is_arr() {
643 cx.err(e.line, format!("{what}.elements must be an array"));
644 }
645 let mut elements = Vec::new();
646 for (i, el) in e.items().iter().enumerate() {
647 if let Some((json, from, to)) = element(
648 cx,
649 el,
650 &format!("{what}.elements[{i}]"),
651 &textures,
652 raw_parent,
653 ) {
654 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
655 for a in 0..3 {
656 b[a] = b[a].min(from[a].min(to[a]));
657 b[a + 3] = b[a + 3].max(from[a].max(to[a]));
658 }
659 elements.push(json);
660 }
661 }
662 out.insert("elements".into(), Value::Array(elements));
663 } else if tpl.is_none() && !raw_parent {
664 cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
665 }
666
667 if !textures.contains_key("particle")
671 && !tpl.is_some_and(|t| t.has_particle)
672 && (!raw_parent || j.get("elements").is_some())
673 && let Some(first) = slots.first()
674 {
675 let v = textures[first].clone();
676 textures.insert("particle".into(), v);
677 }
678 if !textures.is_empty() {
679 out.insert("textures".into(), Value::Object(textures));
680 }
681
682 if let Some(d) = j.get("display") {
683 if !d.is_obj() {
684 cx.err(d.line, format!("{what}.display must be an object"));
685 }
686 let mut display = Map::new();
687 for (slot, t) in d.members() {
688 let w = format!("{what}.display.{slot}");
689 if !DISPLAY_SLOTS.contains(&slot.as_str()) {
690 cx.err(
691 t.line,
692 format!(
693 "{w}: unknown display position (expected one of: {})",
694 DISPLAY_SLOTS.join(", ")
695 ),
696 );
697 continue;
698 }
699 cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
700 let mut m = Map::new();
701 for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
702 if let Some(v) = t.get(key)
703 && let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
704 {
705 m.insert(
706 key.into(),
707 Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
708 );
709 }
710 }
711 display.insert(slot.clone(), Value::Object(m));
712 }
713 out.insert("display".into(), Value::Object(display));
714 }
715 if let Some(ao) = j.get("ambient_occlusion")
716 && let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
717 {
718 out.insert("ambientocclusion".into(), Value::Bool(b));
719 }
720 if let Some(gl) = j.get("gui_light")
721 && let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
722 {
723 out.insert("gui_light".into(), Value::String(s));
724 }
725 Some(Model {
726 json: Value::Object(out),
727 bounds,
728 })
729}
730
731fn element(
733 cx: &mut Ctx,
734 j: &J,
735 what: &str,
736 textures: &Map<String, Value>,
737 raw_parent: bool,
738) -> Option<(Value, [f64; 3], [f64; 3])> {
739 if !j.is_obj() {
740 cx.err(j.line, format!("{what} must be an object"));
741 return None;
742 }
743 cx.unknown_keys(
744 j,
745 what,
746 &["name", "from", "to", "rotation", "shade", "faces"],
747 );
748 if let Some(n) = j.get("name") {
749 cx.string(n, &format!("{what}.name"));
750 }
751 let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
752 cx.err(j.line, format!("{what} needs `from` and `to`"));
753 return None;
754 };
755 let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
756 let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
757 let mut out = Map::new();
758 out.insert(
759 "from".into(),
760 Value::Array(from.iter().map(|n| num_value(*n)).collect()),
761 );
762 out.insert(
763 "to".into(),
764 Value::Array(to.iter().map(|n| num_value(*n)).collect()),
765 );
766 if let Some(r) = j.get("rotation") {
767 let w = format!("{what}.rotation");
768 cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
769 let axis = r
770 .get("axis")
771 .and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
772 let angle = r
773 .get("angle")
774 .and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
775 if let Some(a) = angle
776 && (a / 22.5).fract() != 0.0
777 {
778 cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
779 }
780 let origin = match r.get("origin") {
781 Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
782 None => Some([8.0, 8.0, 8.0]),
783 };
784 if axis.is_none() || angle.is_none() {
785 if r.get("axis").is_none() || r.get("angle").is_none() {
786 cx.err(r.line, format!("{w} needs `axis` and `angle`"));
787 }
788 } else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
789 let mut rot = Map::new();
790 rot.insert(
791 "origin".into(),
792 Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
793 );
794 rot.insert("axis".into(), Value::String(axis));
795 rot.insert("angle".into(), num_value(angle));
796 if let Some(rs) = r.get("rescale")
797 && let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
798 && b
799 {
800 rot.insert("rescale".into(), Value::Bool(true));
801 }
802 out.insert("rotation".into(), Value::Object(rot));
803 }
804 }
805 if let Some(s) = j.get("shade")
806 && let Some(b) = cx.boolean(s, &format!("{what}.shade"))
807 && !b
808 {
809 out.insert("shade".into(), Value::Bool(false));
810 }
811 let mut faces = Map::new();
812 match j.get("faces") {
813 None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
814 Some(f) => {
815 if !f.is_obj() {
816 cx.err(
817 f.line,
818 format!("{what}.faces must be an object of direction → face"),
819 );
820 }
821 for (dir, face) in f.members() {
822 let w = format!("{what}.faces.{dir}");
823 if !DIRECTIONS.contains(&dir.as_str()) {
824 cx.err(
825 face.line,
826 format!(
827 "{w}: unknown face (expected one of: {})",
828 DIRECTIONS.join(", ")
829 ),
830 );
831 continue;
832 }
833 cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
834 let mut fo = Map::new();
835 match face.get("texture") {
836 None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
837 Some(t) => {
838 if let Some(s) = cx.string(t, &format!("{w}.texture")) {
839 let slot = s.strip_prefix('#').unwrap_or(s);
840 if !textures.contains_key(slot) && !raw_parent {
841 cx.err(
842 t.line,
843 format!("{w}.texture `#{slot}`: no such slot in textures"),
844 );
845 }
846 fo.insert("texture".into(), Value::String(format!("#{slot}")));
847 }
848 }
849 }
850 if let Some(uv) = face.get("uv")
851 && let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
852 {
853 fo.insert(
854 "uv".into(),
855 Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
856 );
857 }
858 if let Some(r) = face.get("rotation") {
859 match r.num() {
860 Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
861 if n != 0.0 {
862 fo.insert("rotation".into(), num_value(n));
863 }
864 }
865 _ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
866 }
867 }
868 if let Some(c) = face.get("cullface")
869 && let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
870 {
871 fo.insert("cullface".into(), Value::String(c));
872 }
873 if let Some(t) = face.get("tint")
874 && let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
875 && n >= 0.0
876 {
877 fo.insert("tintindex".into(), num_value(n.floor()));
878 }
879 faces.insert(dir.clone(), Value::Object(fo));
880 }
881 }
882 }
883 out.insert("faces".into(), Value::Object(faces));
884 Some((Value::Object(out), from, to))
885}
886
887fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
889 let (raw, line) = match j.get("id") {
890 Some(v) => (cx.string(v, "id")?.to_string(), v.line),
891 None => (
892 path.file_stem()
893 .unwrap_or_default()
894 .to_string_lossy()
895 .into_owned(),
896 1,
897 ),
898 };
899 let (ns, p) = match raw.split_once(':') {
900 Some((ns, p)) => (ns.to_string(), p.to_string()),
901 None => match mod_ns {
902 Some(ns) => (ns.to_string(), raw.clone()),
903 None => {
904 cx.err(
905 line,
906 "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
907 );
908 return None;
909 }
910 },
911 };
912 if !valid_namespace(&ns) || !valid_path(&p) {
913 cx.err(
914 line,
915 format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
916 );
917 return None;
918 }
919 Some((ns, p))
920}
921
922fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
924 let mut v = Map::new();
925 v.insert("model".into(), Value::String(model.into()));
926 if x != 0.0 {
927 v.insert("x".into(), num_value(x));
928 }
929 if y != 0.0 {
930 v.insert("y".into(), num_value(y));
931 }
932 if uvlock {
933 v.insert("uvlock".into(), Value::Bool(true));
934 }
935 if let Some(w) = weight
936 && w != 1.0
937 {
938 v.insert("weight".into(), num_value(w));
939 }
940 Value::Object(v)
941}
942
943fn pretty(v: &Value) -> Vec<u8> {
944 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
945 s.push('\n');
946 s.into_bytes()
947}
948
949#[allow(clippy::too_many_arguments)]
951fn compile_file(
952 cx: &mut Ctx,
953 j: &J,
954 kind: Kind,
955 path: &Path,
956 mod_ns: Option<&str>,
957 out: &mut Compiled,
958 generated: &mut BTreeMap<String, String>,
959) -> Option<Entry> {
960 if !j.is_obj() {
961 cx.err(
962 j.line,
963 format!("a .y{} file is one JSON object", kind.word()),
964 );
965 return None;
966 }
967 let common = [
968 "$schema", "format", "id", "name", "tooltip", "register", "model",
969 ];
970 let specific: &[&str] = match kind {
971 Kind::Block => &["block", "item_model", "states"],
972 Kind::Item => &["item"],
973 Kind::Recipe => unreachable!("a recipe isn't a block or an item"),
974 };
975 let known: Vec<&str> = common.iter().chain(specific).copied().collect();
976 cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
977 if let Some(f) = j.get("format")
978 && f.num() != Some(1.0)
979 {
980 cx.err(f.line, "format must be 1 (this yog understands format 1)");
981 }
982 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
983 cx.ns = ns.clone();
984 let id = format!("{ns}:{p}");
985
986 let auto = match j.get("register") {
987 None => true,
988 Some(r) => cx
989 .one_of(r, "register", &["auto", "manual"])
990 .is_none_or(|s| s == "auto"),
991 };
992
993 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
995 for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
996 let Some(v) = j.get(field) else { continue };
997 match cx.text(v, field)? {
998 Text::Key(from) if from == target => {}
999 Text::Key(from) => out.lang_aliases.push((from, target)),
1000 Text::Literal(s) => {
1001 out.lang
1002 .entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
1003 .or_default()
1004 .insert(target, s);
1005 }
1006 }
1007 }
1008
1009 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
1010 let full = format!("assets/{ns}/{rel}");
1011 if cx.root.join(&full).is_file() {
1012 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
1013 }
1014 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
1015 cx.err(1, format!("{full} is also written by {other}"));
1016 }
1017 out.outputs.push((full, pretty(value)));
1018 };
1019
1020 let mut entry = Entry {
1021 kind,
1022 id: id.clone(),
1023 file: cx.file.clone(),
1024 auto,
1025 block: BlockProps::default(),
1026 item: ItemProps::default(),
1027 };
1028
1029 match kind {
1030 Kind::Recipe => unreachable!("a recipe isn't a block or an item"),
1031 Kind::Item => {
1032 if let Some(it) = j.get("item") {
1033 cx.unknown_keys(
1034 it,
1035 "item",
1036 &[
1037 "stack_size",
1038 "durability",
1039 "fire_resistant",
1040 "fuel_ticks",
1041 "food",
1042 ],
1043 );
1044 let props = &mut entry.item;
1045 if let Some(v) = it.get("stack_size") {
1046 props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
1047 }
1048 if let Some(v) = it.get("durability") {
1049 props.durability =
1050 cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
1051 }
1052 if let Some(v) = it.get("fire_resistant") {
1053 props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
1054 }
1055 if let Some(v) = it.get("fuel_ticks") {
1056 props.fuel_ticks =
1057 cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
1058 }
1059 if let Some(f) = it.get("food") {
1060 cx.unknown_keys(
1061 f,
1062 "item.food",
1063 &["nutrition", "saturation", "always_edible"],
1064 );
1065 let n = f
1066 .get("nutrition")
1067 .and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
1068 let s = f
1069 .get("saturation")
1070 .and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
1071 let a = f
1072 .get("always_edible")
1073 .and_then(|v| cx.boolean(v, "item.food.always_edible"));
1074 if n.is_none() {
1075 cx.err(f.line, "item.food needs nutrition");
1076 }
1077 props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
1078 }
1079 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
1080 cx.warn(
1081 it.line,
1082 "item.stack_size is ignored: an item with durability stacks to 1",
1083 );
1084 }
1085 }
1086 let Some(mj) = j.get("model") else {
1087 cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
1088 return Some(entry);
1089 };
1090 let m = model(cx, mj, "model", true)?;
1091 emit(cx, format!("models/item/{p}.json"), &m.json);
1092 emit(
1093 cx,
1094 format!("items/{p}.json"),
1095 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1096 );
1097 }
1098 Kind::Block => {
1099 let mut render_layer: Option<String> = None;
1100 let mut drops = Value::Null;
1101 let mut shape_auto = true;
1102 if let Some(b) = j.get("block") {
1103 cx.unknown_keys(
1104 b,
1105 "block",
1106 &[
1107 "hardness",
1108 "resistance",
1109 "sound",
1110 "light",
1111 "tool",
1112 "tool_level",
1113 "requires_tool",
1114 "collision",
1115 "slipperiness",
1116 "shape",
1117 "render_layer",
1118 "drops",
1119 "connect_groups",
1120 "inventory",
1121 "redstone",
1122 ],
1123 );
1124 let props = &mut entry.block;
1125 if let Some(v) = b.get("hardness") {
1126 props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
1127 }
1128 if let Some(v) = b.get("resistance") {
1129 props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
1130 }
1131 if let Some(v) = b.get("sound") {
1132 props.sound = cx.one_of(v, "block.sound", &SOUNDS);
1133 }
1134 if let Some(v) = b.get("light") {
1135 props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
1136 }
1137 if let Some(v) = b.get("tool") {
1138 props.tool = cx.one_of(v, "block.tool", &TOOLS);
1139 }
1140 if let Some(v) = b.get("tool_level") {
1141 props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
1142 }
1143 if let Some(v) = b.get("requires_tool") {
1144 props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
1145 }
1146 if let Some(v) = b.get("collision") {
1147 props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
1148 }
1149 if let Some(v) = b.get("slipperiness") {
1150 props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
1151 }
1152 if let Some(v) = b.get("shape") {
1153 match &v.v {
1154 V::Str(s) if s == "auto" => {}
1155 V::Str(s) if s == "full" => shape_auto = false,
1156 V::Arr(_) => {
1157 shape_auto = false;
1158 props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
1159 }
1160 _ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
1161 }
1162 }
1163 if let Some(v) = b.get("render_layer") {
1164 render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
1165 }
1166 if let Some(v) = b.get("redstone") {
1167 props.redstone = cx.boolean(v, "block.redstone").unwrap_or(false);
1168 }
1169 if let Some(v) = b.get("inventory")
1170 && let Some(id) = cx.string(v, "block.inventory")
1171 {
1172 if valid_id(id) && id.contains(':') {
1173 props.inventory = Some(id.to_string());
1174 let at = (cx.file.clone(), v.line, id.to_string());
1175 cx.inventories.push(at);
1176 } else {
1177 cx.err(v.line, format!("block.inventory `{id}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
1178 }
1179 }
1180 if let Some(v) = b.get("connect_groups") {
1181 for g in v.items() {
1182 if let Some(s) = cx.string(g, "block.connect_groups[]") {
1183 props.connect_groups.push(s.to_string());
1184 }
1185 }
1186 if !v.is_arr() {
1187 cx.err(
1188 v.line,
1189 "block.connect_groups must be an array of group names",
1190 );
1191 }
1192 }
1193 if props.tool_level.is_some() && props.tool.is_none() {
1194 cx.warn(
1195 b.line,
1196 "block.tool_level without block.tool — any tool of that level mines it",
1197 );
1198 }
1199 if let Some(d) = b.get("drops") {
1200 drops = drops_table(cx, d, &id);
1201 }
1202 }
1203 if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
1204 drops = drops_table_self(&id);
1205 }
1206
1207 let Some(mj) = j.get("model") else {
1208 cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
1209 return Some(entry);
1210 };
1211 let mut m = model(cx, mj, "model", false)?;
1212 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1213 o.insert(
1215 "render_type".into(),
1216 Value::String(format!("minecraft:{layer}")),
1217 );
1218 }
1219 if shape_auto {
1220 entry.block.shape = m
1221 .bounds
1222 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1223 .map(|b| {
1224 [
1225 b[0].max(0.0),
1226 b[1].max(0.0),
1227 b[2].max(0.0),
1228 b[3].min(16.0),
1229 b[4].min(16.0),
1230 b[5].min(16.0),
1231 ]
1232 });
1233 }
1234 let main = format!("{ns}:block/{p}");
1235 emit(cx, format!("models/block/{p}.json"), &m.json);
1236
1237 let mut rotations: Vec<Value> = Vec::new();
1239 let mut arm: Option<(String, bool)> = None;
1240 if let Some(s) = j.get("states") {
1241 cx.unknown_keys(s, "states", &["rotations", "connect"]);
1242 if let Some(r) = s.get("rotations") {
1243 if !r.is_arr() || r.items().is_empty() {
1244 cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
1245 }
1246 for (i, rot) in r.items().iter().enumerate() {
1247 let w = format!("states.rotations[{i}]");
1248 cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
1249 let mut angle = |k: &str| -> f64 {
1250 match rot.get(k).map(|v| (v, v.num())) {
1251 None => 0.0,
1252 Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
1253 Some((v, _)) => {
1254 cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
1255 0.0
1256 }
1257 }
1258 };
1259 let (x, y) = (angle("x"), angle("y"));
1260 let weight = rot
1261 .get("weight")
1262 .and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
1263 let uvlock = rot
1264 .get("uvlock")
1265 .and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
1266 .unwrap_or(false);
1267 rotations.push(variant(&main, x, y, uvlock, weight));
1268 }
1269 }
1270 if let Some(c) = s.get("connect") {
1271 cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
1272 let uvlock = c
1273 .get("uvlock")
1274 .and_then(|v| cx.boolean(v, "states.connect.uvlock"))
1275 .unwrap_or(false);
1276 match c.get("arm") {
1277 None => cx.err(
1278 c.line,
1279 "states.connect needs `arm` — the model of the arm towards north",
1280 ),
1281 Some(a) => {
1282 if let Some(am) = model(cx, a, "states.connect.arm", false) {
1283 let mut json = am.json;
1284 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
1285 {
1286 o.insert(
1287 "render_type".into(),
1288 Value::String(format!("minecraft:{layer}")),
1289 );
1290 }
1291 emit(cx, format!("models/block/{p}_arm.json"), &json);
1292 arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
1293 }
1294 }
1295 }
1296 entry.block.connects = true;
1297 if entry.block.connect_groups.is_empty() {
1298 entry.block.connect_groups.push(id.clone());
1299 }
1300 }
1301 }
1302 let base: Value = match rotations.len() {
1303 0 => variant(&main, 0.0, 0.0, false, None),
1304 1 => rotations.remove(0),
1305 _ => Value::Array(rotations),
1306 };
1307 let blockstate = match &arm {
1308 None => json!({ "variants": { "": base } }),
1309 Some((arm, uvlock)) => {
1310 let mut parts = vec![json!({ "apply": base })];
1311 for (dir, x, y) in [
1312 ("north", 0.0, 0.0),
1313 ("east", 0.0, 90.0),
1314 ("south", 0.0, 180.0),
1315 ("west", 0.0, 270.0),
1316 ("up", 270.0, 0.0),
1317 ("down", 90.0, 0.0),
1318 ] {
1319 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1320 }
1321 json!({ "multipart": parts })
1322 }
1323 };
1324 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1325
1326 let item_model = match j.get("item_model") {
1328 None => Some(json!({ "parent": main })),
1329 Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
1330 };
1331 if let Some(im) = item_model {
1332 emit(cx, format!("models/item/{p}.json"), &im);
1333 }
1334 emit(
1335 cx,
1336 format!("items/{p}.json"),
1337 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1338 );
1339
1340 if !drops.is_null() {
1341 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1342 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1343 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1344 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
1345 }
1346 out.outputs.push((full, pretty(&drops)));
1347 }
1348 if let Some(tool) = &entry.block.tool {
1349 out.tags
1350 .entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
1351 .or_default()
1352 .insert(id.clone());
1353 }
1354 if let Some(level) = &entry.block.tool_level {
1355 out.tags
1356 .entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
1357 .or_default()
1358 .insert(id.clone());
1359 }
1360 }
1361 }
1362 Some(entry)
1363}
1364
1365fn drops_table_self(id: &str) -> Value {
1366 json!({
1367 "type": "minecraft:block",
1368 "pools": [{
1369 "rolls": 1,
1370 "entries": [{ "type": "minecraft:item", "name": id }],
1371 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1372 }]
1373 })
1374}
1375
1376fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
1378 match &d.v {
1379 V::Str(s) if s == "self" => drops_table_self(id),
1380 V::Str(s) if s == "none" => Value::Null,
1381 V::Obj(_) => {
1382 cx.unknown_keys(d, "block.drops", &["item", "count"]);
1383 let item = match d.get("item") {
1384 Some(i) => match cx.string(i, "block.drops.item") {
1385 Some(s) if valid_id(s) => {
1386 if s.contains(':') {
1387 s.to_string()
1388 } else {
1389 format!("minecraft:{s}")
1390 }
1391 }
1392 Some(s) => {
1393 cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
1394 return Value::Null;
1395 }
1396 None => return Value::Null,
1397 },
1398 None => id.to_string(),
1399 };
1400 let mut entry = json!({ "type": "minecraft:item", "name": item });
1401 if let Some(c) = d.get("count") {
1402 let count = match &c.v {
1403 V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
1404 V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
1405 json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
1406 }),
1407 _ => {
1408 cx.err(c.line, "block.drops.count must be a number or [min, max]");
1409 None
1410 }
1411 };
1412 if let Some(count) = count {
1413 entry["functions"] =
1414 json!([{ "function": "minecraft:set_count", "count": count }]);
1415 }
1416 }
1417 json!({
1418 "type": "minecraft:block",
1419 "pools": [{
1420 "rolls": 1,
1421 "entries": [entry],
1422 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1423 }]
1424 })
1425 }
1426 _ => {
1427 cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
1428 Value::Null
1429 }
1430 }
1431}
1432
1433#[derive(Debug, Clone)]
1439enum RecipeKind {
1440 Shaped { pattern: Vec<String>, key: Vec<(char, String)> },
1442 Shapeless(Vec<String>),
1444 Smelting { input: String, experience: f64, cook_time: u32 },
1446}
1447
1448#[derive(Debug, Clone)]
1450struct Recipe {
1451 id: String,
1452 file: String,
1453 kind: RecipeKind,
1454 result: String,
1455 count: u32,
1456 items: Vec<(usize, String)>,
1459}
1460
1461impl Recipe {
1462 fn registration(&self) -> String {
1464 let (id, result, count) = (&self.id, &self.result, self.count);
1465 match &self.kind {
1466 RecipeKind::Shaped { pattern, key } => {
1467 let mut b = format!("::yog_api::ShapedRecipe::new({id:?}, {result:?}, {count})");
1468 for row in pattern {
1469 let _ = write!(b, ".row({row:?})");
1470 }
1471 for (c, item) in key {
1472 let _ = write!(b, ".key({c:?}, {item:?})");
1473 }
1474 format!("registry.add_shaped_recipe({b});")
1475 }
1476 RecipeKind::Shapeless(ingredients) => {
1477 let mut b = format!("::yog_api::ShapelessRecipe::new({id:?}, {result:?}, {count})");
1478 for item in ingredients {
1479 let _ = write!(b, ".ingredient({item:?})");
1480 }
1481 format!("registry.add_shapeless_recipe({b});")
1482 }
1483 RecipeKind::Smelting { input, experience, cook_time } => format!(
1484 "registry.add_furnace_recipe(::yog_api::FurnaceRecipe::new({id:?}, {input:?}, {result:?}, {count}).experience({}_f32).cook_time({cook_time}));",
1485 float(*experience)
1486 ),
1487 }
1488 }
1489}
1490
1491fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1494 let s = cx.string(j, what)?.trim().to_string();
1495 let (tag, id) = match s.strip_prefix('#') {
1496 Some(rest) => (true, rest),
1497 None => (false, s.as_str()),
1498 };
1499 if tag && !tags {
1500 cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1501 return None;
1502 }
1503 let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1504 if !ok {
1505 let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1506 cx.err(
1507 j.line,
1508 format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1509 );
1510 return None;
1511 }
1512 if !tag {
1513 items.push((j.line, id.to_string()));
1514 }
1515 Some(s)
1516}
1517
1518fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1520 if !j.is_obj() {
1521 cx.err(j.line, "a .yrecipe file is one JSON object");
1522 return None;
1523 }
1524 cx.unknown_keys(
1525 j,
1526 "a .yrecipe",
1527 &[
1528 "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1529 "result", "count", "experience", "cook_time",
1530 ],
1531 );
1532 if let Some(f) = j.get("format")
1533 && f.num() != Some(1.0)
1534 {
1535 cx.err(f.line, "format must be 1 (this yog understands format 1)");
1536 }
1537 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1538 let Some(t) = j.get("type") else {
1539 cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1540 return None;
1541 };
1542 let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1543 for (key, owner) in [
1545 ("pattern", "shaped"),
1546 ("key", "shaped"),
1547 ("ingredients", "shapeless"),
1548 ("ingredient", "smelting"),
1549 ("experience", "smelting"),
1550 ("cook_time", "smelting"),
1551 ] {
1552 if owner != ty
1553 && let Some(v) = j.get(key)
1554 {
1555 cx.err(v.line, format!("{key} is for {owner} recipes, this one is {ty}"));
1556 }
1557 }
1558 let mut items = Vec::new();
1559 let result = match j.get("result") {
1560 Some(v) => recipe_item(cx, v, "result", false, &mut items),
1561 None => {
1562 cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1563 None
1564 }
1565 };
1566 let count = match j.get("count") {
1567 Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1568 None => Some(1),
1569 };
1570 let kind = match ty.as_str() {
1571 "shaped" => {
1572 let Some(pv) = j.get("pattern") else {
1573 cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1574 return None;
1575 };
1576 let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1577 let width = rows.first().map_or(0, |r| r.chars().count());
1578 if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1579 cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1580 return None;
1581 }
1582 let mut key = Vec::new();
1583 match j.get("key") {
1584 Some(kv) if kv.is_obj() => {
1585 for (k, v) in kv.members() {
1586 let mut chars = k.chars();
1587 match (chars.next(), chars.next()) {
1588 (Some(c), None) if c != ' ' => {
1589 if let Some(item) = recipe_item(cx, v, &format!("key {k:?}"), true, &mut items) {
1590 if !rows.iter().any(|r| r.contains(c)) {
1591 cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1592 }
1593 key.push((c, item));
1594 }
1595 }
1596 _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1597 }
1598 }
1599 }
1600 Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1601 None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1602 }
1603 for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1604 if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1605 cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1606 return None;
1607 }
1608 }
1609 RecipeKind::Shaped { pattern: rows, key }
1610 }
1611 "shapeless" => {
1612 let Some(iv) = j.get("ingredients") else {
1613 cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1614 return None;
1615 };
1616 let list: Vec<String> = iv
1617 .items()
1618 .iter()
1619 .filter_map(|i| recipe_item(cx, i, "ingredients[]", true, &mut items))
1620 .collect();
1621 if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1622 cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1623 return None;
1624 }
1625 RecipeKind::Shapeless(list)
1626 }
1627 _ => {
1628 let Some(iv) = j.get("ingredient") else {
1629 cx.err(j.line, "a smelting recipe needs an \"ingredient\": the item or tag it smelts");
1630 return None;
1631 };
1632 let input = recipe_item(cx, iv, "ingredient", true, &mut items)?;
1633 let experience = match j.get("experience") {
1634 Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1635 None => 0.1,
1636 };
1637 let cook_time = match j.get("cook_time") {
1638 Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)? as u32,
1639 None => 200,
1640 };
1641 RecipeKind::Smelting { input, experience, cook_time }
1642 }
1643 };
1644 Some(Recipe {
1645 id: format!("{ns}:{p}"),
1646 file: cx.file.clone(),
1647 kind,
1648 result: result?,
1649 count: count?,
1650 items,
1651 })
1652}
1653
1654fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1657 let mut out = BTreeSet::new();
1658 let Ok(entries) = std::fs::read_dir(dir) else {
1659 return out;
1660 };
1661 for e in entries.flatten() {
1662 let path = e.path();
1663 if path.is_dir() {
1664 out.extend(inventory_screens(&path));
1665 continue;
1666 }
1667 if path.extension().is_none_or(|x| x != "yui") {
1668 continue;
1669 }
1670 let Ok(text) = std::fs::read_to_string(&path) else { continue };
1671 if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1672 continue;
1673 }
1674 if let Some(screen) = text.find("<Screen")
1675 && let Some(at) = text[screen..].find(" id=\"")
1676 {
1677 let rest = &text[screen + at + 5..];
1678 if let Some(end) = rest.find('"') {
1679 out.insert(rest[..end].to_string());
1680 }
1681 }
1682 }
1683 out
1684}
1685
1686pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1687 let mut out = Compiled::default();
1688 let files = find(root);
1689 if files.is_empty() {
1690 return Ok(out);
1691 }
1692 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1693 let mut errors: Vec<String> = Vec::new();
1694 let mut warnings: Vec<String> = Vec::new();
1695 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1696 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1697 let mut entries: Vec<Entry> = Vec::new();
1698 let mut ids: HashMap<String, String> = HashMap::new();
1699 let mut recipes: Vec<Recipe> = Vec::new();
1700 let mut recipe_ids: HashMap<String, String> = HashMap::new();
1701 let mut inventories: Vec<(String, usize, String)> = Vec::new();
1702 for path in &files {
1703 let shown = display(root, path);
1704 let Some(kind) = Kind::of(path) else { continue };
1705 let text = match std::fs::read_to_string(path) {
1706 Ok(t) => t,
1707 Err(e) => {
1708 errors.push(format!("{shown}: {e}"));
1709 continue;
1710 }
1711 };
1712 let j = match json::parse(&text) {
1713 Ok(j) => j,
1714 Err(e) => {
1715 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
1716 continue;
1717 }
1718 };
1719 let mut cx = Ctx {
1720 root,
1721 refs,
1722 file: shown.clone(),
1723 ns: mod_ns.unwrap_or("minecraft").to_string(),
1724 errors: &mut errors,
1725 warnings: &mut warnings,
1726 textures: &mut textures,
1727 inventories: &mut inventories,
1728 };
1729 if kind == Kind::Recipe {
1730 if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
1731 if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
1732 errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
1733 }
1734 recipes.push(r);
1735 }
1736 continue;
1737 }
1738 if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
1739 {
1740 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
1741 errors.push(format!(
1742 "{shown}: id {} is also defined by {other}",
1743 entry.id
1744 ));
1745 }
1746 entries.push(entry);
1747 }
1748 }
1749 if let Some(ns) = mod_ns {
1752 for r in &recipes {
1753 for (line, item) in &r.items {
1754 if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
1755 errors.push(format!(
1756 "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
1757 r.file
1758 ));
1759 }
1760 }
1761 }
1762 let screens = inventory_screens(&root.join("src"));
1763 for (file, line, id) in &inventories {
1764 if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
1765 errors.push(format!(
1766 "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
1767 ));
1768 }
1769 }
1770 }
1771 for (target, source) in &textures {
1772 match std::fs::read(source) {
1773 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
1774 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
1775 }
1776 }
1777 out.warnings = warnings;
1778 if !errors.is_empty() {
1779 return Err(format!(
1780 "invalid blocks/items in content/:\n {}",
1781 errors.join("\n ")
1782 ));
1783 }
1784 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
1785 out.items = entries.len() - out.blocks;
1786 out.recipes = recipes.len();
1787 if (entries.iter().any(|e| e.auto) || !recipes.is_empty())
1788 && !mentions_content_macro(&root.join("src"))
1789 {
1790 out.warnings.push(format!(
1791 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
1792 CONTENT_DIR
1793 ));
1794 }
1795 out.source = Some(rust_source(&entries, &recipes));
1796 Ok(out)
1797}
1798
1799fn mentions_content_macro(dir: &Path) -> bool {
1801 let Ok(entries) = std::fs::read_dir(dir) else {
1802 return false;
1803 };
1804 entries.flatten().any(|e| {
1805 let p = e.path();
1806 if p.is_dir() {
1807 mentions_content_macro(&p)
1808 } else {
1809 p.extension().is_some_and(|x| x == "rs")
1810 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
1811 }
1812 })
1813}
1814
1815fn rust_ident(path: &str) -> String {
1817 let mut s: String = path
1818 .chars()
1819 .map(|c| {
1820 if c.is_ascii_alphanumeric() {
1821 c.to_ascii_lowercase()
1822 } else {
1823 '_'
1824 }
1825 })
1826 .collect();
1827 if s.starts_with(|c: char| c.is_ascii_digit()) {
1828 s.insert(0, '_');
1829 }
1830 s
1831}
1832
1833fn float(n: f64) -> String {
1834 let s = format!("{n:?}");
1835 if s.contains('.') || s.contains('e') {
1836 s
1837 } else {
1838 format!("{s}.0")
1839 }
1840}
1841
1842fn rust_source(entries: &[Entry], recipes: &[Recipe]) -> String {
1843 let mut code = String::from(
1844 "// <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",
1845 );
1846 for (kind, set, def) in [
1847 (Kind::Block, "Blocks", "BlockDef"),
1848 (Kind::Item, "Items", "ItemDef"),
1849 ] {
1850 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
1851 let _ = writeln!(
1852 code,
1853 "\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} {{",
1854 if kind == Kind::Block {
1855 "blocks"
1856 } else {
1857 "items"
1858 }
1859 );
1860 let mut used: HashMap<String, usize> = HashMap::new();
1861 for e in mine {
1862 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
1863 let mut ident = rust_ident(path);
1864 let n = used.entry(ident.clone()).or_insert(0);
1865 *n += 1;
1866 if *n > 1 {
1867 let _ = write!(ident, "_{n}");
1868 }
1869 let upper = ident.to_ascii_uppercase();
1870 let ident = if is_keyword(&ident) {
1871 format!("r#{ident}")
1872 } else {
1873 ident
1874 };
1875 let _ = writeln!(
1876 code,
1877 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
1878 e.id, e.file, e.id
1879 );
1880 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
1881 match kind {
1882 Kind::Recipe => unreachable!("a recipe isn't a block or an item"),
1883 Kind::Block => {
1884 let p = &e.block;
1885 if p.hardness.is_some() || p.resistance.is_some() {
1886 let _ = write!(
1887 b,
1888 "\n .strength({}, {})",
1889 float(p.hardness.unwrap_or(1.5)),
1890 float(p.resistance.unwrap_or(6.0))
1891 );
1892 }
1893 if let Some(s) = &p.sound {
1894 let _ = write!(b, "\n .sound({s:?})");
1895 }
1896 if p.light > 0 {
1897 let _ = write!(b, "\n .light_level({})", p.light);
1898 }
1899 if p.requires_tool {
1900 b.push_str("\n .requires_tool()");
1901 }
1902 if p.no_collision {
1903 b.push_str("\n .no_collision()");
1904 }
1905 if let Some(s) = p.slipperiness {
1906 let _ = write!(b, "\n .slipperiness({})", float(s));
1907 }
1908 if let Some(s) = p.shape {
1909 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
1910 let _ = write!(b, "\n .shape({})", args.join(", "));
1911 }
1912 if p.connects {
1913 b.push_str("\n .connects_to_neighbors()");
1914 }
1915 if !p.connect_groups.is_empty() {
1916 let groups: Vec<String> =
1917 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
1918 let _ =
1919 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
1920 }
1921 if let Some(inv) = &p.inventory {
1922 let _ = write!(b, "\n .inventory({inv:?})");
1923 }
1924 if p.redstone {
1925 b.push_str("\n .redstone_source()");
1926 }
1927 }
1928 Kind::Item => {
1929 let p = &e.item;
1930 if let Some(s) = p.stack_size {
1931 let _ = write!(b, "\n .max_stack({s})");
1932 }
1933 if p.durability > 0 {
1934 let _ = write!(b, "\n .max_damage({})", p.durability);
1935 }
1936 if p.fire_resistant {
1937 b.push_str("\n .fire_resistant()");
1938 }
1939 if p.fuel_ticks > 0 {
1940 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
1941 }
1942 if let Some((n, s, always)) = p.food {
1943 let _ = write!(
1944 b,
1945 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
1946 float(s),
1947 if always { ".can_always_eat()" } else { "" }
1948 );
1949 }
1950 }
1951 }
1952 let _ = writeln!(
1953 code,
1954 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
1955 e.file
1956 );
1957 }
1958 code.push_str("}\n");
1959 }
1960
1961 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");
1962 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
1963 for e in entries {
1964 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
1965 let mut ident = rust_ident(path);
1966 let n = used.entry((e.kind, ident.clone())).or_insert(0);
1967 *n += 1;
1968 if *n > 1 {
1969 let _ = write!(ident, "_{n}");
1970 }
1971 let ident = if is_keyword(&ident) {
1972 format!("r#{ident}")
1973 } else {
1974 ident
1975 };
1976 if !e.auto {
1977 continue;
1978 }
1979 let _ = match e.kind {
1980 Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
1981 Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
1982 Kind::Recipe => unreachable!("a recipe isn't a block or an item"),
1983 };
1984 }
1985 for r in recipes {
1986 let _ = writeln!(code, " {}", r.registration());
1987 }
1988 code.push_str("}\n");
1989 if entries.iter().any(|e| e.auto) || !recipes.is_empty() {
1990 code.push_str(
1991 "\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",
1992 );
1993 }
1994 code
1995}
1996
1997fn is_keyword(s: &str) -> bool {
1998 const KW: &[&str] = &[
1999 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2000 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2001 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2002 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2003 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2004 ];
2005 KW.contains(&s)
2006}
2007
2008pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2011 let out = root.join(GENERATED_FILE);
2012 let Some(code) = &compiled.source else {
2013 let _ = std::fs::remove_file(&out);
2014 return Ok(());
2015 };
2016 crate::write_if_changed(&out, code)?;
2017 Ok(())
2018}
2019
2020#[derive(Default)]
2023pub struct OutputSet {
2024 items: Vec<Option<Output>>,
2025 at: HashMap<String, usize>,
2026}
2027
2028impl OutputSet {
2029 pub fn get(&self, path: &str) -> Option<&[u8]> {
2030 let i = *self.at.get(path)?;
2031 self.items[i].as_ref().map(|(_, b)| b.as_slice())
2032 }
2033
2034 pub fn contains(&self, path: &str) -> bool {
2035 self.at.contains_key(path)
2036 }
2037
2038 pub fn paths(&self) -> impl Iterator<Item = &String> {
2039 self.at.keys()
2040 }
2041
2042 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2044 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2045 self.items[i] = None;
2046 }
2047 self.items.push(Some((path, bytes)));
2048 }
2049
2050 pub fn into_vec(self) -> Vec<Output> {
2051 self.items.into_iter().flatten().collect()
2052 }
2053}
2054
2055impl From<Vec<Output>> for OutputSet {
2056 fn from(outputs: Vec<Output>) -> Self {
2057 let mut set = OutputSet::default();
2058 for (path, bytes) in outputs {
2059 set.put(path, bytes);
2060 }
2061 set
2062 }
2063}
2064
2065fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2068 let read;
2069 let bytes = match outputs.get(path) {
2070 Some(b) => b,
2071 None => match std::fs::read(root.join(path)) {
2072 Ok(b) => {
2073 read = b;
2074 &read
2075 }
2076 Err(_) => return Ok(Map::new()),
2077 },
2078 };
2079 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2080}
2081
2082pub fn merge_outputs(
2086 root: &Path,
2087 outputs: &mut Vec<Output>,
2088 compiled: Compiled,
2089) -> Result<(), String> {
2090 let mut set = OutputSet::from(std::mem::take(outputs));
2091 let mut errors = Vec::new();
2092 if !compiled.lang_aliases.is_empty() {
2095 let mut lang_files: BTreeSet<String> =
2096 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2097 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2098 for ns in dirs.flatten() {
2099 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2100 for f in files.flatten() {
2101 if let Ok(rel) = f.path().strip_prefix(root) {
2102 let rel = rel.to_string_lossy().replace('\\', "/");
2103 if is_lang_path(&rel) {
2104 lang_files.insert(rel);
2105 }
2106 }
2107 }
2108 }
2109 }
2110 }
2111 for path in lang_files {
2112 let mut map = match json_output(&set, root, &path) {
2113 Ok(m) => m,
2114 Err(e) => {
2115 errors.push(e);
2116 continue;
2117 }
2118 };
2119 let mut changed = false;
2120 for (from, to) in &compiled.lang_aliases {
2121 if let Some(text) = map.get(from).cloned()
2122 && !map.contains_key(to)
2123 {
2124 map.insert(to.clone(), text);
2125 changed = true;
2126 }
2127 }
2128 if changed {
2129 set.put(path, pretty(&Value::Object(map)));
2130 }
2131 }
2132 }
2133 for ((ns, locale), entries) in &compiled.lang {
2134 let path = format!("assets/{ns}/lang/{locale}.json");
2135 let mut map = match json_output(&set, root, &path) {
2136 Ok(m) => m,
2137 Err(e) => {
2138 errors.push(e);
2139 continue;
2140 }
2141 };
2142 for (k, v) in entries {
2143 map.entry(k.clone())
2144 .or_insert_with(|| Value::String(v.clone()));
2145 }
2146 set.put(path, pretty(&Value::Object(map)));
2147 }
2148 for (path, ids) in &compiled.tags {
2149 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2152 let path = if !set.contains(path) && root.join(&plural).is_file() {
2153 plural
2154 } else {
2155 path.clone()
2156 };
2157 let mut map = match json_output(&set, root, &path) {
2158 Ok(m) => m,
2159 Err(e) => {
2160 errors.push(e);
2161 continue;
2162 }
2163 };
2164 let values = map
2165 .entry("values")
2166 .or_insert_with(|| Value::Array(Vec::new()));
2167 if let Value::Array(list) = values {
2168 for id in ids {
2169 if !list.iter().any(|v| v.as_str() == Some(id)) {
2170 list.push(Value::String(id.clone()));
2171 }
2172 }
2173 }
2174 map.entry("replace").or_insert(Value::Bool(false));
2175 set.put(path, pretty(&Value::Object(map)));
2176 }
2177 for (path, bytes) in compiled.outputs {
2178 set.put(path, bytes);
2179 }
2180 *outputs = set.into_vec();
2181 if errors.is_empty() {
2182 Ok(())
2183 } else {
2184 Err(format!(
2185 "merging blocks/items into the mod's files:\n {}",
2186 errors.join("\n ")
2187 ))
2188 }
2189}
2190
2191fn is_lang_path(p: &str) -> bool {
2192 let parts: Vec<&str> = p.split('/').collect();
2193 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2194}
2195
2196#[cfg(test)]
2197mod tests {
2198 use super::*;
2199
2200 fn project(files: &[(&str, &str)]) -> PathBuf {
2201 use std::sync::atomic::{AtomicUsize, Ordering};
2202 static N: AtomicUsize = AtomicUsize::new(0);
2203 let dir = std::env::temp_dir().join(format!(
2204 "yog-content-test-{}-{}",
2205 std::process::id(),
2206 N.fetch_add(1, Ordering::SeqCst)
2207 ));
2208 let _ = std::fs::remove_dir_all(&dir);
2209 for (path, text) in files {
2210 let p = dir.join(path);
2211 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2212 std::fs::write(p, text).unwrap();
2213 }
2214 dir
2215 }
2216
2217 fn output(c: &Compiled, path: &str) -> Value {
2218 let (_, b) = c
2219 .outputs
2220 .iter()
2221 .find(|(p, _)| p == path)
2222 .unwrap_or_else(|| {
2223 panic!(
2224 "no {path} in {:?}",
2225 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2226 )
2227 });
2228 serde_json::from_slice(b).unwrap()
2229 }
2230
2231 #[test]
2232 fn compiles_a_cube_block() {
2233 let root = project(&[
2234 ("resources/block/ruby_ore.png", "png"),
2235 (
2236 "content/blocks/ruby_ore.yblock",
2237 r#"{
2238 "name": "Ruby Ore",
2239 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2240 "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2241 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2242}"#,
2243 ),
2244 ]);
2245 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2246 assert_eq!(c.blocks, 1);
2247 assert_eq!(
2248 output(&c, "assets/mymod/models/block/ruby_ore.json"),
2249 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2250 );
2251 assert_eq!(
2252 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2253 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2254 );
2255 assert_eq!(
2256 output(&c, "assets/mymod/models/item/ruby_ore.json"),
2257 json!({ "parent": "mymod:block/ruby_ore" })
2258 );
2259 assert_eq!(
2260 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2261 json!("mymod:item/ruby_ore")
2262 );
2263 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2264 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2265 assert_eq!(
2266 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2267 json!(3)
2268 );
2269 assert!(
2270 c.outputs
2271 .iter()
2272 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2273 );
2274 let src = c.source.as_ref().unwrap();
2275 assert!(
2276 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2277 "{src}"
2278 );
2279 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2280 assert!(src.contains(".requires_tool()"), "{src}");
2281 assert!(
2282 src.contains("registry.register_block(Blocks::ruby_ore());"),
2283 "{src}"
2284 );
2285
2286 let mut outputs = Vec::new();
2287 merge_outputs(&root, &mut outputs, c).unwrap();
2288 let find = |p: &str| -> Value {
2289 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2290 };
2291 assert_eq!(
2292 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2293 json!("Ruby Ore")
2294 );
2295 assert_eq!(
2296 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2297 json!(["mymod:ruby_ore"])
2298 );
2299 assert_eq!(
2300 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2301 json!(["mymod:ruby_ore"])
2302 );
2303 let _ = std::fs::remove_dir_all(root);
2304 }
2305
2306 #[test]
2307 fn raw_parent_with_elements_gets_a_particle() {
2308 let root = project(&[(
2309 "content/cable.yblock",
2310 r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2311 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2312 )]);
2313 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2314 let m = output(&c, "assets/mymod/models/block/cable.json");
2315 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2316 let _ = std::fs::remove_dir_all(root);
2317 }
2318
2319 #[test]
2320 fn compiles_elements_shape_and_connect() {
2321 let root = project(&[(
2322 "content/pipe.yblock",
2323 r##"{
2324 "block": { "render_layer": "cutout" },
2325 "model": {
2326 "textures": { "0": "minecraft:block/stone" },
2327 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2328 "rotation": { "axis": "y", "angle": 22.5 },
2329 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2330 },
2331 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2332 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2333}"##,
2334 )]);
2335 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2336 let m = output(&c, "assets/mymod/models/block/pipe.json");
2337 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2338 assert_eq!(m["render_type"], json!("minecraft:cutout"));
2339 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2340 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2341 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2342 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2343 let src = c.source.unwrap();
2344 assert!(
2345 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2346 "{src}"
2347 );
2348 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2349 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2350 let _ = std::fs::remove_dir_all(root);
2351 }
2352
2353 #[test]
2354 fn reports_errors_with_lines() {
2355 let root = project(&[(
2356 "content/bad.yblock",
2357 "{\n \"model\": {\n \"textures\": { \"0\": \"minecraft:block/stone\" },\n \"elements\": [ {\n \"from\": [0, 0, 0], \"to\": [16, 16, 16],\n \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n \"faces\": { \"north\": { \"texture\": \"#1\" } }\n } ]\n },\n \"bogus\": 1\n}",
2358 )]);
2359 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2360 assert!(
2361 e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2362 "{e}"
2363 );
2364 assert!(
2365 e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2366 "{e}"
2367 );
2368 assert!(
2369 e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2370 "{e}"
2371 );
2372 assert!(
2373 e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2374 "{e}"
2375 );
2376 let _ = std::fs::remove_dir_all(root);
2377 }
2378
2379 #[test]
2380 fn compiles_recipes() {
2381 let root = project(&[
2382 ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2383 (
2384 "content/recipes/ruby_block.yrecipe",
2385 r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2386 ),
2387 (
2388 "content/recipes/ruby.yrecipe",
2389 r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2390 ),
2391 (
2392 "content/recipes/smelt_ruby.yrecipe",
2393 r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7 }"##,
2394 ),
2395 ]);
2396 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2397 assert_eq!(c.recipes, 3);
2398 let src = c.source.clone().unwrap();
2399 for want in [
2400 r#"registry.add_shaped_recipe(::yog_api::ShapedRecipe::new("mymod:ruby_block", "minecraft:emerald_block", 1).row("RR").row("RR").key('R', "mymod:ruby"));"#,
2401 r##"registry.add_shapeless_recipe(::yog_api::ShapelessRecipe::new("mymod:ruby", "mymod:ruby", 2).ingredient("minecraft:emerald").ingredient("#c:dusts"));"##,
2402 r##"::yog_api::FurnaceRecipe::new("mymod:smelt_ruby", "#c:ores", "mymod:ruby", 1).experience(0.7_f32).cook_time(200)"##,
2403 ] {
2404 assert!(src.contains(want), "missing {want}\n{src}");
2405 }
2406 let _ = std::fs::remove_dir_all(root);
2407 }
2408
2409 #[test]
2410 fn recipe_mistakes() {
2411 let bad = |recipe: &str| {
2412 let root = project(&[("content/r.yrecipe", recipe)]);
2413 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2414 let _ = std::fs::remove_dir_all(root);
2415 e
2416 };
2417 let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2418 assert!(e.contains("pattern uses 'B'"), "{e}");
2419 let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2420 assert!(e.contains("write `minecraft:stick`"), "{e}");
2421 let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2422 assert!(e.contains("must be an item, not a tag"), "{e}");
2423 let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2424 assert!(e.contains("no block or item mymod:nothing"), "{e}");
2425 let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2426 assert!(e.contains("pattern is for shaped recipes"), "{e}");
2427 }
2428
2429 #[test]
2430 fn block_inventory_names_a_screen() {
2431 let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2432 let root = project(&[
2433 ("content/bench.yblock", block),
2434 ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2435 ]);
2436 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2437 assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2438 let _ = std::fs::remove_dir_all(root);
2439
2440 let root = project(&[("content/bench.yblock", block)]);
2441 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2442 assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2443 let _ = std::fs::remove_dir_all(root);
2444 }
2445
2446 #[test]
2447 fn compiles_an_item_with_resource_refs() {
2448 let root = project(&[(
2449 "content/items/ruby.yitem",
2450 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2451 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2452 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2453 )]);
2454 let mut refs = yres::Refs::default();
2455 refs.texts.insert(
2456 "Items.ruby".into(),
2457 ("item.mymod.ruby".into(), "Ruby".into()),
2458 );
2459 refs.texts.insert(
2460 "Strings.ruby_tip".into(),
2461 ("mymod.ruby_tip".into(), "Shiny".into()),
2462 );
2463 refs.images
2464 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2465 let c = compile(&root, "mymod", &refs).unwrap();
2466 assert_eq!(
2467 output(&c, "assets/mymod/models/item/ruby.json"),
2468 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2469 );
2470 let src = c.source.clone().unwrap();
2471 assert!(src.contains(".max_stack(16)"), "{src}");
2472 assert!(
2473 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2474 "{src}"
2475 );
2476 let mut outputs = vec![(
2478 "assets/mymod/lang/ru_ru.json".to_string(),
2479 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2480 .as_bytes()
2481 .to_vec(),
2482 )];
2483 merge_outputs(&root, &mut outputs, c).unwrap();
2484 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2485 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2486 let _ = std::fs::remove_dir_all(root);
2487 }
2488
2489 #[test]
2490 fn namespace_needed_for_odd_mod_ids() {
2491 let root = project(&[(
2492 "content/x.yitem",
2493 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2494 )]);
2495 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2496 assert!(e.contains("give the namespace"), "{e}");
2497 let _ = std::fs::remove_dir_all(root);
2498 }
2499}