Skip to main content

yog_cli/
content.rs

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