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