1mod import;
21pub mod json;
22
23pub use import::import_command;
24
25use crate::yres;
26use json::{J, V, num_value};
27use serde_json::{Map, Value, json};
28use std::collections::{BTreeMap, BTreeSet, HashMap};
29use std::fmt::Write as _;
30use std::path::{Path, PathBuf};
31
32pub const CONTENT_DIR: &str = "content";
34pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
36
37pub type Output = (String, Vec<u8>);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum Kind {
43 Block,
45 Item,
47}
48
49impl Kind {
50 pub fn of(path: &Path) -> Option<Kind> {
51 match path.extension()?.to_str()? {
52 "yblock" => Some(Kind::Block),
53 "yitem" => Some(Kind::Item),
54 _ => None,
55 }
56 }
57
58 fn word(self) -> &'static str {
59 match self {
60 Kind::Block => "block",
61 Kind::Item => "item",
62 }
63 }
64}
65
66#[derive(Debug, Default)]
68pub struct Compiled {
69 pub outputs: Vec<Output>,
71 lang: BTreeMap<(String, String), BTreeMap<String, String>>,
74 lang_aliases: Vec<(String, String)>,
76 tags: BTreeMap<String, BTreeSet<String>>,
78 pub source: Option<String>,
80 pub warnings: Vec<String>,
81 pub blocks: usize,
82 pub items: usize,
83}
84
85pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
88pub const DISPLAY_SLOTS: [&str; 8] = [
89 "thirdperson_righthand",
90 "thirdperson_lefthand",
91 "firstperson_righthand",
92 "firstperson_lefthand",
93 "gui",
94 "head",
95 "ground",
96 "fixed",
97];
98pub const SOUNDS: [&str; 10] = [
100 "stone",
101 "wood",
102 "grass",
103 "gravel",
104 "sand",
105 "snow",
106 "metal",
107 "glass",
108 "wool",
109 "nether_brick",
110];
111pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
112pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
113pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
114
115pub struct Template {
117 pub name: &'static str,
118 pub parent: &'static str,
120 pub slots: &'static [&'static str],
122 pub sprite: bool,
124 pub has_particle: bool,
126}
127
128pub const TEMPLATES: &[Template] = &[
129 Template {
130 name: "cube_all",
131 parent: "minecraft:block/cube_all",
132 slots: &["all"],
133 sprite: false,
134 has_particle: true,
135 },
136 Template {
137 name: "cube_column",
138 parent: "minecraft:block/cube_column",
139 slots: &["end", "side"],
140 sprite: false,
141 has_particle: true,
142 },
143 Template {
144 name: "cube_bottom_top",
145 parent: "minecraft:block/cube_bottom_top",
146 slots: &["top", "bottom", "side"],
147 sprite: false,
148 has_particle: true,
149 },
150 Template {
151 name: "cube",
152 parent: "minecraft:block/cube",
153 slots: &["down", "up", "north", "south", "west", "east"],
154 sprite: false,
155 has_particle: false,
156 },
157 Template {
158 name: "orientable",
159 parent: "minecraft:block/orientable",
160 slots: &["front", "side", "top"],
161 sprite: false,
162 has_particle: true,
163 },
164 Template {
165 name: "cross",
166 parent: "minecraft:block/cross",
167 slots: &["cross"],
168 sprite: false,
169 has_particle: true,
170 },
171 Template {
172 name: "generated",
173 parent: "minecraft:item/generated",
174 slots: &["layer0"],
175 sprite: true,
176 has_particle: true,
177 },
178 Template {
179 name: "handheld",
180 parent: "minecraft:item/handheld",
181 slots: &["layer0"],
182 sprite: true,
183 has_particle: true,
184 },
185];
186
187pub fn template(name: &str) -> Option<&'static Template> {
188 TEMPLATES.iter().find(|t| t.name == name)
189}
190
191pub fn valid_namespace(s: &str) -> bool {
193 !s.is_empty()
194 && s.bytes().all(|b| {
195 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
196 })
197}
198
199pub fn valid_path(s: &str) -> bool {
201 !s.is_empty()
202 && !s.starts_with('/')
203 && !s.ends_with('/')
204 && s.bytes().all(|b| {
205 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
206 })
207}
208
209pub fn valid_id(s: &str) -> bool {
211 match s.split_once(':') {
212 Some((ns, path)) => valid_namespace(ns) && valid_path(path),
213 None => valid_path(s),
214 }
215}
216
217pub fn find(root: &Path) -> Vec<PathBuf> {
221 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
222 let Ok(entries) = std::fs::read_dir(dir) else {
223 return;
224 };
225 for entry in entries.flatten() {
226 let path = entry.path();
227 if path.is_dir() {
228 walk(&path, out);
229 } else if Kind::of(&path).is_some() {
230 out.push(path);
231 }
232 }
233 }
234 let mut out = Vec::new();
235 walk(&root.join(CONTENT_DIR), &mut out);
236 out.sort();
237 out
238}
239
240fn display(root: &Path, p: &Path) -> String {
241 p.strip_prefix(root)
242 .unwrap_or(p)
243 .to_string_lossy()
244 .replace('\\', "/")
245}
246
247#[derive(Debug, Clone)]
251enum Text {
252 Key(String),
254 Literal(String),
255}
256
257#[derive(Debug, Clone, Default)]
258struct BlockProps {
259 hardness: Option<f64>,
260 resistance: Option<f64>,
261 sound: Option<String>,
262 light: u8,
263 tool: Option<String>,
264 tool_level: Option<String>,
265 requires_tool: bool,
266 no_collision: bool,
267 slipperiness: Option<f64>,
268 shape: Option<[f64; 6]>,
270 connect_groups: Vec<String>,
271 connects: bool,
272}
273
274#[derive(Debug, Clone, Default)]
275struct ItemProps {
276 stack_size: Option<u8>,
277 durability: u32,
278 fire_resistant: bool,
279 fuel_ticks: u32,
280 food: Option<(u32, f64, bool)>,
282}
283
284#[derive(Debug, Clone)]
286struct Entry {
287 kind: Kind,
288 id: String,
289 file: String,
290 auto: bool,
291 block: BlockProps,
292 item: ItemProps,
293}
294
295struct Ctx<'a> {
297 root: &'a Path,
298 refs: &'a yres::Refs,
299 file: String,
300 ns: String,
301 errors: &'a mut Vec<String>,
302 warnings: &'a mut Vec<String>,
303 textures: &'a mut BTreeMap<String, PathBuf>,
305}
306
307impl Ctx<'_> {
308 fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
309 self.errors.push(format!("{}:{line}: {msg}", self.file));
310 }
311
312 fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
313 self.warnings.push(format!("{}:{line}: {msg}", self.file));
314 }
315
316 fn unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
318 for (k, v) in obj.members() {
319 if !known.contains(&k.as_str()) {
320 self.err(
321 v.line,
322 format!(
323 "unknown key {k:?} in {what} (expected one of: {})",
324 known.join(", ")
325 ),
326 );
327 }
328 }
329 }
330
331 fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
332 match j.num() {
333 Some(n) if (min..=max).contains(&n) => Some(n),
334 Some(n) => {
335 self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
336 None
337 }
338 None => {
339 self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
340 None
341 }
342 }
343 }
344
345 fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
346 let b = j.bool();
347 if b.is_none() {
348 self.err(j.line, format!("{what} must be true or false"));
349 }
350 b
351 }
352
353 fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
354 let s = j.str();
355 if s.is_none() {
356 self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
357 }
358 s
359 }
360
361 fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
362 let s = self.string(j, what)?;
363 if allowed.contains(&s) {
364 Some(s.to_string())
365 } else {
366 self.err(
367 j.line,
368 format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
369 );
370 None
371 }
372 }
373
374 fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
375 let items = j.items();
376 if !j.is_arr() || items.len() != N {
377 self.err(j.line, format!("{what} must be an array of {N} numbers"));
378 return None;
379 }
380 let mut out = [0.0; N];
381 for (i, item) in items.iter().enumerate() {
382 out[i] = self.number(item, what, min, max)?;
383 }
384 Some(out)
385 }
386
387 fn text(&mut self, j: &J, what: &str) -> Option<Text> {
390 let s = self.string(j, what)?;
391 match s.strip_prefix('@') {
392 Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
393 Some(rest) => match self.refs.texts.get(rest) {
394 Some((key, _)) => Some(Text::Key(key.clone())),
395 None => {
396 self.err(
397 j.line,
398 format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
399 );
400 None
401 }
402 },
403 None => Some(Text::Literal(s.to_string())),
404 }
405 }
406
407 fn texture(&mut self, j: &J, what: &str) -> Option<String> {
411 let s = self.string(j, what)?.trim().to_string();
412 if let Some(slot) = s.strip_prefix('#') {
413 if slot.is_empty() {
414 self.err(j.line, format!("{what}: `#` needs a slot name"));
415 return None;
416 }
417 return Some(s);
418 }
419 if let Some(rest) = s.strip_prefix('@') {
420 return match self.refs.images.get(rest) {
421 Some(id) => Some(id.clone()),
422 None => {
423 self.err(
424 j.line,
425 format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
426 );
427 None
428 }
429 };
430 }
431 if let Some(stem) = s.strip_suffix(".png") {
432 let rel = stem.trim_start_matches("./").trim_start_matches('/');
433 let rel = rel.strip_prefix("resources/").unwrap_or(rel);
434 if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
435 self.err(
436 j.line,
437 format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
438 );
439 return None;
440 }
441 let file = self
442 .root
443 .join(yres::RESOURCES_DIR)
444 .join(format!("{rel}.png"));
445 if !file.is_file() {
446 self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
447 return None;
448 }
449 let target = format!("assets/{}/textures/{rel}.png", self.ns);
450 if let Some(other) = self.textures.insert(target.clone(), file.clone())
451 && other != file
452 {
453 self.err(
454 j.line,
455 format!(
456 "{what}: {target} also comes from {}",
457 display(self.root, &other)
458 ),
459 );
460 }
461 let meta = self
462 .root
463 .join(yres::RESOURCES_DIR)
464 .join(format!("{rel}.png.mcmeta"));
465 if meta.is_file() {
466 self.textures.insert(format!("{target}.mcmeta"), meta);
467 }
468 return Some(format!("{}:{rel}", self.ns));
469 }
470 if !valid_id(&s) {
471 self.err(
472 j.line,
473 format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
474 );
475 return None;
476 }
477 let id = if s.contains(':') {
478 s
479 } else {
480 format!("minecraft:{s}")
481 };
482 let (ns, path) = id.split_once(':').unwrap_or_default();
483 if ns == self.ns {
484 let file = self
485 .root
486 .join("assets")
487 .join(ns)
488 .join("textures")
489 .join(format!("{path}.png"));
490 let bundled = self
491 .textures
492 .contains_key(&format!("assets/{ns}/textures/{path}.png"))
493 || self.refs.images.values().any(|v| *v == id);
494 if !file.is_file() && !bundled {
495 self.warn(
496 j.line,
497 format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
498 );
499 }
500 }
501 Some(id)
502 }
503}
504
505struct Model {
507 json: Value,
508 bounds: Option<[f64; 6]>,
510}
511
512fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
515 if !j.is_obj() {
516 cx.err(j.line, format!("{what} must be an object"));
517 return None;
518 }
519 cx.unknown_keys(
520 j,
521 what,
522 &[
523 "parent",
524 "textures",
525 "elements",
526 "display",
527 "ambient_occlusion",
528 "gui_light",
529 ],
530 );
531 let mut out = Map::new();
532 let mut tpl: Option<&Template> = None;
533 let mut raw_parent = false;
534 if let Some(p) = j.get("parent") {
535 let name = cx.string(p, &format!("{what}.parent"))?;
536 if let Some(t) = template(name) {
537 if t.sprite && !item {
538 cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
539 }
540 out.insert("parent".into(), Value::String(t.parent.into()));
541 tpl = Some(t);
542 } else if name.contains(':') && valid_id(name) {
543 out.insert("parent".into(), Value::String(name.into()));
544 raw_parent = true;
545 } else {
546 let names: Vec<&str> = TEMPLATES
547 .iter()
548 .filter(|t| item || !t.sprite)
549 .map(|t| t.name)
550 .collect();
551 cx.err(
552 p.line,
553 format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
554 );
555 return None;
556 }
557 }
558
559 let mut slots: Vec<String> = Vec::new();
561 let mut textures = Map::new();
562 if let Some(t) = j.get("textures") {
563 if !t.is_obj() {
564 cx.err(
565 t.line,
566 format!("{what}.textures must be an object of slot → texture"),
567 );
568 }
569 for (slot, v) in t.members() {
570 if slot.is_empty() || slot.starts_with('#') {
571 cx.err(
572 v.line,
573 format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
574 );
575 continue;
576 }
577 if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
578 textures.insert(slot.clone(), Value::String(tex));
579 slots.push(slot.clone());
580 }
581 }
582 }
583 let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
584 if let Some(t) = tpl {
585 if t.sprite {
586 if !defined("layer0", &textures) {
587 cx.err(
588 j.line,
589 format!("{what}: parent {:?} needs textures.layer0", t.name),
590 );
591 }
592 for slot in &slots {
593 let n = slot
594 .strip_prefix("layer")
595 .and_then(|n| n.parse::<usize>().ok());
596 match n {
597 Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
598 Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
599 None if slot == "particle" => {}
600 None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
601 }
602 }
603 } else {
604 for slot in t.slots {
605 if !defined(slot, &textures) {
606 cx.err(
607 j.line,
608 format!("{what}: parent {:?} needs textures.{slot}", t.name),
609 );
610 }
611 }
612 }
613 }
614
615 let mut bounds: Option<[f64; 6]> = None;
617 if let Some(e) = j.get("elements") {
618 if let Some(t) = tpl {
619 cx.err(
620 e.line,
621 format!(
622 "{what}: elements replace the shape of parent {:?} — remove one of them",
623 t.name
624 ),
625 );
626 }
627 if !e.is_arr() {
628 cx.err(e.line, format!("{what}.elements must be an array"));
629 }
630 let mut elements = Vec::new();
631 for (i, el) in e.items().iter().enumerate() {
632 if let Some((json, from, to)) = element(
633 cx,
634 el,
635 &format!("{what}.elements[{i}]"),
636 &textures,
637 raw_parent,
638 ) {
639 let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
640 for a in 0..3 {
641 b[a] = b[a].min(from[a].min(to[a]));
642 b[a + 3] = b[a + 3].max(from[a].max(to[a]));
643 }
644 elements.push(json);
645 }
646 }
647 out.insert("elements".into(), Value::Array(elements));
648 } else if tpl.is_none() && !raw_parent {
649 cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
650 }
651
652 if !textures.contains_key("particle")
654 && !tpl.is_some_and(|t| t.has_particle)
655 && !raw_parent
656 && let Some(first) = slots.first()
657 {
658 let v = textures[first].clone();
659 textures.insert("particle".into(), v);
660 }
661 if !textures.is_empty() {
662 out.insert("textures".into(), Value::Object(textures));
663 }
664
665 if let Some(d) = j.get("display") {
666 if !d.is_obj() {
667 cx.err(d.line, format!("{what}.display must be an object"));
668 }
669 let mut display = Map::new();
670 for (slot, t) in d.members() {
671 let w = format!("{what}.display.{slot}");
672 if !DISPLAY_SLOTS.contains(&slot.as_str()) {
673 cx.err(
674 t.line,
675 format!(
676 "{w}: unknown display position (expected one of: {})",
677 DISPLAY_SLOTS.join(", ")
678 ),
679 );
680 continue;
681 }
682 cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
683 let mut m = Map::new();
684 for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
685 if let Some(v) = t.get(key)
686 && let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
687 {
688 m.insert(
689 key.into(),
690 Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
691 );
692 }
693 }
694 display.insert(slot.clone(), Value::Object(m));
695 }
696 out.insert("display".into(), Value::Object(display));
697 }
698 if let Some(ao) = j.get("ambient_occlusion")
699 && let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
700 {
701 out.insert("ambientocclusion".into(), Value::Bool(b));
702 }
703 if let Some(gl) = j.get("gui_light")
704 && let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
705 {
706 out.insert("gui_light".into(), Value::String(s));
707 }
708 Some(Model {
709 json: Value::Object(out),
710 bounds,
711 })
712}
713
714fn element(
716 cx: &mut Ctx,
717 j: &J,
718 what: &str,
719 textures: &Map<String, Value>,
720 raw_parent: bool,
721) -> Option<(Value, [f64; 3], [f64; 3])> {
722 if !j.is_obj() {
723 cx.err(j.line, format!("{what} must be an object"));
724 return None;
725 }
726 cx.unknown_keys(
727 j,
728 what,
729 &["name", "from", "to", "rotation", "shade", "faces"],
730 );
731 if let Some(n) = j.get("name") {
732 cx.string(n, &format!("{what}.name"));
733 }
734 let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
735 cx.err(j.line, format!("{what} needs `from` and `to`"));
736 return None;
737 };
738 let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
739 let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
740 let mut out = Map::new();
741 out.insert(
742 "from".into(),
743 Value::Array(from.iter().map(|n| num_value(*n)).collect()),
744 );
745 out.insert(
746 "to".into(),
747 Value::Array(to.iter().map(|n| num_value(*n)).collect()),
748 );
749 if let Some(r) = j.get("rotation") {
750 let w = format!("{what}.rotation");
751 cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
752 let axis = r
753 .get("axis")
754 .and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
755 let angle = r
756 .get("angle")
757 .and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
758 if let Some(a) = angle
759 && (a / 22.5).fract() != 0.0
760 {
761 cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
762 }
763 let origin = match r.get("origin") {
764 Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
765 None => Some([8.0, 8.0, 8.0]),
766 };
767 if axis.is_none() || angle.is_none() {
768 if r.get("axis").is_none() || r.get("angle").is_none() {
769 cx.err(r.line, format!("{w} needs `axis` and `angle`"));
770 }
771 } else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
772 let mut rot = Map::new();
773 rot.insert(
774 "origin".into(),
775 Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
776 );
777 rot.insert("axis".into(), Value::String(axis));
778 rot.insert("angle".into(), num_value(angle));
779 if let Some(rs) = r.get("rescale")
780 && let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
781 && b
782 {
783 rot.insert("rescale".into(), Value::Bool(true));
784 }
785 out.insert("rotation".into(), Value::Object(rot));
786 }
787 }
788 if let Some(s) = j.get("shade")
789 && let Some(b) = cx.boolean(s, &format!("{what}.shade"))
790 && !b
791 {
792 out.insert("shade".into(), Value::Bool(false));
793 }
794 let mut faces = Map::new();
795 match j.get("faces") {
796 None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
797 Some(f) => {
798 if !f.is_obj() {
799 cx.err(
800 f.line,
801 format!("{what}.faces must be an object of direction → face"),
802 );
803 }
804 for (dir, face) in f.members() {
805 let w = format!("{what}.faces.{dir}");
806 if !DIRECTIONS.contains(&dir.as_str()) {
807 cx.err(
808 face.line,
809 format!(
810 "{w}: unknown face (expected one of: {})",
811 DIRECTIONS.join(", ")
812 ),
813 );
814 continue;
815 }
816 cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
817 let mut fo = Map::new();
818 match face.get("texture") {
819 None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
820 Some(t) => {
821 if let Some(s) = cx.string(t, &format!("{w}.texture")) {
822 let slot = s.strip_prefix('#').unwrap_or(s);
823 if !textures.contains_key(slot) && !raw_parent {
824 cx.err(
825 t.line,
826 format!("{w}.texture `#{slot}`: no such slot in textures"),
827 );
828 }
829 fo.insert("texture".into(), Value::String(format!("#{slot}")));
830 }
831 }
832 }
833 if let Some(uv) = face.get("uv")
834 && let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
835 {
836 fo.insert(
837 "uv".into(),
838 Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
839 );
840 }
841 if let Some(r) = face.get("rotation") {
842 match r.num() {
843 Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
844 if n != 0.0 {
845 fo.insert("rotation".into(), num_value(n));
846 }
847 }
848 _ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
849 }
850 }
851 if let Some(c) = face.get("cullface")
852 && let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
853 {
854 fo.insert("cullface".into(), Value::String(c));
855 }
856 if let Some(t) = face.get("tint")
857 && let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
858 && n >= 0.0
859 {
860 fo.insert("tintindex".into(), num_value(n.floor()));
861 }
862 faces.insert(dir.clone(), Value::Object(fo));
863 }
864 }
865 }
866 out.insert("faces".into(), Value::Object(faces));
867 Some((Value::Object(out), from, to))
868}
869
870fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
872 let (raw, line) = match j.get("id") {
873 Some(v) => (cx.string(v, "id")?.to_string(), v.line),
874 None => (
875 path.file_stem()
876 .unwrap_or_default()
877 .to_string_lossy()
878 .into_owned(),
879 1,
880 ),
881 };
882 let (ns, p) = match raw.split_once(':') {
883 Some((ns, p)) => (ns.to_string(), p.to_string()),
884 None => match mod_ns {
885 Some(ns) => (ns.to_string(), raw.clone()),
886 None => {
887 cx.err(
888 line,
889 "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
890 );
891 return None;
892 }
893 },
894 };
895 if !valid_namespace(&ns) || !valid_path(&p) {
896 cx.err(
897 line,
898 format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
899 );
900 return None;
901 }
902 Some((ns, p))
903}
904
905fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
907 let mut v = Map::new();
908 v.insert("model".into(), Value::String(model.into()));
909 if x != 0.0 {
910 v.insert("x".into(), num_value(x));
911 }
912 if y != 0.0 {
913 v.insert("y".into(), num_value(y));
914 }
915 if uvlock {
916 v.insert("uvlock".into(), Value::Bool(true));
917 }
918 if let Some(w) = weight
919 && w != 1.0
920 {
921 v.insert("weight".into(), num_value(w));
922 }
923 Value::Object(v)
924}
925
926fn pretty(v: &Value) -> Vec<u8> {
927 let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
928 s.push('\n');
929 s.into_bytes()
930}
931
932#[allow(clippy::too_many_arguments)]
934fn compile_file(
935 cx: &mut Ctx,
936 j: &J,
937 kind: Kind,
938 path: &Path,
939 mod_ns: Option<&str>,
940 out: &mut Compiled,
941 generated: &mut BTreeMap<String, String>,
942) -> Option<Entry> {
943 if !j.is_obj() {
944 cx.err(
945 j.line,
946 format!("a .y{} file is one JSON object", kind.word()),
947 );
948 return None;
949 }
950 let common = [
951 "$schema", "format", "id", "name", "tooltip", "register", "model",
952 ];
953 let specific: &[&str] = match kind {
954 Kind::Block => &["block", "item_model", "states"],
955 Kind::Item => &["item"],
956 };
957 let known: Vec<&str> = common.iter().chain(specific).copied().collect();
958 cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
959 if let Some(f) = j.get("format")
960 && f.num() != Some(1.0)
961 {
962 cx.err(f.line, "format must be 1 (this yog understands format 1)");
963 }
964 let (ns, p) = entry_id(cx, j, path, mod_ns)?;
965 cx.ns = ns.clone();
966 let id = format!("{ns}:{p}");
967
968 let auto = match j.get("register") {
969 None => true,
970 Some(r) => cx
971 .one_of(r, "register", &["auto", "manual"])
972 .is_none_or(|s| s == "auto"),
973 };
974
975 let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
977 for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
978 let Some(v) = j.get(field) else { continue };
979 match cx.text(v, field)? {
980 Text::Key(from) if from == target => {}
981 Text::Key(from) => out.lang_aliases.push((from, target)),
982 Text::Literal(s) => {
983 out.lang
984 .entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
985 .or_default()
986 .insert(target, s);
987 }
988 }
989 }
990
991 let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
992 let full = format!("assets/{ns}/{rel}");
993 if cx.root.join(&full).is_file() {
994 cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
995 }
996 if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
997 cx.err(1, format!("{full} is also written by {other}"));
998 }
999 out.outputs.push((full, pretty(value)));
1000 };
1001
1002 let mut entry = Entry {
1003 kind,
1004 id: id.clone(),
1005 file: cx.file.clone(),
1006 auto,
1007 block: BlockProps::default(),
1008 item: ItemProps::default(),
1009 };
1010
1011 match kind {
1012 Kind::Item => {
1013 if let Some(it) = j.get("item") {
1014 cx.unknown_keys(
1015 it,
1016 "item",
1017 &[
1018 "stack_size",
1019 "durability",
1020 "fire_resistant",
1021 "fuel_ticks",
1022 "food",
1023 ],
1024 );
1025 let props = &mut entry.item;
1026 if let Some(v) = it.get("stack_size") {
1027 props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
1028 }
1029 if let Some(v) = it.get("durability") {
1030 props.durability =
1031 cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
1032 }
1033 if let Some(v) = it.get("fire_resistant") {
1034 props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
1035 }
1036 if let Some(v) = it.get("fuel_ticks") {
1037 props.fuel_ticks =
1038 cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
1039 }
1040 if let Some(f) = it.get("food") {
1041 cx.unknown_keys(
1042 f,
1043 "item.food",
1044 &["nutrition", "saturation", "always_edible"],
1045 );
1046 let n = f
1047 .get("nutrition")
1048 .and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
1049 let s = f
1050 .get("saturation")
1051 .and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
1052 let a = f
1053 .get("always_edible")
1054 .and_then(|v| cx.boolean(v, "item.food.always_edible"));
1055 if n.is_none() {
1056 cx.err(f.line, "item.food needs nutrition");
1057 }
1058 props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
1059 }
1060 if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
1061 cx.warn(
1062 it.line,
1063 "item.stack_size is ignored: an item with durability stacks to 1",
1064 );
1065 }
1066 }
1067 let Some(mj) = j.get("model") else {
1068 cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
1069 return Some(entry);
1070 };
1071 let m = model(cx, mj, "model", true)?;
1072 emit(cx, format!("models/item/{p}.json"), &m.json);
1073 emit(
1074 cx,
1075 format!("items/{p}.json"),
1076 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1077 );
1078 }
1079 Kind::Block => {
1080 let mut render_layer: Option<String> = None;
1081 let mut drops = Value::Null;
1082 let mut shape_auto = true;
1083 if let Some(b) = j.get("block") {
1084 cx.unknown_keys(
1085 b,
1086 "block",
1087 &[
1088 "hardness",
1089 "resistance",
1090 "sound",
1091 "light",
1092 "tool",
1093 "tool_level",
1094 "requires_tool",
1095 "collision",
1096 "slipperiness",
1097 "shape",
1098 "render_layer",
1099 "drops",
1100 "connect_groups",
1101 ],
1102 );
1103 let props = &mut entry.block;
1104 if let Some(v) = b.get("hardness") {
1105 props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
1106 }
1107 if let Some(v) = b.get("resistance") {
1108 props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
1109 }
1110 if let Some(v) = b.get("sound") {
1111 props.sound = cx.one_of(v, "block.sound", &SOUNDS);
1112 }
1113 if let Some(v) = b.get("light") {
1114 props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
1115 }
1116 if let Some(v) = b.get("tool") {
1117 props.tool = cx.one_of(v, "block.tool", &TOOLS);
1118 }
1119 if let Some(v) = b.get("tool_level") {
1120 props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
1121 }
1122 if let Some(v) = b.get("requires_tool") {
1123 props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
1124 }
1125 if let Some(v) = b.get("collision") {
1126 props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
1127 }
1128 if let Some(v) = b.get("slipperiness") {
1129 props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
1130 }
1131 if let Some(v) = b.get("shape") {
1132 match &v.v {
1133 V::Str(s) if s == "auto" => {}
1134 V::Str(s) if s == "full" => shape_auto = false,
1135 V::Arr(_) => {
1136 shape_auto = false;
1137 props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
1138 }
1139 _ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
1140 }
1141 }
1142 if let Some(v) = b.get("render_layer") {
1143 render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
1144 }
1145 if let Some(v) = b.get("connect_groups") {
1146 for g in v.items() {
1147 if let Some(s) = cx.string(g, "block.connect_groups[]") {
1148 props.connect_groups.push(s.to_string());
1149 }
1150 }
1151 if !v.is_arr() {
1152 cx.err(
1153 v.line,
1154 "block.connect_groups must be an array of group names",
1155 );
1156 }
1157 }
1158 if props.tool_level.is_some() && props.tool.is_none() {
1159 cx.warn(
1160 b.line,
1161 "block.tool_level without block.tool — any tool of that level mines it",
1162 );
1163 }
1164 if let Some(d) = b.get("drops") {
1165 drops = drops_table(cx, d, &id);
1166 }
1167 }
1168 if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
1169 drops = drops_table_self(&id);
1170 }
1171
1172 let Some(mj) = j.get("model") else {
1173 cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
1174 return Some(entry);
1175 };
1176 let mut m = model(cx, mj, "model", false)?;
1177 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1178 o.insert(
1180 "render_type".into(),
1181 Value::String(format!("minecraft:{layer}")),
1182 );
1183 }
1184 if shape_auto {
1185 entry.block.shape = m
1186 .bounds
1187 .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1188 .map(|b| {
1189 [
1190 b[0].max(0.0),
1191 b[1].max(0.0),
1192 b[2].max(0.0),
1193 b[3].min(16.0),
1194 b[4].min(16.0),
1195 b[5].min(16.0),
1196 ]
1197 });
1198 }
1199 let main = format!("{ns}:block/{p}");
1200 emit(cx, format!("models/block/{p}.json"), &m.json);
1201
1202 let mut rotations: Vec<Value> = Vec::new();
1204 let mut arm: Option<(String, bool)> = None;
1205 if let Some(s) = j.get("states") {
1206 cx.unknown_keys(s, "states", &["rotations", "connect"]);
1207 if let Some(r) = s.get("rotations") {
1208 if !r.is_arr() || r.items().is_empty() {
1209 cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
1210 }
1211 for (i, rot) in r.items().iter().enumerate() {
1212 let w = format!("states.rotations[{i}]");
1213 cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
1214 let mut angle = |k: &str| -> f64 {
1215 match rot.get(k).map(|v| (v, v.num())) {
1216 None => 0.0,
1217 Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
1218 Some((v, _)) => {
1219 cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
1220 0.0
1221 }
1222 }
1223 };
1224 let (x, y) = (angle("x"), angle("y"));
1225 let weight = rot
1226 .get("weight")
1227 .and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
1228 let uvlock = rot
1229 .get("uvlock")
1230 .and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
1231 .unwrap_or(false);
1232 rotations.push(variant(&main, x, y, uvlock, weight));
1233 }
1234 }
1235 if let Some(c) = s.get("connect") {
1236 cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
1237 let uvlock = c
1238 .get("uvlock")
1239 .and_then(|v| cx.boolean(v, "states.connect.uvlock"))
1240 .unwrap_or(false);
1241 match c.get("arm") {
1242 None => cx.err(
1243 c.line,
1244 "states.connect needs `arm` — the model of the arm towards north",
1245 ),
1246 Some(a) => {
1247 if let Some(am) = model(cx, a, "states.connect.arm", false) {
1248 let mut json = am.json;
1249 if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
1250 {
1251 o.insert(
1252 "render_type".into(),
1253 Value::String(format!("minecraft:{layer}")),
1254 );
1255 }
1256 emit(cx, format!("models/block/{p}_arm.json"), &json);
1257 arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
1258 }
1259 }
1260 }
1261 entry.block.connects = true;
1262 if entry.block.connect_groups.is_empty() {
1263 entry.block.connect_groups.push(id.clone());
1264 }
1265 }
1266 }
1267 let base: Value = match rotations.len() {
1268 0 => variant(&main, 0.0, 0.0, false, None),
1269 1 => rotations.remove(0),
1270 _ => Value::Array(rotations),
1271 };
1272 let blockstate = match &arm {
1273 None => json!({ "variants": { "": base } }),
1274 Some((arm, uvlock)) => {
1275 let mut parts = vec![json!({ "apply": base })];
1276 for (dir, x, y) in [
1277 ("north", 0.0, 0.0),
1278 ("east", 0.0, 90.0),
1279 ("south", 0.0, 180.0),
1280 ("west", 0.0, 270.0),
1281 ("up", 270.0, 0.0),
1282 ("down", 90.0, 0.0),
1283 ] {
1284 parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1285 }
1286 json!({ "multipart": parts })
1287 }
1288 };
1289 emit(cx, format!("blockstates/{p}.json"), &blockstate);
1290
1291 let item_model = match j.get("item_model") {
1293 None => Some(json!({ "parent": main })),
1294 Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
1295 };
1296 if let Some(im) = item_model {
1297 emit(cx, format!("models/item/{p}.json"), &im);
1298 }
1299 emit(
1300 cx,
1301 format!("items/{p}.json"),
1302 &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1303 );
1304
1305 if !drops.is_null() {
1306 let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1307 let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1308 if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1309 cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
1310 }
1311 out.outputs.push((full, pretty(&drops)));
1312 }
1313 if let Some(tool) = &entry.block.tool {
1314 out.tags
1315 .entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
1316 .or_default()
1317 .insert(id.clone());
1318 }
1319 if let Some(level) = &entry.block.tool_level {
1320 out.tags
1321 .entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
1322 .or_default()
1323 .insert(id.clone());
1324 }
1325 }
1326 }
1327 Some(entry)
1328}
1329
1330fn drops_table_self(id: &str) -> Value {
1331 json!({
1332 "type": "minecraft:block",
1333 "pools": [{
1334 "rolls": 1,
1335 "entries": [{ "type": "minecraft:item", "name": id }],
1336 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1337 }]
1338 })
1339}
1340
1341fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
1343 match &d.v {
1344 V::Str(s) if s == "self" => drops_table_self(id),
1345 V::Str(s) if s == "none" => Value::Null,
1346 V::Obj(_) => {
1347 cx.unknown_keys(d, "block.drops", &["item", "count"]);
1348 let item = match d.get("item") {
1349 Some(i) => match cx.string(i, "block.drops.item") {
1350 Some(s) if valid_id(s) => {
1351 if s.contains(':') {
1352 s.to_string()
1353 } else {
1354 format!("minecraft:{s}")
1355 }
1356 }
1357 Some(s) => {
1358 cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
1359 return Value::Null;
1360 }
1361 None => return Value::Null,
1362 },
1363 None => id.to_string(),
1364 };
1365 let mut entry = json!({ "type": "minecraft:item", "name": item });
1366 if let Some(c) = d.get("count") {
1367 let count = match &c.v {
1368 V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
1369 V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
1370 json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
1371 }),
1372 _ => {
1373 cx.err(c.line, "block.drops.count must be a number or [min, max]");
1374 None
1375 }
1376 };
1377 if let Some(count) = count {
1378 entry["functions"] =
1379 json!([{ "function": "minecraft:set_count", "count": count }]);
1380 }
1381 }
1382 json!({
1383 "type": "minecraft:block",
1384 "pools": [{
1385 "rolls": 1,
1386 "entries": [entry],
1387 "conditions": [{ "condition": "minecraft:survives_explosion" }]
1388 }]
1389 })
1390 }
1391 _ => {
1392 cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
1393 Value::Null
1394 }
1395 }
1396}
1397
1398pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1401 let mut out = Compiled::default();
1402 let files = find(root);
1403 if files.is_empty() {
1404 return Ok(out);
1405 }
1406 let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1407 let mut errors: Vec<String> = Vec::new();
1408 let mut warnings: Vec<String> = Vec::new();
1409 let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1410 let mut generated: BTreeMap<String, String> = BTreeMap::new();
1411 let mut entries: Vec<Entry> = Vec::new();
1412 let mut ids: HashMap<String, String> = HashMap::new();
1413 for path in &files {
1414 let shown = display(root, path);
1415 let Some(kind) = Kind::of(path) else { continue };
1416 let text = match std::fs::read_to_string(path) {
1417 Ok(t) => t,
1418 Err(e) => {
1419 errors.push(format!("{shown}: {e}"));
1420 continue;
1421 }
1422 };
1423 let j = match json::parse(&text) {
1424 Ok(j) => j,
1425 Err(e) => {
1426 errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
1427 continue;
1428 }
1429 };
1430 let mut cx = Ctx {
1431 root,
1432 refs,
1433 file: shown.clone(),
1434 ns: mod_ns.unwrap_or("minecraft").to_string(),
1435 errors: &mut errors,
1436 warnings: &mut warnings,
1437 textures: &mut textures,
1438 };
1439 if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
1440 {
1441 if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
1442 errors.push(format!(
1443 "{shown}: id {} is also defined by {other}",
1444 entry.id
1445 ));
1446 }
1447 entries.push(entry);
1448 }
1449 }
1450 for (target, source) in &textures {
1451 match std::fs::read(source) {
1452 Ok(bytes) => out.outputs.push((target.clone(), bytes)),
1453 Err(e) => errors.push(format!("{}: {e}", display(root, source))),
1454 }
1455 }
1456 out.warnings = warnings;
1457 if !errors.is_empty() {
1458 return Err(format!(
1459 "invalid blocks/items in content/:\n {}",
1460 errors.join("\n ")
1461 ));
1462 }
1463 out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
1464 out.items = entries.len() - out.blocks;
1465 if entries.iter().any(|e| e.auto) && !mentions_content_macro(&root.join("src")) {
1466 out.warnings.push(format!(
1467 "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
1468 CONTENT_DIR
1469 ));
1470 }
1471 out.source = Some(rust_source(&entries));
1472 Ok(out)
1473}
1474
1475fn mentions_content_macro(dir: &Path) -> bool {
1477 let Ok(entries) = std::fs::read_dir(dir) else {
1478 return false;
1479 };
1480 entries.flatten().any(|e| {
1481 let p = e.path();
1482 if p.is_dir() {
1483 mentions_content_macro(&p)
1484 } else {
1485 p.extension().is_some_and(|x| x == "rs")
1486 && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
1487 }
1488 })
1489}
1490
1491fn rust_ident(path: &str) -> String {
1493 let mut s: String = path
1494 .chars()
1495 .map(|c| {
1496 if c.is_ascii_alphanumeric() {
1497 c.to_ascii_lowercase()
1498 } else {
1499 '_'
1500 }
1501 })
1502 .collect();
1503 if s.starts_with(|c: char| c.is_ascii_digit()) {
1504 s.insert(0, '_');
1505 }
1506 s
1507}
1508
1509fn float(n: f64) -> String {
1510 let s = format!("{n:?}");
1511 if s.contains('.') || s.contains('e') {
1512 s
1513 } else {
1514 format!("{s}.0")
1515 }
1516}
1517
1518fn rust_source(entries: &[Entry]) -> String {
1519 let mut code = String::from(
1520 "// <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",
1521 );
1522 for (kind, set, def) in [
1523 (Kind::Block, "Blocks", "BlockDef"),
1524 (Kind::Item, "Items", "ItemDef"),
1525 ] {
1526 let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
1527 let _ = writeln!(
1528 code,
1529 "\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} {{",
1530 if kind == Kind::Block {
1531 "blocks"
1532 } else {
1533 "items"
1534 }
1535 );
1536 let mut used: HashMap<String, usize> = HashMap::new();
1537 for e in mine {
1538 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
1539 let mut ident = rust_ident(path);
1540 let n = used.entry(ident.clone()).or_insert(0);
1541 *n += 1;
1542 if *n > 1 {
1543 let _ = write!(ident, "_{n}");
1544 }
1545 let upper = ident.to_ascii_uppercase();
1546 let ident = if is_keyword(&ident) {
1547 format!("r#{ident}")
1548 } else {
1549 ident
1550 };
1551 let _ = writeln!(
1552 code,
1553 " /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
1554 e.id, e.file, e.id
1555 );
1556 let mut b = format!("::yog_api::{def}::new(Self::{upper})");
1557 match kind {
1558 Kind::Block => {
1559 let p = &e.block;
1560 if p.hardness.is_some() || p.resistance.is_some() {
1561 let _ = write!(
1562 b,
1563 "\n .strength({}, {})",
1564 float(p.hardness.unwrap_or(1.5)),
1565 float(p.resistance.unwrap_or(6.0))
1566 );
1567 }
1568 if let Some(s) = &p.sound {
1569 let _ = write!(b, "\n .sound({s:?})");
1570 }
1571 if p.light > 0 {
1572 let _ = write!(b, "\n .light_level({})", p.light);
1573 }
1574 if p.requires_tool {
1575 b.push_str("\n .requires_tool()");
1576 }
1577 if p.no_collision {
1578 b.push_str("\n .no_collision()");
1579 }
1580 if let Some(s) = p.slipperiness {
1581 let _ = write!(b, "\n .slipperiness({})", float(s));
1582 }
1583 if let Some(s) = p.shape {
1584 let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
1585 let _ = write!(b, "\n .shape({})", args.join(", "));
1586 }
1587 if p.connects {
1588 b.push_str("\n .connects_to_neighbors()");
1589 }
1590 if !p.connect_groups.is_empty() {
1591 let groups: Vec<String> =
1592 p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
1593 let _ =
1594 write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
1595 }
1596 }
1597 Kind::Item => {
1598 let p = &e.item;
1599 if let Some(s) = p.stack_size {
1600 let _ = write!(b, "\n .max_stack({s})");
1601 }
1602 if p.durability > 0 {
1603 let _ = write!(b, "\n .max_damage({})", p.durability);
1604 }
1605 if p.fire_resistant {
1606 b.push_str("\n .fire_resistant()");
1607 }
1608 if p.fuel_ticks > 0 {
1609 let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
1610 }
1611 if let Some((n, s, always)) = p.food {
1612 let _ = write!(
1613 b,
1614 "\n .food(::yog_api::FoodDef::new({n}, {}){})",
1615 float(s),
1616 if always { ".can_always_eat()" } else { "" }
1617 );
1618 }
1619 }
1620 }
1621 let _ = writeln!(
1622 code,
1623 " /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
1624 e.file
1625 );
1626 }
1627 code.push_str("}\n");
1628 }
1629
1630 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");
1631 let mut used: HashMap<(Kind, String), usize> = HashMap::new();
1632 for e in entries {
1633 let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
1634 let mut ident = rust_ident(path);
1635 let n = used.entry((e.kind, ident.clone())).or_insert(0);
1636 *n += 1;
1637 if *n > 1 {
1638 let _ = write!(ident, "_{n}");
1639 }
1640 let ident = if is_keyword(&ident) {
1641 format!("r#{ident}")
1642 } else {
1643 ident
1644 };
1645 if !e.auto {
1646 continue;
1647 }
1648 let _ = match e.kind {
1649 Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
1650 Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
1651 };
1652 }
1653 code.push_str("}\n");
1654 if entries.iter().any(|e| e.auto) {
1655 code.push_str(
1656 "\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",
1657 );
1658 }
1659 code
1660}
1661
1662fn is_keyword(s: &str) -> bool {
1663 const KW: &[&str] = &[
1664 "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
1665 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
1666 "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
1667 "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
1668 "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
1669 ];
1670 KW.contains(&s)
1671}
1672
1673pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
1676 let out = root.join(GENERATED_FILE);
1677 let Some(code) = &compiled.source else {
1678 let _ = std::fs::remove_file(&out);
1679 return Ok(());
1680 };
1681 crate::write_if_changed(&out, code)?;
1682 Ok(())
1683}
1684
1685#[derive(Default)]
1688pub struct OutputSet {
1689 items: Vec<Option<Output>>,
1690 at: HashMap<String, usize>,
1691}
1692
1693impl OutputSet {
1694 pub fn get(&self, path: &str) -> Option<&[u8]> {
1695 let i = *self.at.get(path)?;
1696 self.items[i].as_ref().map(|(_, b)| b.as_slice())
1697 }
1698
1699 pub fn contains(&self, path: &str) -> bool {
1700 self.at.contains_key(path)
1701 }
1702
1703 pub fn paths(&self) -> impl Iterator<Item = &String> {
1704 self.at.keys()
1705 }
1706
1707 pub fn put(&mut self, path: String, bytes: Vec<u8>) {
1709 if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
1710 self.items[i] = None;
1711 }
1712 self.items.push(Some((path, bytes)));
1713 }
1714
1715 pub fn into_vec(self) -> Vec<Output> {
1716 self.items.into_iter().flatten().collect()
1717 }
1718}
1719
1720impl From<Vec<Output>> for OutputSet {
1721 fn from(outputs: Vec<Output>) -> Self {
1722 let mut set = OutputSet::default();
1723 for (path, bytes) in outputs {
1724 set.put(path, bytes);
1725 }
1726 set
1727 }
1728}
1729
1730fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
1733 let read;
1734 let bytes = match outputs.get(path) {
1735 Some(b) => b,
1736 None => match std::fs::read(root.join(path)) {
1737 Ok(b) => {
1738 read = b;
1739 &read
1740 }
1741 Err(_) => return Ok(Map::new()),
1742 },
1743 };
1744 serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
1745}
1746
1747pub fn merge_outputs(
1751 root: &Path,
1752 outputs: &mut Vec<Output>,
1753 compiled: Compiled,
1754) -> Result<(), String> {
1755 let mut set = OutputSet::from(std::mem::take(outputs));
1756 let mut errors = Vec::new();
1757 if !compiled.lang_aliases.is_empty() {
1760 let mut lang_files: BTreeSet<String> =
1761 set.paths().filter(|p| is_lang_path(p)).cloned().collect();
1762 if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
1763 for ns in dirs.flatten() {
1764 if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
1765 for f in files.flatten() {
1766 if let Ok(rel) = f.path().strip_prefix(root) {
1767 let rel = rel.to_string_lossy().replace('\\', "/");
1768 if is_lang_path(&rel) {
1769 lang_files.insert(rel);
1770 }
1771 }
1772 }
1773 }
1774 }
1775 }
1776 for path in lang_files {
1777 let mut map = match json_output(&set, root, &path) {
1778 Ok(m) => m,
1779 Err(e) => {
1780 errors.push(e);
1781 continue;
1782 }
1783 };
1784 let mut changed = false;
1785 for (from, to) in &compiled.lang_aliases {
1786 if let Some(text) = map.get(from).cloned()
1787 && !map.contains_key(to)
1788 {
1789 map.insert(to.clone(), text);
1790 changed = true;
1791 }
1792 }
1793 if changed {
1794 set.put(path, pretty(&Value::Object(map)));
1795 }
1796 }
1797 }
1798 for ((ns, locale), entries) in &compiled.lang {
1799 let path = format!("assets/{ns}/lang/{locale}.json");
1800 let mut map = match json_output(&set, root, &path) {
1801 Ok(m) => m,
1802 Err(e) => {
1803 errors.push(e);
1804 continue;
1805 }
1806 };
1807 for (k, v) in entries {
1808 map.entry(k.clone())
1809 .or_insert_with(|| Value::String(v.clone()));
1810 }
1811 set.put(path, pretty(&Value::Object(map)));
1812 }
1813 for (path, ids) in &compiled.tags {
1814 let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
1817 let path = if !set.contains(path) && root.join(&plural).is_file() {
1818 plural
1819 } else {
1820 path.clone()
1821 };
1822 let mut map = match json_output(&set, root, &path) {
1823 Ok(m) => m,
1824 Err(e) => {
1825 errors.push(e);
1826 continue;
1827 }
1828 };
1829 let values = map
1830 .entry("values")
1831 .or_insert_with(|| Value::Array(Vec::new()));
1832 if let Value::Array(list) = values {
1833 for id in ids {
1834 if !list.iter().any(|v| v.as_str() == Some(id)) {
1835 list.push(Value::String(id.clone()));
1836 }
1837 }
1838 }
1839 map.entry("replace").or_insert(Value::Bool(false));
1840 set.put(path, pretty(&Value::Object(map)));
1841 }
1842 for (path, bytes) in compiled.outputs {
1843 set.put(path, bytes);
1844 }
1845 *outputs = set.into_vec();
1846 if errors.is_empty() {
1847 Ok(())
1848 } else {
1849 Err(format!(
1850 "merging blocks/items into the mod's files:\n {}",
1851 errors.join("\n ")
1852 ))
1853 }
1854}
1855
1856fn is_lang_path(p: &str) -> bool {
1857 let parts: Vec<&str> = p.split('/').collect();
1858 parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863 use super::*;
1864
1865 fn project(files: &[(&str, &str)]) -> PathBuf {
1866 use std::sync::atomic::{AtomicUsize, Ordering};
1867 static N: AtomicUsize = AtomicUsize::new(0);
1868 let dir = std::env::temp_dir().join(format!(
1869 "yog-content-test-{}-{}",
1870 std::process::id(),
1871 N.fetch_add(1, Ordering::SeqCst)
1872 ));
1873 let _ = std::fs::remove_dir_all(&dir);
1874 for (path, text) in files {
1875 let p = dir.join(path);
1876 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1877 std::fs::write(p, text).unwrap();
1878 }
1879 dir
1880 }
1881
1882 fn output(c: &Compiled, path: &str) -> Value {
1883 let (_, b) = c
1884 .outputs
1885 .iter()
1886 .find(|(p, _)| p == path)
1887 .unwrap_or_else(|| {
1888 panic!(
1889 "no {path} in {:?}",
1890 c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
1891 )
1892 });
1893 serde_json::from_slice(b).unwrap()
1894 }
1895
1896 #[test]
1897 fn compiles_a_cube_block() {
1898 let root = project(&[
1899 ("resources/block/ruby_ore.png", "png"),
1900 (
1901 "content/blocks/ruby_ore.yblock",
1902 r#"{
1903 "name": "Ruby Ore",
1904 "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
1905 "drops": { "item": "mymod:ruby", "count": [1, 3] } },
1906 "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
1907}"#,
1908 ),
1909 ]);
1910 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
1911 assert_eq!(c.blocks, 1);
1912 assert_eq!(
1913 output(&c, "assets/mymod/models/block/ruby_ore.json"),
1914 json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
1915 );
1916 assert_eq!(
1917 output(&c, "assets/mymod/blockstates/ruby_ore.json"),
1918 json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
1919 );
1920 assert_eq!(
1921 output(&c, "assets/mymod/models/item/ruby_ore.json"),
1922 json!({ "parent": "mymod:block/ruby_ore" })
1923 );
1924 assert_eq!(
1925 output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
1926 json!("mymod:item/ruby_ore")
1927 );
1928 let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
1929 assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
1930 assert_eq!(
1931 loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
1932 json!(3)
1933 );
1934 assert!(
1935 c.outputs
1936 .iter()
1937 .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
1938 );
1939 let src = c.source.as_ref().unwrap();
1940 assert!(
1941 src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
1942 "{src}"
1943 );
1944 assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
1945 assert!(src.contains(".requires_tool()"), "{src}");
1946 assert!(
1947 src.contains("registry.register_block(Blocks::ruby_ore());"),
1948 "{src}"
1949 );
1950
1951 let mut outputs = Vec::new();
1952 merge_outputs(&root, &mut outputs, c).unwrap();
1953 let find = |p: &str| -> Value {
1954 serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
1955 };
1956 assert_eq!(
1957 find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
1958 json!("Ruby Ore")
1959 );
1960 assert_eq!(
1961 find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
1962 json!(["mymod:ruby_ore"])
1963 );
1964 assert_eq!(
1965 find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
1966 json!(["mymod:ruby_ore"])
1967 );
1968 let _ = std::fs::remove_dir_all(root);
1969 }
1970
1971 #[test]
1972 fn compiles_elements_shape_and_connect() {
1973 let root = project(&[(
1974 "content/pipe.yblock",
1975 r##"{
1976 "block": { "render_layer": "cutout" },
1977 "model": {
1978 "textures": { "0": "minecraft:block/stone" },
1979 "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
1980 "rotation": { "axis": "y", "angle": 22.5 },
1981 "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
1982 },
1983 "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
1984 "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
1985}"##,
1986 )]);
1987 let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
1988 let m = output(&c, "assets/mymod/models/block/pipe.json");
1989 assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
1990 assert_eq!(m["render_type"], json!("minecraft:cutout"));
1991 assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
1992 let bs = output(&c, "assets/mymod/blockstates/pipe.json");
1993 assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
1994 assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
1995 let src = c.source.unwrap();
1996 assert!(
1997 src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
1998 "{src}"
1999 );
2000 assert!(src.contains(".connects_to_neighbors()"), "{src}");
2001 assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2002 let _ = std::fs::remove_dir_all(root);
2003 }
2004
2005 #[test]
2006 fn reports_errors_with_lines() {
2007 let root = project(&[(
2008 "content/bad.yblock",
2009 "{\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}",
2010 )]);
2011 let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2012 assert!(
2013 e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2014 "{e}"
2015 );
2016 assert!(
2017 e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2018 "{e}"
2019 );
2020 assert!(
2021 e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2022 "{e}"
2023 );
2024 assert!(
2025 e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2026 "{e}"
2027 );
2028 let _ = std::fs::remove_dir_all(root);
2029 }
2030
2031 #[test]
2032 fn compiles_an_item_with_resource_refs() {
2033 let root = project(&[(
2034 "content/items/ruby.yitem",
2035 r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2036 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2037 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2038 )]);
2039 let mut refs = yres::Refs::default();
2040 refs.texts.insert(
2041 "Items.ruby".into(),
2042 ("item.mymod.ruby".into(), "Ruby".into()),
2043 );
2044 refs.texts.insert(
2045 "Strings.ruby_tip".into(),
2046 ("mymod.ruby_tip".into(), "Shiny".into()),
2047 );
2048 refs.images
2049 .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2050 let c = compile(&root, "mymod", &refs).unwrap();
2051 assert_eq!(
2052 output(&c, "assets/mymod/models/item/ruby.json"),
2053 json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2054 );
2055 let src = c.source.clone().unwrap();
2056 assert!(src.contains(".max_stack(16)"), "{src}");
2057 assert!(
2058 src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2059 "{src}"
2060 );
2061 let mut outputs = vec![(
2063 "assets/mymod/lang/ru_ru.json".to_string(),
2064 r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2065 .as_bytes()
2066 .to_vec(),
2067 )];
2068 merge_outputs(&root, &mut outputs, c).unwrap();
2069 let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2070 assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2071 let _ = std::fs::remove_dir_all(root);
2072 }
2073
2074 #[test]
2075 fn namespace_needed_for_odd_mod_ids() {
2076 let root = project(&[(
2077 "content/x.yitem",
2078 r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2079 )]);
2080 let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2081 assert!(e.contains("give the namespace"), "{e}");
2082 let _ = std::fs::remove_dir_all(root);
2083 }
2084}