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