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