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