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