Skip to main content

yog_cli/
content.rs

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