Skip to main content

yog_cli/
content.rs

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