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    definitions: &mut Vec<(&'static str, String, Vec<u8>)>,
1595    out: &mut Compiled,
1596) -> Option<yog_formats::def::RecipeKindDef> {
1597    use yog_formats::def::Def;
1598    let doc = match yog_data::text::parse(text) {
1599        Ok(d) => d,
1600        Err(e) => {
1601            cx.err(e.at.line as usize, e.message);
1602            return None;
1603        }
1604    };
1605    let read = match yog_data::schema::from_doc::<Def>(text, &doc) {
1606        Ok(r) => r,
1607        Err(errors) => {
1608            for e in errors.0 {
1609                let l = e.at.map_or(1, |a| a.line as usize);
1610                if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
1611            }
1612            return None;
1613        }
1614    };
1615    let mut ok = true;
1616    for f in &read.refs {
1617        // A tag's `optional` entries are other mods' — there or not.
1618        if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1619            continue;
1620        }
1621        if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
1622            && let Err(e) = known.check(kind, &f.value.id)
1623        {
1624            cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1625            ok = false;
1626        }
1627    }
1628    for f in &read.tags {
1629        if matches!(read.value, Def::Tag(_)) && f.path.to_string().starts_with("optional") {
1630            continue;
1631        }
1632        if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
1633            cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
1634            ok = false;
1635        }
1636    }
1637    let ns = mod_ns.unwrap_or("minecraft");
1638    let kind = match read.value {
1639        Def::RecipeKind(mut k) => {
1640            if !k.id.contains(':') {
1641                k.id = format!("{ns}:{}", k.id);
1642            }
1643            if k.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1644                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));
1645                ok = false;
1646            }
1647            k
1648        }
1649        Def::Ore(o) => {
1650            if ok {
1651                ores.push(ore(o, ns));
1652            }
1653            return None;
1654        }
1655        Def::Dimension(d) => {
1656            if ok {
1657                dimensions.push(dimension(d, ns));
1658            }
1659            return None;
1660        }
1661        Def::Tag(t) => {
1662            let line = doc.locate(text, &yog_data::Path::root().key("values")).line as usize;
1663            match tag_entries(&t, ns) {
1664                Ok((path, entries)) => out.tags.entry(path).or_default().extend(entries),
1665                Err(problems) => problems.into_iter().for_each(|p| cx.err(line, p)),
1666            }
1667            return None;
1668        }
1669        Def::DamageType(d) => {
1670            match damage_type(&d, ns) {
1671                Ok((file, tags)) => {
1672                    out.outputs.push(file);
1673                    for (path, id) in tags {
1674                        out.tags.entry(path).or_default().insert(id, true);
1675                    }
1676                }
1677                Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1678            }
1679            return None;
1680        }
1681        Def::Tree(t) => {
1682            match tree(&t, ns) {
1683                Ok(file) => {
1684                    if ok {
1685                        out.outputs.push(file);
1686                    }
1687                }
1688                Err(e) => cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, e),
1689            }
1690            return None;
1691        }
1692        Def::LootModifier(mut m) => {
1693            m.id = full_id(&m.id, ns);
1694            if m.id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1695                cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{}`: the mod's own, `{ns}:<name>`", m.id));
1696                ok = false;
1697            }
1698            if ok {
1699                use yog_data::Schema;
1700                loot.push((m.id.clone(), yog_data::binary::encode(&m.write())));
1701            }
1702            return None;
1703        }
1704        // A table of its own: an empty one in the datapack (so it exists for
1705        // `/loot` and other tables), filled by a modifier on it.
1706        // Registered with the game: the host reads them from the content.
1707        Def::Attribute(mut a) => {
1708            a.id = full_id(&a.id, ns);
1709            content_def(cx, &doc, text, ns, "attribute", a.id.clone(), &a, ok, definitions);
1710            return None;
1711        }
1712        Def::Effect(mut e) => {
1713            e.id = full_id(&e.id, ns);
1714            content_def(cx, &doc, text, ns, "effect", e.id.clone(), &e, ok, definitions);
1715            return None;
1716        }
1717        Def::Potion(mut p) => {
1718            p.id = full_id(&p.id, ns);
1719            content_def(cx, &doc, text, ns, "potion", p.id.clone(), &p, ok, definitions);
1720            return None;
1721        }
1722        Def::LootTable(t) => {
1723            let id = full_id(&t.id, ns);
1724            let Some(path) = id.strip_prefix(&format!("{ns}:")).filter(|p| valid_path(p)) else {
1725                cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1726                return None;
1727            };
1728            if ok {
1729                use yog_data::Schema;
1730                out.outputs.push((format!("data/{ns}/loot_table/{path}.json"), pretty(&json!({ "type": "minecraft:generic", "pools": [] }))));
1731                let fill = yog_registry::LootModifier { id: id.clone(), tables: vec![id.clone()], add: t.add, scale: Vec::new() };
1732                loot.push((id, yog_data::binary::encode(&fill.write())));
1733            }
1734            return None;
1735        }
1736        Def::Table(t) => {
1737            match table::check(&cx.file, t, cx.refs, known) {
1738                Ok(t) => tables.push(t),
1739                Err(errors) => {
1740                    for e in errors {
1741                        // `rows.wood.size: …` — at its line.
1742                        let path = e.split_once(':').map_or("", |(p, _)| p);
1743                        let at = path.split('.').fold(yog_data::Path::root(), |at, k| at.key(k));
1744                        cx.err(doc.locate(text, &at).line as usize, e);
1745                    }
1746                }
1747            }
1748            return None;
1749        }
1750        _ => return None,
1751    };
1752    ok.then_some(kind)
1753}
1754
1755/// The builder calls (`.max_stack(16)`…) for an item's properties; empty
1756/// when it has none of its own.
1757fn item_props(p: &ItemProps) -> String {
1758    let mut b = String::new();
1759    if let Some(s) = p.stack_size {
1760        let _ = write!(b, "\n            .max_stack({s})");
1761    }
1762    if p.durability > 0 {
1763        let _ = write!(b, "\n            .max_damage({})", p.durability);
1764    }
1765    if p.fire_resistant {
1766        b.push_str("\n            .fire_resistant()");
1767    }
1768    if p.fuel_ticks > 0 {
1769        let _ = write!(b, "\n            .fuel({})", p.fuel_ticks);
1770    }
1771    if let Some((kind, material, d, s)) = &p.tool {
1772        let _ = write!(b, "\n            .tool({kind:?}, {material:?}, {d:?}, {s:?})");
1773    }
1774    if let Some(slot) = &p.wearable {
1775        let _ = write!(b, "\n            .wearable({slot:?})");
1776    }
1777    if let Some((slot, material)) = &p.armor {
1778        let _ = write!(b, "\n            .armor({slot:?}, {material:?})");
1779    }
1780    if let Some((n, s, always)) = p.food {
1781        let _ = write!(
1782            b,
1783            "\n            .food(::yog_api::FoodDef::new({n}, {}){})",
1784            float(s),
1785            if always { ".can_always_eat()" } else { "" }
1786        );
1787    }
1788    b
1789}
1790
1791/// A definition the game registers (`attribute`, `effect`, `potion`): the
1792/// mod's own id, kept as data for the code to register.
1793#[allow(clippy::too_many_arguments)]
1794fn content_def<T: yog_data::Schema>(
1795    cx: &mut Ctx,
1796    doc: &yog_data::text::Doc,
1797    text: &str,
1798    ns: &str,
1799    kind: &'static str,
1800    id: String,
1801    def: &T,
1802    ok: bool,
1803    definitions: &mut Vec<(&'static str, String, Vec<u8>)>,
1804) {
1805    if id.split_once(':').is_none_or(|(n, p)| n != ns || !valid_path(p)) {
1806        cx.err(doc.locate(text, &yog_data::Path::root().key("id")).line as usize, format!("id: `{id}`: the mod's own, `{ns}:<name>`"));
1807        return;
1808    }
1809    if ok {
1810        definitions.push((kind, id, yog_data::binary::encode(&def.write())));
1811    }
1812}
1813
1814/// A definition's file under `.yog-build/generated/`.
1815fn definition_path(kind: &str, id: &str) -> String {
1816    format!("{kind}/{}.ydata", id.replace([':', '/'], "__"))
1817}
1818
1819/// A loot modifier's file under `.yog-build/generated/`.
1820fn loot_path(id: &str) -> String {
1821    format!("loot/{}.ydata", id.replace([':', '/'], "__"))
1822}
1823
1824/// `ns:path` for an id the mod writes bare (`water_plants`).
1825fn full_id(id: &str, ns: &str) -> String {
1826    if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") }
1827}
1828
1829/// A deferred check of a tag of content `<kinds>`: `tag:item`, `tag:block|item`.
1830const TAG_REF: &str = "tag:";
1831
1832/// A `.ydef` tag: where it's written and its entries (`optional` ones
1833/// kept only if present). What it names was checked with the file's refs.
1834fn tag_entries(t: &yog_formats::def::TagDef, ns: &str) -> Result<(String, BTreeMap<String, bool>), Vec<String>> {
1835    use yog_data::AnyRefOrTag;
1836    let id = full_id(&t.id, ns);
1837    let (tns, tpath) = id.split_once(':').unwrap_or(("minecraft", &id));
1838    let mut problems = Vec::new();
1839    let mut entries = BTreeMap::new();
1840    for (list, required) in [(&t.values, true), (&t.optional, false)] {
1841        for v in list {
1842            if v.kind() != t.of {
1843                problems.push(format!("`{v}` in a tag of {}", t.of));
1844                continue;
1845            }
1846            let (entry, required) = match v {
1847                AnyRefOrTag::One(r) => (full_id(&r.id, "minecraft"), required),
1848                // A tag only some versions or loaders have: where it isn't, the rest still counts.
1849                AnyRefOrTag::Tag(r) => {
1850                    let tag = full_id(&r.id, "minecraft");
1851                    let everywhere = !matches!(refs::tag_hosts(refs::tag_registry(&t.of), &tag), Some(Some(_)));
1852                    (format!("#{tag}"), required && everywhere)
1853                }
1854            };
1855            entries.insert(entry, required);
1856        }
1857    }
1858    if problems.is_empty() {
1859        Ok((format!("data/{tns}/tags/{}/{tpath}.json", refs::tag_registry(&t.of)), entries))
1860    } else {
1861        Err(problems)
1862    }
1863}
1864
1865/// A `.ydef` tree: the game's `minecraft:tree` configured feature.
1866fn tree(t: &yog_formats::def::TreeDef, ns: &str) -> Result<Output, String> {
1867    use serde_json::json;
1868    let id = full_id(&t.id, ns);
1869    let (tns, tpath) = id.split_once(':').unwrap_or((ns, &id));
1870    if tns != ns || !valid_path(tpath) {
1871        return Err(format!("id: `{}`: the mod's own tree, `{ns}:<name>`", t.id));
1872    }
1873    // One block, or blocks by weight.
1874    let provider = |blocks: &[yog_formats::def::WeightedBlock]| match blocks {
1875        [one] => json!({ "type": "minecraft:simple_state_provider", "state": { "Name": one.block.id } }),
1876        many => json!({
1877            "type": "minecraft:weighted_state_provider",
1878            "entries": many.iter().map(|b| json!({ "data": { "Name": b.block.id }, "weight": b.weight })).collect::<Vec<_>>()
1879        }),
1880    };
1881    // The game's placers by their short name (`fancy`), a mod's by id.
1882    let placer = |shape: &str, what: &str| if shape.contains(':') { shape.to_string() } else { format!("minecraft:{shape}_{what}_placer") };
1883    if t.trunk.blocks.is_empty() || t.leaves.blocks.is_empty() {
1884        return Err("a tree needs trunk and leaves blocks".into());
1885    }
1886    let mut foliage = json!({ "type": placer(&t.leaves.shape, "foliage"), "radius": t.leaves.radius, "offset": t.leaves.offset });
1887    if let Some(h) = t.leaves.height {
1888        foliage["height"] = h.into();
1889    }
1890    let mut size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 1, "lower_size": 0, "upper_size": 1 });
1891    if let Some(h) = t.min_clipped_height {
1892        size = json!({ "type": "minecraft:two_layers_feature_size", "limit": 0, "lower_size": 0, "upper_size": 0, "min_clipped_height": h });
1893    }
1894    let dirt = t.dirt.as_ref().map_or("minecraft:dirt", |b| b.id.as_str());
1895    let v = json!({
1896        "type": "minecraft:tree",
1897        "config": {
1898            "trunk_provider": provider(&t.trunk.blocks),
1899            "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 },
1900            "foliage_provider": provider(&t.leaves.blocks),
1901            "foliage_placer": foliage,
1902            "dirt_provider": { "type": "minecraft:simple_state_provider", "state": { "Name": dirt } },
1903            "minimum_size": size,
1904            "decorators": [],
1905            "ignore_vines": t.ignore_vines,
1906            "force_dirt": t.force_dirt
1907        }
1908    });
1909    Ok((format!("data/{tns}/worldgen/configured_feature/{tpath}.json"), pretty(&v)))
1910}
1911
1912/// A `.ydef` damage type: its `damage_type/` file, and the damage type
1913/// tags it joins (path, its id).
1914fn damage_type(d: &yog_formats::def::DamageTypeDef, ns: &str) -> Result<(Output, Vec<(String, String)>), String> {
1915    let id = full_id(&d.id, ns);
1916    let (dns, dpath) = id.split_once(':').unwrap_or((ns, &id));
1917    if dns != ns || !valid_path(dpath) {
1918        return Err(format!("id: `{}`: the mod's own damage type, `{ns}:<name>`", d.id));
1919    }
1920    let mut v = serde_json::json!({
1921        "message_id": d.message_id.clone().unwrap_or_else(|| format!("{dns}.{dpath}")),
1922        "scaling": d.scaling,
1923        "exhaustion": d.exhaustion,
1924    });
1925    if let Some(e) = &d.effects {
1926        v["effects"] = Value::String(e.clone());
1927    }
1928    if let Some(m) = &d.death_message_type {
1929        v["death_message_type"] = Value::String(m.clone());
1930    }
1931    let tags = d
1932        .tags
1933        .iter()
1934        .map(|t| {
1935            let t = full_id(t, "minecraft");
1936            let (tns, tpath) = t.split_once(':').unwrap_or(("minecraft", &t));
1937            (format!("data/{tns}/tags/damage_type/{tpath}.json"), id.clone())
1938        })
1939        .collect();
1940    Ok(((format!("data/{dns}/damage_type/{dpath}.json"), pretty(&v)), tags))
1941}
1942
1943/// The mod's recipe layouts (`.yui` `<Component recipe_layout="…">`),
1944/// checked: each kind the game's or one the mod declares, laid out once;
1945/// a mod kind's slots its own; stations known items.
1946fn recipe_layouts(
1947    root: &Path,
1948    ns: &str,
1949    kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
1950    known: &refs::Known,
1951    errors: &mut Vec<String>,
1952) -> Vec<yog_book::layout::RecipeLayout> {
1953    use yog_book::layout::LayoutPart;
1954    let mut laid_out: HashMap<String, String> = HashMap::new();
1955    let mut out = Vec::new();
1956    for (shown, l) in crate::book_page::find_layouts(root, ns, errors) {
1957        let mut ok = true;
1958        for k in &l.kinds {
1959            let game = yog_formats::recipe::TYPES.contains(&k.as_str());
1960            if !game && !kinds.contains_key(k) {
1961                errors.push(format!(
1962                    "{shown}: recipe_layout: `{k}`: one of {}, or a kind the mod declares (a .ydef of kind recipe_kind)",
1963                    yog_formats::recipe::TYPES.join(", ")
1964                ));
1965                ok = false;
1966            }
1967            if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
1968                errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
1969                ok = false;
1970            }
1971        }
1972        for part in &l.parts {
1973            match part {
1974                LayoutPart::Slots { slot, .. } => {
1975                    for k in l.kinds.iter().filter_map(|k| kinds.get(k)) {
1976                        if !k.slots.0.iter().any(|(s, _)| s == slot) {
1977                            let all: Vec<&str> = k.slots.0.iter().map(|(s, _)| s.as_str()).collect();
1978                            errors.push(format!("{shown}: <RecipeSlot slot=\"{slot}\">: {} has no slot `{slot}` — {}", k.id, all.join(", ")));
1979                            ok = false;
1980                        }
1981                    }
1982                }
1983                LayoutPart::Item { item, .. } => {
1984                    if let Err(e) = known.check(refs::RefKind::Item, item) {
1985                        errors.push(format!("{shown}: <ItemSlot item=\"{item}\">: {e}"));
1986                        ok = false;
1987                    }
1988                }
1989                _ => {}
1990            }
1991        }
1992        if ok {
1993            out.push(l);
1994        }
1995    }
1996    out
1997}
1998
1999/// A dimension as the runtime and the hosts read it (Yog Data, binary):
2000/// its type, biome, flags, the generator that makes its chunks — its
2001/// `terrain`'s (the runtime's own, under the dimension's id) or the mod's.
2002fn dimension(d: yog_formats::def::DimensionDef, ns: &str) -> (String, Vec<u8>) {
2003    use yog_data::{Map, Schema, Value as V};
2004    let id = if d.id.contains(':') { d.id.clone() } else { format!("{ns}:{}", d.id) };
2005    let mut m = Map::new();
2006    m.insert("id", V::from(id.as_str()));
2007    m.insert("dimension_type", d.dimension_type.write());
2008    m.insert("biome", V::from(d.biome.map_or("minecraft:plains".to_string(), |b| b.id).as_str()));
2009    m.insert("structures", V::Bool(d.structures));
2010    m.insert("features", V::Bool(d.features));
2011    m.insert("mob_spawning", V::Bool(d.mob_spawning));
2012    if let Some(t) = &d.terrain {
2013        m.insert("generator_type", V::from(id.as_str()));
2014        m.insert("terrain", t.write());
2015    } else if let Some(g) = &d.generator {
2016        m.insert("generator_type", V::from(if g.contains(':') { g.clone() } else { format!("{ns}:{g}") }.as_str()));
2017    }
2018    (id, yog_data::binary::encode(&V::Map(m)))
2019}
2020
2021/// What an ore makes: its files, and the biome feature (feature, biomes, step) for Fabric's host.
2022type OreOut = (Vec<Output>, (String, String, String));
2023
2024/// An ore: its configured and placed features (the same on every version),
2025/// the biome modifiers Forge and NeoForge read (each its own), and the
2026/// biome feature the build registers for Fabric's host.
2027fn ore(o: yog_formats::def::OreDef, ns: &str) -> OreOut {
2028    use serde_json::json;
2029    let p = o.id.split_once(':').map_or(o.id.as_str(), |(_, p)| p).to_string();
2030    let id = format!("{ns}:{p}");
2031    let targets: Vec<Value> = o
2032        .targets
2033        .iter()
2034        .map(|t| {
2035            let target = match t.replaces.strip_prefix('#') {
2036                Some(tag) => json!({ "predicate_type": "minecraft:tag_match", "tag": tag }),
2037                None => json!({ "predicate_type": "minecraft:block_match", "block": t.replaces }),
2038            };
2039            json!({ "target": target, "state": { "Name": t.block.id } })
2040        })
2041        .collect();
2042    let configured = json!({
2043        "type": "minecraft:ore",
2044        "config": { "size": o.size, "discard_chance_on_air_exposure": o.air_exposure, "targets": targets }
2045    });
2046    let shape = match o.height.shape {
2047        yog_formats::def::HeightShape::Uniform => "minecraft:uniform",
2048        yog_formats::def::HeightShape::Triangle => "minecraft:trapezoid",
2049    };
2050    let placed = json!({
2051        "feature": id,
2052        "placement": [
2053            { "type": "minecraft:count", "count": o.per_chunk },
2054            { "type": "minecraft:in_square" },
2055            { "type": "minecraft:height_range", "height": { "type": shape, "min_inclusive": { "absolute": o.height.min }, "max_inclusive": { "absolute": o.height.max } } },
2056            { "type": "minecraft:biome" }
2057        ]
2058    });
2059    let biomes = o.biomes.map(|b| b.id).unwrap_or_else(|| "minecraft:is_overworld".into());
2060    let modifier = |kind: &str| json!({ "type": kind, "biomes": format!("#{biomes}"), "features": id, "step": "underground_ores" });
2061    let mut files: Vec<Output> = Vec::new();
2062    let mut put = |path: String, v: Value| files.push((path, pretty(&v)));
2063    put(format!("data/{ns}/worldgen/configured_feature/{p}.json"), configured);
2064    put(format!("data/{ns}/worldgen/placed_feature/{p}.json"), placed);
2065    for (dir, kind) in [("when/forge/any", "forge:add_features"), ("when/neoforge/1.20", "forge:add_features")] {
2066        put(format!("{dir}/data/{ns}/forge/biome_modifier/{p}.json"), modifier(kind));
2067    }
2068    put(format!("when/neoforge/1.21/data/{ns}/neoforge/biome_modifier/{p}.json"), modifier("neoforge:add_features"));
2069    (files, (id, biomes, "underground_ores".into()))
2070}
2071
2072/// One checked `.yrecipe`.
2073#[derive(Debug, Clone)]
2074struct Recipe {
2075    id: String,
2076    /// Its `type`: `shaped`, `smelting`, …, or the mod's own kind.
2077    ty: String,
2078    kind: RecipeKind,
2079    result: String,
2080    count: u32,
2081    group: Option<String>,
2082    category: Option<String>,
2083    /// Mods it needs (`requires`): registered only when they're all loaded.
2084    requires: Vec<String>,
2085}
2086
2087impl Recipe {
2088    /// The `registry.add_recipe(...)` call registering it.
2089    fn registration(&self) -> String {
2090        let list = |items: &[String]| {
2091            let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
2092            format!("vec![{}]", quoted.join(", "))
2093        };
2094        let kind = match &self.kind {
2095            RecipeKind::Shaped { pattern, key } => {
2096                let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
2097                let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
2098                format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
2099            }
2100            RecipeKind::Shapeless(ingredients) => {
2101                let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
2102                format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
2103            }
2104            RecipeKind::Cooking { method, input, experience, cook_time } => format!(
2105                "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
2106                list(input),
2107                float(*experience)
2108            ),
2109            RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
2110            RecipeKind::Custom { kind, slots, values } => {
2111                // Yog's recipe JSON as is: the host keeps it out of the game's data.
2112                let mut o = Map::new();
2113                o.insert("type".into(), kind.clone().into());
2114                o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
2115                if !values.is_empty() {
2116                    o.insert("values".into(), Value::Object(values.clone()));
2117                }
2118                o.insert("result".into(), self.result.clone().into());
2119                if self.count != 1 {
2120                    o.insert("count".into(), self.count.into());
2121                }
2122                if let Some(g) = &self.group {
2123                    o.insert("group".into(), g.clone().into());
2124                }
2125                return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
2126            }
2127            RecipeKind::Smithing { template, base, addition } => format!(
2128                "Smithing {{ template: {}, base: {}, addition: {} }}",
2129                list(template),
2130                list(base),
2131                list(addition)
2132            ),
2133        };
2134        let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
2135        format!(
2136            "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
2137            self.id,
2138            self.result,
2139            self.count,
2140            opt(&self.group),
2141            opt(&self.category)
2142        )
2143    }
2144}
2145
2146/// Checks one `.yrecipe` (see `content::recipe`): its schema, then what it
2147/// names — the mod's content, every Minecraft version's, other mods'
2148/// declared ids (`known`).
2149fn compile_recipe(
2150    cx: &mut Ctx,
2151    text: &str,
2152    path: &Path,
2153    mod_ns: Option<&str>,
2154    known: &refs::Known,
2155    kinds: &HashMap<String, yog_formats::def::RecipeKindDef>,
2156) -> Option<Recipe> {
2157    use yog_formats::recipe::{Body, RecipeFile};
2158    use yog_data::Path as At;
2159
2160    let doc = match yog_data::text::parse(text) {
2161        Ok(d) => d,
2162        Err(e) => {
2163            cx.err(e.at.line as usize, e.message);
2164            return None;
2165        }
2166    };
2167    let line = |p: &At| doc.locate(text, p).line as usize;
2168    let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
2169        Ok(r) => r,
2170        Err(errors) => {
2171            for e in errors.0 {
2172                let l = e.at.map_or(1, |a| a.line as usize);
2173                if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
2174            }
2175            return None;
2176        }
2177    };
2178    let mut ok = true;
2179    for f in &read.refs {
2180        if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
2181            && let Err(e) = known.check(kind, &f.value.id)
2182        {
2183            cx.err(line(&f.path), format!("{}: {e}", f.path));
2184            ok = false;
2185        }
2186    }
2187    for f in &read.tags {
2188        if let Err(e) = known.check_tag(&f.value.kind, &f.value.id) {
2189            cx.err(line(&f.path), format!("{}: {e}", f.path));
2190            ok = false;
2191        }
2192    }
2193    // What's only on some versions or loaders keeps the recipe out of the others.
2194    if ok {
2195        for (missing, why) in recipe_gaps(&read.refs, &read.tags) {
2196            cx.warn(line(&At::root()), format!("left out on {}: {why}", refs::describe_hosts(&missing)));
2197        }
2198    }
2199    let RecipeFile { common: c, body } = read.value;
2200    let ty = match &body {
2201        Body::Shaped(_) => "shaped".to_string(),
2202        Body::Shapeless(_) => "shapeless".to_string(),
2203        Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
2204        Body::Stonecutting(_) => "stonecutting".to_string(),
2205        Body::Smithing(_) => "smithing".to_string(),
2206    };
2207    let root = At::root();
2208    if let Some(f) = c.format
2209        && f != 1
2210    {
2211        cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
2212        ok = false;
2213    }
2214    // Its id: as written, else the file's name; the mod's namespace by default.
2215    let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
2216    let id_line = line(&root.key("id"));
2217    let (ns, p) = match raw.split_once(':') {
2218        Some((ns, p)) => (ns.to_string(), p.to_string()),
2219        None => match mod_ns {
2220            Some(ns) => (ns.to_string(), raw.clone()),
2221            None => {
2222                cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
2223                return None;
2224            }
2225        },
2226    };
2227    if !valid_namespace(&ns) || !valid_path(&p) {
2228        cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
2229        return None;
2230    }
2231    if !(1..=64).contains(&c.count) {
2232        cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
2233        ok = false;
2234    }
2235    let kind = match body {
2236        Body::Shaped(s) => {
2237            let width = s.pattern.first().map_or(0, |r| r.chars().count());
2238            if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
2239                cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
2240                return None;
2241            }
2242            let mut key = Vec::new();
2243            for (k, ing) in &s.key.0 {
2244                let at = line(&root.key("key").key(k));
2245                let mut chars = k.chars();
2246                match (chars.next(), chars.next()) {
2247                    (Some(ch), None) if ch != ' ' => {
2248                        if !s.pattern.iter().any(|r| r.contains(ch)) {
2249                            cx.warn(at, format!("key {k:?} isn't used by the pattern"));
2250                        }
2251                        key.push((ch, ing.ids()));
2252                    }
2253                    _ => {
2254                        cx.err(at, format!("key {k:?}: one character other than space"));
2255                        ok = false;
2256                    }
2257                }
2258            }
2259            for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
2260                if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
2261                    cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
2262                    return None;
2263                }
2264            }
2265            RecipeKind::Shaped { pattern: s.pattern, key }
2266        }
2267        Body::Shapeless(s) => {
2268            if s.ingredients.is_empty() || s.ingredients.len() > 9 {
2269                cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
2270                return None;
2271            }
2272            RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
2273        }
2274        Body::Cooking(ty, k) => {
2275            let (method, default_time) = match ty.as_str() {
2276                "smelting" => ("Smelting", 200),
2277                "blasting" => ("Blasting", 100),
2278                "smoking" => ("Smoking", 100),
2279                _ => ("Campfire", 600),
2280            };
2281            if !(0.0..=1000.0).contains(&k.experience) {
2282                cx.err(line(&root.key("experience")), "experience: 0–1000");
2283                ok = false;
2284            }
2285            let cook_time = k.cook_time.unwrap_or(default_time);
2286            if !(1..=32767).contains(&cook_time) {
2287                cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
2288                ok = false;
2289            }
2290            RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
2291        }
2292        Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
2293        Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
2294        Body::Custom(kind, k) => {
2295            // The mod's own kind of recipe: `<mod id>:<kind>`.
2296            let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
2297            if !own {
2298                cx.err(
2299                    line(&root.key("type")),
2300                    format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", yog_formats::recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
2301                );
2302                return None;
2303            }
2304            if c.category.is_some() {
2305                cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
2306                ok = false;
2307            }
2308            // What its kind declares (`.ydef` recipe_kind).
2309            let Some(def) = kinds.get(&kind) else {
2310                let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
2311                cx.err(line(&root.key("type")), format!("type `{kind}`: no such kind — declare it: content/{file}.ydef, {{ kind: \"recipe_kind\", id: \"{file}\", slots: {{ … }} }}"));
2312                return None;
2313            };
2314            for (name, ings) in &k.slots.0 {
2315                let at = root.key("slots").key(name);
2316                match def.slots.0.iter().find(|(n, _)| n == name) {
2317                    None => {
2318                        let names: Vec<&str> = def.slots.0.iter().map(|(n, _)| n.as_str()).collect();
2319                        cx.err(line(&at), format!("slots.{name}: {kind} has no slot `{name}` ({})", names.join(", ")));
2320                        ok = false;
2321                    }
2322                    Some((_, spec)) if ings.len() > spec.max as usize => {
2323                        cx.err(line(&at), format!("slots.{name}: at most {} ingredient(s), found {}", spec.max, ings.len()));
2324                        ok = false;
2325                    }
2326                    _ => {}
2327                }
2328            }
2329            for (name, spec) in &def.slots.0 {
2330                if spec.required && !k.slots.0.iter().any(|(n, ings)| n == name && !ings.is_empty()) {
2331                    cx.err(line(&root.key("slots")), format!("slots: {kind} needs `{name}`"));
2332                    ok = false;
2333                }
2334            }
2335            for (name, v) in &k.values.0 {
2336                let at = root.key("values").key(name);
2337                let fits = match def.values.0.iter().find(|(n, _)| n == name).map(|(_, t)| *t) {
2338                    None => {
2339                        let names: Vec<&str> = def.values.0.iter().map(|(n, _)| n.as_str()).collect();
2340                        cx.err(line(&at), format!("values.{name}: {kind} has no value `{name}` ({})", names.join(", ")));
2341                        ok = false;
2342                        continue;
2343                    }
2344                    Some(yog_formats::def::ValueType::Number) => v.as_f64().is_some(),
2345                    Some(yog_formats::def::ValueType::Bool) => v.as_bool().is_some(),
2346                    Some(yog_formats::def::ValueType::Text) => v.as_str().is_some(),
2347                };
2348                if !fits {
2349                    cx.err(line(&at), format!("values.{name}: a {}", yog_formats::content::word(&def.values.0.iter().find(|(n, _)| n == name).unwrap().1)));
2350                    ok = false;
2351                }
2352            }
2353            let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
2354            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();
2355            RecipeKind::Custom { kind, slots, values }
2356        }
2357    };
2358    ok.then(|| Recipe {
2359        id: format!("{ns}:{p}"),
2360        ty,
2361        kind,
2362        result: c.result.id,
2363        count: c.count,
2364        group: c.group,
2365        category: c.category,
2366        requires: c.requires,
2367    })
2368}
2369
2370/// The hosts (`<loader>/<version>`) a recipe naming `refs` and `tags` can't
2371/// be on, with why: an item or tag one hasn't got. Alternatives (`["a",
2372/// "#b"]`, one list in the file) count as there when any of them is.
2373fn recipe_gaps(items: &[yog_data::schema::Found<yog_data::Ref>], tags: &[yog_data::schema::Found<yog_data::Ref>]) -> Vec<(BTreeSet<String>, String)> {
2374    use refs::RefKind;
2375    // Where each reference is (`None`: everywhere), grouped by the list it's in.
2376    let mut lists: BTreeMap<String, Vec<(String, Option<BTreeSet<String>>)>> = BTreeMap::new();
2377    let parent = |p: &yog_data::Path| {
2378        let mut p = p.clone();
2379        if matches!(p.segments().last(), Some(yog_data::Seg::Index(_))) {
2380            p.pop();
2381        }
2382        p.to_string()
2383    };
2384    for f in items {
2385        let Some(kind) = RefKind::from_word(&f.value.kind) else { continue };
2386        if !f.value.id.starts_with("minecraft:") {
2387            continue;
2388        }
2389        let on: BTreeSet<String> = refs::hosts()
2390            .iter()
2391            .filter(|h| h.split_once('/').is_some_and(|(_, v)| refs::equivalent(kind, &f.value.id, v).is_some()))
2392            .cloned()
2393            .collect();
2394        let on = (on.len() < refs::hosts().len()).then_some(on);
2395        lists.entry(parent(&f.path)).or_default().push((f.value.id.clone(), on));
2396    }
2397    for f in tags {
2398        let Some(on) = refs::tag_hosts(refs::tag_registry(&f.value.kind), &f.value.id) else { continue };
2399        lists.entry(parent(&f.path)).or_default().push((format!("#{}", f.value.id), on));
2400    }
2401    let mut out = Vec::new();
2402    for alts in lists.values() {
2403        if alts.iter().any(|(_, on)| on.is_none()) {
2404            continue;
2405        }
2406        let on: BTreeSet<String> = alts.iter().flat_map(|(_, on)| on.iter().flatten().cloned()).collect();
2407        let missing: BTreeSet<String> = refs::hosts().iter().filter(|h| !on.contains(*h)).cloned().collect();
2408        if missing.is_empty() {
2409            continue;
2410        }
2411        let names: Vec<&str> = alts.iter().map(|(id, _)| id.as_str()).collect();
2412        // A Yog tag that's everywhere, if one is these.
2413        let hint = alts
2414            .iter()
2415            .filter_map(|(id, _)| id.strip_prefix('#').and_then(|t| refs::yog_tag_for("item", t)))
2416            .find(|y| refs::tag_hosts("item", y) == Some(None))
2417            .map(|y| format!(" — @tag:item:{y} is on every version and loader"))
2418            .unwrap_or_default();
2419        out.push((missing, format!("{} isn't there{hint}", names.join(" / "))));
2420    }
2421    out
2422}
2423
2424/// What the `.ydef`s define: every ladder, its levels bottom to top (the
2425/// game's tool ladder and the mod's), and the mod's sound groups.
2426struct Defs {
2427    order: BTreeMap<String, Vec<String>>,
2428    sound_groups: Vec<yog_formats::def::SoundGroup>,
2429    tool_materials: Vec<yog_formats::def::ToolMaterial>,
2430    armor_materials: Vec<yog_formats::def::ArmorMaterial>,
2431    /// Placed features the mod adds to biomes (its ores), for the hosts that add them in code.
2432    biome_features: Vec<(String, String, String)>,
2433    /// Its dimensions' ids (their data in `generated/dimensions/`).
2434    dimensions: Vec<String>,
2435    /// Its tables (`kind: "table"`), each an enum in its code.
2436    tables: Vec<table::Table>,
2437    /// Its loot modifiers' ids (their data in `generated/loot/`).
2438    loot_modifiers: Vec<String>,
2439    /// Its attributes, effects and potions: (kind, id).
2440    definitions: Vec<(&'static str, String)>,
2441}
2442
2443impl Defs {
2444    /// A level's ladder and place on it.
2445    fn level(&self, id: &str) -> Option<(&str, usize)> {
2446        self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
2447    }
2448}
2449
2450/// Reads the `.ydef`s: ladders, levels placed on them (`above`, in any
2451/// order of files) and sound groups.
2452fn read_defs(defs: &[(String, String)], mod_ns: Option<&str>, errors: &mut Vec<String>) -> Defs {
2453    let mut order: BTreeMap<String, Vec<String>> = BTreeMap::new();
2454    let mut sound_groups: Vec<yog_formats::def::SoundGroup> = Vec::new();
2455    order.insert(yog_formats::def::TOOL_LADDER.into(), yog_formats::def::TOOL_LEVELS.iter().map(|l| format!("minecraft:{l}")).collect());
2456    let ns = mod_ns.unwrap_or("minecraft");
2457    let full = |id: &str| if id.contains(':') { id.to_string() } else { format!("{ns}:{id}") };
2458    // (file, line, id, ladder, above)
2459    let mut levels: Vec<(String, usize, String, String, Option<String>)> = Vec::new();
2460    let mut tools: Vec<(String, yog_formats::def::ToolMaterialDef)> = Vec::new();
2461    let mut armor_materials: Vec<yog_formats::def::ArmorMaterial> = Vec::new();
2462    let repair = |r: &yog_data::ItemIdOrTag| match r {
2463        yog_data::OneOrTag::One(i) => i.id.clone(),
2464        yog_data::OneOrTag::Tag(t) => format!("#{}", t.id),
2465    };
2466    for (file, text) in defs {
2467        let doc = match yog_data::text::parse(text) {
2468            Ok(d) => d,
2469            Err(e) => {
2470                errors.push(format!("{file}:{}: {}", e.at.line, e.message));
2471                continue;
2472            }
2473        };
2474        match yog_data::schema::from_doc::<yog_formats::def::Def>(text, &doc) {
2475            Ok(r) => match r.value {
2476                yog_formats::def::Def::Ladder { id } => {
2477                    if order.insert(full(&id), Vec::new()).is_some() {
2478                        errors.push(format!("{file}:1: ladder {} is defined twice", full(&id)));
2479                    }
2480                }
2481                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)),
2482                yog_formats::def::Def::SoundGroup(mut g) => {
2483                    // Short ids — the group's and its sounds' — are the mod's.
2484                    for s in [&mut g.id, &mut g.break_sound, &mut g.step, &mut g.place, &mut g.hit, &mut g.fall] {
2485                        *s = full(s);
2486                    }
2487                    if yog_formats::def::SoundGroup::built_in(&g.id).is_some() {
2488                        errors.push(format!("{file}:1: sound group {} is the game's", g.id));
2489                    } else if sound_groups.iter().any(|x| x.id == g.id) {
2490                        errors.push(format!("{file}:1: sound group {} is defined twice", g.id));
2491                    } else {
2492                        sound_groups.push(g);
2493                    }
2494                }
2495                // Read with the recipes (`compile_layout`).
2496                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(_) | yog_formats::def::Def::Attribute(_) | yog_formats::def::Def::Effect(_) | yog_formats::def::Def::Potion(_) => {}
2497                // Its level is placed on the ladder below.
2498                yog_formats::def::Def::ToolMaterial(mut m) => {
2499                    m.id = full(&m.id);
2500                    tools.push((file.clone(), m));
2501                }
2502                yog_formats::def::Def::ArmorMaterial(m) => {
2503                    let id = full(&m.id);
2504                    if yog_formats::def::ArmorMaterial::built_in(&id).is_some() || armor_materials.iter().any(|x| x.id == id) {
2505                        errors.push(format!("{file}:1: armor material {id} is the game's or defined twice"));
2506                        continue;
2507                    }
2508                    armor_materials.push(yog_formats::def::ArmorMaterial {
2509                        texture: m.texture.as_deref().map(full).unwrap_or_else(|| id.clone()),
2510                        equip_sound: full(&m.equip_sound),
2511                        durability: m.durability,
2512                        defense: m.defense,
2513                        enchantability: m.enchantability,
2514                        toughness: m.toughness,
2515                        knockback_resistance: m.knockback_resistance,
2516                        repair: repair(&m.repair),
2517                        id,
2518                    });
2519                }
2520            },
2521            Err(es) => {
2522                for e in es.0 {
2523                    let l = e.at.map_or(1, |a| a.line as usize);
2524                    errors.push(if e.path.is_root() { format!("{file}:{l}: {}", e.message) } else { format!("{file}:{l}: {}: {}", e.path, e.message) });
2525                }
2526            }
2527        }
2528    }
2529    // Place each level once what it's above is placed.
2530    loop {
2531        let before = levels.len();
2532        levels.retain(|(file, line, id, ladder, above)| {
2533            let Some(list) = order.get_mut(ladder) else {
2534                errors.push(format!("{file}:{line}: level {id}: no ladder {ladder}"));
2535                return false;
2536            };
2537            let at = match above {
2538                None => 0,
2539                Some(a) => match list.iter().position(|x| x == a) {
2540                    Some(i) => i + 1,
2541                    None => return true, // later
2542                },
2543            };
2544            if list.contains(id) {
2545                errors.push(format!("{file}:{line}: level {id} is defined twice"));
2546            } else {
2547                list.insert(at, id.clone());
2548            }
2549            false
2550        });
2551        if levels.is_empty() || levels.len() == before {
2552            break;
2553        }
2554    }
2555    for (file, line, id, ladder, above) in levels {
2556        let above = above.unwrap_or_default();
2557        errors.push(format!("{file}:{line}: level {id}: above {above}, which isn't a level of {ladder} (or the levels go round in a circle)"));
2558    }
2559    // Tool materials: what their level is on each version — 1.20's level
2560    // number and 1.21's tag of what they can't mine, as blocks of their
2561    // level are tagged (`tool_level_tags`: the weakest of the game's levels
2562    // at or above it).
2563    let ladder = order.get(yog_formats::def::TOOL_LADDER).cloned().unwrap_or_default();
2564    let mut tool_materials: Vec<yog_formats::def::ToolMaterial> = Vec::new();
2565    for (file, m) in tools {
2566        if yog_formats::def::ToolMaterial::built_in(&m.id).is_some() || tool_materials.iter().any(|x| x.id == m.id) {
2567            errors.push(format!("{file}:1: tool material {} is the game's or defined twice", m.id));
2568            continue;
2569        }
2570        let Some(at) = ladder.iter().position(|l| l == &m.level) else {
2571            errors.push(format!("{file}:1: level: {} isn't a level of the tool ladder", m.level));
2572            continue;
2573        };
2574        let place = |v: &str| ladder.iter().position(|x| x == &format!("minecraft:{v}")).unwrap_or(0);
2575        let game = ["wood", "stone", "iron", "diamond", "netherite"];
2576        let (level_index, incorrect) = match game.iter().position(|v| place(v) >= at) {
2577            Some(i) => (i as u32, format!("minecraft:incorrect_for_{}_tool", if game[i] == "wood" { "wooden" } else { game[i] })),
2578            // Above netherite: a tag of its own (the build writes it).
2579            None => {
2580                let (n, p) = m.level.split_once(':').unwrap_or(("minecraft", &m.level));
2581                (5, format!("{n}:incorrect_for_{p}_tool"))
2582            }
2583        };
2584        tool_materials.push(yog_formats::def::ToolMaterial {
2585            id: m.id,
2586            durability: m.durability,
2587            speed: m.speed,
2588            attack_damage: m.attack_damage,
2589            enchantability: m.enchantability,
2590            level: m.level,
2591            repair: repair(&m.repair),
2592            level_index,
2593            incorrect,
2594        });
2595    }
2596    Defs { order, sound_groups, tool_materials, armor_materials, biome_features: Vec::new(), dimensions: Vec::new(), tables: Vec::new(), loot_modifiers: Vec::new(), definitions: Vec::new() }
2597}
2598
2599/// The tags that make block `id` need tool level `level`, on every version:
2600/// the game's `needs_*_tool` where they say it, and — for a level above
2601/// diamond — each version's own way (`incorrect_for_*_tool` on 1.21,
2602/// Fabric's `needs_tool_level_N` and Forge's `needs_netherite_tool` on 1.20).
2603fn tool_level_tags(id: &str, level: &str, ladders: &Defs, out: &mut Compiled, err: &mut dyn FnMut(String), warn: &mut dyn FnMut(String)) {
2604    let Some((ladder, at)) = ladders.level(level) else { return };
2605    if ladder != yog_formats::def::TOOL_LADDER {
2606        err(format!("{level} is a level of {ladder}, not of the tool ladder ({})", yog_formats::def::TOOL_LADDER));
2607        return;
2608    }
2609    let tools = &ladders.order[yog_formats::def::TOOL_LADDER];
2610    let place = |l: &str| tools.iter().position(|x| x == &format!("minecraft:{l}")).unwrap_or(0);
2611    // The weakest of the game's tools that mines it.
2612    let able = ["wood", "stone", "iron", "diamond", "netherite"].into_iter().find(|v| place(v) >= at);
2613    let mut tag = |path: String| {
2614        out.tags.entry(path).or_default().insert(id.to_string(), true);
2615    };
2616    match able {
2617        Some("wood") => {}
2618        Some(v @ ("stone" | "iron" | "diamond")) => tag(format!("data/minecraft/tags/block/needs_{v}_tool.json")),
2619        _ => {
2620            // Above diamond: diamond's tag everywhere, then each version's own.
2621            tag("data/minecraft/tags/block/needs_diamond_tool.json".into());
2622            tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json".into());
2623            tag(format!("when/fabric/1.20/data/fabric/tags/block/needs_tool_level_{}.json", if able.is_some() { 4 } else { 5 }));
2624            for loader in ["forge", "neoforge"] {
2625                tag(format!("when/{loader}/1.20/data/forge/tags/block/needs_netherite_tool.json"));
2626            }
2627            if able.is_none() {
2628                tag("when/any/1.21/data/minecraft/tags/block/incorrect_for_netherite_tool.json".into());
2629                warn("above netherite: on Forge and NeoForge 1.20 a netherite tool still mines it (their tiers stop at netherite)".into());
2630            }
2631        }
2632    }
2633}
2634
2635/// Ids of the `.yui` screens under `dir` that lay out an inventory (have
2636/// `<Slot>`s or a `<PlayerInventory>`).
2637fn inventory_screens(dir: &Path) -> BTreeSet<String> {
2638    let mut out = BTreeSet::new();
2639    let Ok(entries) = std::fs::read_dir(dir) else {
2640        return out;
2641    };
2642    for e in entries.flatten() {
2643        let path = e.path();
2644        if path.is_dir() {
2645            out.extend(inventory_screens(&path));
2646            continue;
2647        }
2648        if path.extension().is_none_or(|x| x != "yui") {
2649            continue;
2650        }
2651        let Ok(text) = std::fs::read_to_string(&path) else { continue };
2652        if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
2653            continue;
2654        }
2655        if let Some(screen) = text.find("<Screen")
2656            && let Some(at) = text[screen..].find(" id=\"")
2657        {
2658            let rest = &text[screen + at + 5..];
2659            if let Some(end) = rest.find('"') {
2660                out.insert(rest[..end].to_string());
2661            }
2662        }
2663    }
2664    out
2665}
2666
2667/// Compiles the mod's content files (see the module docs). Any error fails
2668/// the whole thing, listing every problem.
2669pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
2670    let mut out = Compiled::default();
2671    let files = find(root);
2672    if files.is_empty() {
2673        return Ok(out);
2674    }
2675    let mod_ns = valid_namespace(mod_id).then_some(mod_id);
2676    let mut errors: Vec<String> = Vec::new();
2677    let mut warnings: Vec<String> = Vec::new();
2678    let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
2679    let mut generated: BTreeMap<String, String> = BTreeMap::new();
2680    let mut entries: Vec<Entry> = Vec::new();
2681    let mut ids: HashMap<String, String> = HashMap::new();
2682    let mut recipes: Vec<Recipe> = Vec::new();
2683    let mut recipe_ids: HashMap<String, String> = HashMap::new();
2684    let mut books: Vec<BookFile> = Vec::new();
2685    let mut book_ids: HashMap<String, String> = HashMap::new();
2686    let mut bodies: Vec<BodyFile> = Vec::new();
2687    let mut entity_files: Vec<EntityFile> = Vec::new();
2688    let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
2689    let mut inventories: Vec<(String, usize, String)> = Vec::new();
2690    let mut pending_books: Vec<(String, String)> = Vec::new();
2691    let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
2692    let mut pending_layouts: Vec<(String, String)> = Vec::new();
2693    let mut pending_defs: Vec<(String, String)> = Vec::new();
2694    let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
2695    for path in &files {
2696        let shown = display(root, path);
2697        let Some(kind) = Kind::of(path) else { continue };
2698        let text = match std::fs::read_to_string(path) {
2699            Ok(t) => t,
2700            Err(e) => {
2701                errors.push(format!("{shown}: {e}"));
2702                continue;
2703            }
2704        };
2705        if matches!(kind, Kind::Model | Kind::Anim) {
2706            if let Some(b) = compile_body(&shown, &text, kind, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
2707                if let Some(other) = body_ids.insert((kind == Kind::Model, b.id.clone()), shown.clone()) {
2708                    errors.push(format!("{shown}: {} {} is also defined by {other}", kind.word(), b.id));
2709                }
2710                bodies.push(b);
2711            }
2712            continue;
2713        }
2714        if kind == Kind::Book {
2715            // Checked once every block, item and recipe is known.
2716            pending_books.push((shown.clone(), text));
2717            continue;
2718        }
2719        if kind == Kind::Recipe {
2720            // Checked once every block and item is known.
2721            pending_recipes.push((shown.clone(), text, path.clone()));
2722            continue;
2723        }
2724        if kind == Kind::Def {
2725            // Kinds of recipe are checked with the recipes, ores and dimensions with what they name.
2726            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));
2727            if matches!(of.as_deref(), Some("recipe_kind" | "ore" | "dimension" | "table" | "tag" | "damage_type" | "tree" | "loot_modifier" | "loot_table" | "attribute" | "effect" | "potion")) {
2728                pending_layouts.push((shown.clone(), text));
2729            } else {
2730                pending_defs.push((shown.clone(), text));
2731            }
2732            continue;
2733        }
2734        let mut cx = Ctx {
2735            root,
2736            refs,
2737            file: shown.clone(),
2738            ns: mod_ns.unwrap_or("minecraft").to_string(),
2739            errors: &mut errors,
2740            warnings: &mut warnings,
2741            textures: &mut textures,
2742            inventories: &mut inventories,
2743            deferred: &mut deferred,
2744        };
2745        if kind == Kind::Entity {
2746            if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
2747                if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
2748                    errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
2749                }
2750                entity_files.push(e);
2751            }
2752            continue;
2753        }
2754        if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
2755        {
2756            if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2757                errors.push(format!(
2758                    "{shown}: id {} is also defined by {other}",
2759                    entry.id
2760                ));
2761            }
2762            entries.push(entry);
2763        }
2764    }
2765    // Books name the mod's content, vanilla's and other mods' declared ids.
2766    let manifest = crate::manifest::YogToml::read(&root.join("yog.toml")).ok();
2767    let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
2768    let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
2769    let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
2770    for e in &entries {
2771        known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2772    }
2773    for e in &entity_files {
2774        known.add(refs::RefKind::Entity, e.id.clone());
2775        // A model or clips of this mod must be among its `.ymodel`/`.yanim`.
2776        for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
2777            if let Some(id) = wanted
2778                && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
2779                && !bodies.iter().any(|b| b.model == model && &b.id == id)
2780            {
2781                errors.push(format!("{}: {what} `{id}`: no such file in content/ (`{}`)", e.file, if model { ".ymodel" } else { ".yanim" }));
2782            }
2783        }
2784    }
2785    // Kinds of recipe (`.ydef`): what they name checked; a kind declared
2786    // twice is a mistake.
2787    let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
2788    let mut ores: Vec<OreOut> = Vec::new();
2789    let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
2790    let mut tables: Vec<table::Table> = Vec::new();
2791    // Loot modifiers: (id, their data).
2792    let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
2793    // Attributes, effects, potions: (kind, id, their data).
2794    let mut definitions: Vec<(&'static str, String, Vec<u8>)> = Vec::new();
2795    for (shown, text) in &pending_layouts {
2796        let mut cx = Ctx {
2797            root,
2798            refs,
2799            file: shown.clone(),
2800            ns: mod_ns.unwrap_or("minecraft").to_string(),
2801            errors: &mut errors,
2802            warnings: &mut warnings,
2803            textures: &mut textures,
2804            inventories: &mut inventories,
2805            deferred: &mut deferred,
2806        };
2807        let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut definitions, &mut out) else { continue };
2808        if recipe_defs.contains_key(&k.id) {
2809            errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
2810        }
2811        recipe_defs.insert(k.id.clone(), k);
2812    }
2813    // How kinds of recipe are shown: `.yui` `recipe_layout` components.
2814    let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
2815    for (shown, text, path) in &pending_recipes {
2816        let mut cx = Ctx {
2817            root,
2818            refs,
2819            file: shown.clone(),
2820            ns: mod_ns.unwrap_or("minecraft").to_string(),
2821            errors: &mut errors,
2822            warnings: &mut warnings,
2823            textures: &mut textures,
2824            inventories: &mut inventories,
2825            deferred: &mut deferred,
2826        };
2827        if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs) {
2828            if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2829                errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2830            }
2831            recipes.push(r);
2832        }
2833    }
2834    for r in &recipes {
2835        known.add(refs::RefKind::Recipe, r.id.clone());
2836    }
2837    // Definitions: the ladders and their levels, sound groups.
2838    let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
2839    for (files, feature) in ores {
2840        out.outputs.extend(files);
2841        defs.biome_features.push(feature);
2842    }
2843    // What blocks and items name (drops, levels): known now.
2844    for (file, line, path, r) in &deferred {
2845        let found = match r.kind.as_str() {
2846            "level" => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
2847            "ladder" => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
2848            "tool_material" => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
2849                .then_some(())
2850                .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
2851            "armor_material" => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
2852                .then_some(())
2853                .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
2854            "sound_group" => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
2855                .then_some(())
2856                .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
2857            kind if kind.starts_with(TAG_REF) => {
2858                // `tag:block|item`: a tag of either.
2859                let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
2860                let first = found.next().unwrap_or(Ok(()));
2861                if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
2862            }
2863            kind => match refs::RefKind::from_word(kind) {
2864                Some(k) => known.check(k, &r.id),
2865                None => Ok(()),
2866            },
2867        };
2868        if let Err(e) = found {
2869            errors.push(format!("{file}:{line}: {path}: {e}"));
2870        }
2871    }
2872    for e in &entries {
2873        if let Some((level, line)) = &e.block.tool_level {
2874            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)));
2875        }
2876    }
2877    let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
2878    // The mod's own kinds of book page (`.yui` components with `book_page`).
2879    let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
2880    for (shown, text) in &pending_books {
2881        let mut cx = Ctx {
2882            root,
2883            refs,
2884            file: shown.clone(),
2885            ns: mod_ns.unwrap_or("minecraft").to_string(),
2886            errors: &mut errors,
2887            warnings: &mut warnings,
2888            textures: &mut textures,
2889            inventories: &mut inventories,
2890            deferred: &mut deferred,
2891        };
2892        if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
2893            if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2894                errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2895            }
2896            books.push(b);
2897        }
2898    }
2899    // What recipes name from this mod must be its blocks and items (vanilla
2900    // and other mods' ids can't be checked here).
2901    if let Some(ns) = mod_ns {
2902        let screens = inventory_screens(&root.join("src"));
2903        for (file, line, id) in &inventories {
2904            if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2905                errors.push(format!(
2906                    "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2907                ));
2908            }
2909        }
2910    }
2911    for (target, source) in &textures {
2912        match std::fs::read(source) {
2913            Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2914            Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2915        }
2916    }
2917    out.warnings = warnings;
2918    if !errors.is_empty() {
2919        return Err(format!(
2920            "invalid blocks/items in content/:\n  {}",
2921            errors.join("\n  ")
2922        ));
2923    }
2924    out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2925    out.items = entries.len() - out.blocks;
2926    out.recipes = recipes.len();
2927    out.books = books.len();
2928    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() || !definitions.is_empty())
2929        && !mentions_content_macro(&root.join("src"))
2930    {
2931        out.warnings.push(format!(
2932            "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2933            CONTENT_DIR
2934        ));
2935    }
2936    out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
2937    out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
2938    out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
2939    out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
2940    out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
2941    out.data_files.extend(definitions.iter().map(|(kind, id, data)| (definition_path(kind, id), data.clone())));
2942    defs.definitions = definitions.iter().map(|(kind, id, _)| (*kind, id.clone())).collect();
2943    defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
2944    // A tool level above netherite gets a tag of what its tools can't mine.
2945    for m in &defs.tool_materials {
2946        if let Some((n, p)) = m.incorrect.split_once(':')
2947            && n != "minecraft"
2948        {
2949            out.tags.entry(format!("when/any/1.21/data/{n}/tags/block/{p}.json")).or_default();
2950        }
2951    }
2952    defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
2953    defs.tables = tables;
2954    out.source = Some(rust_source(&entries, &recipes, &books, &bodies, &entity_files, &defs));
2955    version_overlays(&mut out, &known.datapack);
2956    out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
2957    Ok(out)
2958}
2959
2960/// [`yog_registry::REGISTRATIONS_FILE`]: what the mod registers at the
2961/// game's start, each with a hash of what can't change while it runs — a
2962/// block's properties and states, not its model, name or tags (the game
2963/// reloads those).
2964fn registrations(entries: &[Entry], entities: &[EntityFile], dimensions: &[(String, Vec<u8>)], defs: &Defs) -> String {
2965    fn hash(text: &str) -> String {
2966        // FNV-1a: the same on every build of the CLI.
2967        let h = text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
2968        format!("{h:016x}")
2969    }
2970    let mut lines = Vec::new();
2971    for e in entries {
2972        let what = match e.kind {
2973            Kind::Block => {
2974                // Its tool tags and level are data (tags), reloaded with the rest.
2975                let b = BlockProps { tools: Vec::new(), tool_level: None, ..e.block.clone() };
2976                format!("{b:?} {:?} {:?}", e.item, e.requires)
2977            }
2978            _ => format!("{:?} {:?}", e.item, e.requires),
2979        };
2980        lines.push(format!("{} {} {}", e.kind.word(), e.id, hash(&what)));
2981    }
2982    for e in entities {
2983        lines.push(format!("entity {} {}", e.id, hash(&format!("{:?}", e.data))));
2984    }
2985    for (id, data) in dimensions {
2986        lines.push(format!("dimension {id} {}", hash(&format!("{data:?}"))));
2987    }
2988    for g in &defs.sound_groups {
2989        lines.push(format!("sound_group {} {}", g.id, hash(&format!("{g:?}"))));
2990    }
2991    for m in &defs.tool_materials {
2992        lines.push(format!("tool_material {} {}", m.id, hash(&format!("{m:?}"))));
2993    }
2994    for m in &defs.armor_materials {
2995        lines.push(format!("armor_material {} {}", m.id, hash(&format!("{m:?}"))));
2996    }
2997    lines.sort();
2998    lines.join("\n") + "\n"
2999}
3000
3001/// The mod's data names vanilla content as its datapack version does; for
3002/// each other version that calls some of it otherwise, an overlay of the
3003/// file with those names (`when/any/<version>/…`). What a version hasn't
3004/// got under any name is said in a warning.
3005fn version_overlays(out: &mut Compiled, datapack: &str) {
3006    fn map(v: &mut Value, version: &str, missing: &mut BTreeSet<String>) -> bool {
3007        match v {
3008            Value::String(s) if s.starts_with("minecraft:") => {
3009                let id = s.trim_start_matches('#').to_string();
3010                let eq = refs::equivalent(refs::RefKind::Item, &id, version).or_else(|| refs::equivalent(refs::RefKind::Block, &id, version));
3011                match eq {
3012                    Some(e) if e != id => {
3013                        *s = e;
3014                        true
3015                    }
3016                    Some(_) => false,
3017                    // Not an id Yog knows (a loot function, a tag), or one this version hasn't got.
3018                    None => {
3019                        if refs::is_vanilla(&id) {
3020                            missing.insert(id);
3021                        }
3022                        false
3023                    }
3024                }
3025            }
3026            Value::Array(a) => a.iter_mut().fold(false, |c, x| map(x, version, missing) | c),
3027            Value::Object(o) => o.values_mut().fold(false, |c, x| map(x, version, missing) | c),
3028            _ => false,
3029        }
3030    }
3031    let mut overlays = Vec::new();
3032    for (path, bytes) in &out.outputs {
3033        if !path.starts_with("data/") || !path.ends_with(".json") {
3034            continue;
3035        }
3036        let Ok(json) = serde_json::from_slice::<Value>(bytes) else { continue };
3037        for version in refs::versions().iter().filter(|v| *v != datapack) {
3038            let mut copy = json.clone();
3039            let mut missing = BTreeSet::new();
3040            if map(&mut copy, version, &mut missing) {
3041                overlays.push((format!("when/any/{version}/{path}"), pretty(&copy)));
3042            }
3043            for id in missing {
3044                out.warnings.push(format!("{path} names {id}, which Minecraft {version} hasn't got"));
3045            }
3046        }
3047    }
3048    out.outputs.extend(overlays);
3049}
3050
3051
3052/// Whether any `src/**/*.rs` has `content!(` — the include of the generated code.
3053fn mentions_content_macro(dir: &Path) -> bool {
3054    let Ok(entries) = std::fs::read_dir(dir) else {
3055        return false;
3056    };
3057    entries.flatten().any(|e| {
3058        let p = e.path();
3059        if p.is_dir() {
3060            mentions_content_macro(&p)
3061        } else {
3062            p.extension().is_some_and(|x| x == "rs")
3063                && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
3064        }
3065    })
3066}
3067
3068/// `ruby_ore`, `ores/ruby` → `ores_ruby`; a Rust keyword gets `r#`.
3069fn rust_ident(path: &str) -> String {
3070    let mut s: String = path
3071        .chars()
3072        .map(|c| {
3073            if c.is_ascii_alphanumeric() {
3074                c.to_ascii_lowercase()
3075            } else {
3076                '_'
3077            }
3078        })
3079        .collect();
3080    if s.starts_with(|c: char| c.is_ascii_digit()) {
3081        s.insert(0, '_');
3082    }
3083    s
3084}
3085
3086fn float(n: f64) -> String {
3087    let s = format!("{n:?}");
3088    if s.contains('.') || s.contains('e') {
3089        s
3090    } else {
3091        format!("{s}.0")
3092    }
3093}
3094
3095/// `stmt`, only when the mods `requires` names are all loaded (an optional integration).
3096fn when_loaded(requires: &[String], stmt: &str) -> String {
3097    if requires.is_empty() {
3098        return stmt.to_string();
3099    }
3100    let cond: Vec<String> = requires.iter().map(|m| format!("::yog_api::mod_loaded({m:?})")).collect();
3101    format!("if {} {{ {stmt} }}", cond.join(" && "))
3102}
3103
3104fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile], bodies: &[BodyFile], entities: &[EntityFile], defs: &Defs) -> String {
3105    let sound_groups = &defs.sound_groups;
3106    let mut code = String::from(
3107        "// <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",
3108    );
3109    if !entities.is_empty() {
3110        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");
3111        let mut used: HashMap<String, usize> = HashMap::new();
3112        for e in entities {
3113            let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3114            let mut ident = rust_ident(path).to_ascii_uppercase();
3115            let n = used.entry(ident.clone()).or_insert(0);
3116            *n += 1;
3117            if *n > 1 {
3118                let _ = write!(ident, "_{n}");
3119            }
3120            let _ = writeln!(code, "    pub const {ident}: &'static str = {:?};", e.id);
3121        }
3122        code.push_str("}\n");
3123    }
3124    for (model, set, what) in [(true, "Models", "models (`.ymodel`)"), (false, "Anims", "animations (`.yanim`)")] {
3125        let mine: Vec<&BodyFile> = bodies.iter().filter(|b| b.model == model).collect();
3126        if mine.is_empty() {
3127            continue;
3128        }
3129        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} {{");
3130        let mut used: HashMap<String, usize> = HashMap::new();
3131        for b in mine {
3132            let path = b.id.split_once(':').map_or(b.id.as_str(), |(_, p)| p);
3133            let mut ident = rust_ident(path).to_ascii_uppercase();
3134            let n = used.entry(ident.clone()).or_insert(0);
3135            *n += 1;
3136            if *n > 1 {
3137                let _ = write!(ident, "_{n}");
3138            }
3139            let _ = writeln!(code, "    pub const {ident}: &'static str = {:?};", b.id);
3140        }
3141        code.push_str("}\n");
3142    }
3143    for (kind, set, def) in [
3144        (Kind::Block, "Blocks", "BlockDef"),
3145        (Kind::Item, "Items", "ItemDef"),
3146    ] {
3147        let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
3148        let _ = writeln!(
3149            code,
3150            "\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} {{",
3151            if kind == Kind::Block {
3152                "blocks"
3153            } else {
3154                "items"
3155            }
3156        );
3157        let mut used: HashMap<String, usize> = HashMap::new();
3158        for e in mine {
3159            let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3160            let mut ident = rust_ident(path);
3161            let n = used.entry(ident.clone()).or_insert(0);
3162            *n += 1;
3163            if *n > 1 {
3164                let _ = write!(ident, "_{n}");
3165            }
3166            let upper = ident.to_ascii_uppercase();
3167            let ident = if is_keyword(&ident) {
3168                format!("r#{ident}")
3169            } else {
3170                ident
3171            };
3172            let _ = writeln!(
3173                code,
3174                "    /// `{}` — `{}`.\n    pub const {upper}: &'static str = {:?};",
3175                e.id, e.file, e.id
3176            );
3177            let mut b = format!("::yog_api::{def}::new(Self::{upper})");
3178            match kind {
3179                Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3180                Kind::Block => {
3181                    let p = &e.block;
3182                    if p.hardness.is_some() || p.resistance.is_some() {
3183                        let _ = write!(
3184                            b,
3185                            "\n            .strength({}, {})",
3186                            float(p.hardness.unwrap_or(1.5)),
3187                            float(p.resistance.unwrap_or(6.0))
3188                        );
3189                    }
3190                    if let Some(s) = &p.sound {
3191                        let _ = write!(b, "\n            .sound({s:?})");
3192                    }
3193                    if p.light > 0 {
3194                        let _ = write!(b, "\n            .light_level({})", p.light);
3195                    }
3196                    if p.requires_tool {
3197                        b.push_str("\n            .requires_tool()");
3198                    }
3199                    if p.no_collision {
3200                        b.push_str("\n            .no_collision()");
3201                    }
3202                    if let Some(s) = p.slipperiness {
3203                        let _ = write!(b, "\n            .slipperiness({})", float(s));
3204                    }
3205                    if let Some(s) = p.shape {
3206                        let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
3207                        let _ = write!(b, "\n            .shape({})", args.join(", "));
3208                    }
3209                    if p.connects {
3210                        b.push_str("\n            .connects_to_neighbors()");
3211                    }
3212                    if !p.connect_groups.is_empty() {
3213                        let groups: Vec<String> =
3214                            p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
3215                        let _ =
3216                            write!(b, "\n            .connect_groups(&[{}])", groups.join(", "));
3217                    }
3218                    if let Some(inv) = &p.inventory {
3219                        let _ = write!(b, "\n            .inventory({inv:?})");
3220                    }
3221                    if p.redstone {
3222                        b.push_str("\n            .redstone_source()");
3223                    }
3224                    if !p.facing.is_empty() {
3225                        let _ = write!(b, "\n            .facing({:?}, {:?})", p.facing, p.placement);
3226                    }
3227                    if p.attach {
3228                        b.push_str("\n            .attach()");
3229                    }
3230                    if p.waterlogged {
3231                        b.push_str("\n            .waterlogged()");
3232                    }
3233                    for (name, values) in &p.properties {
3234                        let vs: Vec<String> = values.iter().map(|v| format!("{v:?}")).collect();
3235                        let _ = write!(b, "\n            .property({name:?}, &[{}])", vs.join(", "));
3236                    }
3237                    if let Some(name) = &p.powered {
3238                        let _ = write!(b, "\n            .powered({name:?})");
3239                    }
3240                    if let Some(name) = &p.comparator {
3241                        let _ = write!(b, "\n            .comparator({name:?})");
3242                    }
3243                    if p.accepts_items {
3244                        b.push_str("\n            .accepts_items()");
3245                    }
3246                }
3247                Kind::Item => b.push_str(&item_props(&e.item)),
3248            }
3249            let _ = writeln!(
3250                code,
3251                "    /// The definition from `{}` (name and tooltip come from the lang files).\n    pub fn {ident}() -> ::yog_api::{def} {{\n        {b}\n    }}",
3252                e.file
3253            );
3254        }
3255        code.push_str("}\n");
3256    }
3257
3258    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");
3259    let mut used: HashMap<(Kind, String), usize> = HashMap::new();
3260    for e in entries {
3261        let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
3262        let mut ident = rust_ident(path);
3263        let n = used.entry((e.kind, ident.clone())).or_insert(0);
3264        *n += 1;
3265        if *n > 1 {
3266            let _ = write!(ident, "_{n}");
3267        }
3268        let ident = if is_keyword(&ident) {
3269            format!("r#{ident}")
3270        } else {
3271            ident
3272        };
3273        if !e.auto {
3274            continue;
3275        }
3276        let _ = match e.kind {
3277            Kind::Block => {
3278                // Its item form, when the file says how it stacks, burns or survives fire.
3279                let item = item_props(&e.item);
3280                let stmt = if item.is_empty() {
3281                    format!("registry.register_block(Blocks::{ident}());")
3282                } else {
3283                    format!("{{ registry.register_block(Blocks::{ident}()); registry.register_item(::yog_api::ItemDef::new({:?}){}); }}", e.id, item.replace("\n            ", ""))
3284                };
3285                writeln!(code, "    {}", when_loaded(&e.requires, &stmt))
3286            }
3287            Kind::Item => writeln!(code, "    {}", when_loaded(&e.requires, &format!("registry.register_item(Items::{ident}());"))),
3288            Kind::Recipe | Kind::Book | Kind::Def | Kind::Model | Kind::Anim | Kind::Entity => unreachable!("only blocks and items get here"),
3289        };
3290    }
3291    for g in sound_groups {
3292        let _ = writeln!(
3293            code,
3294            "    registry.register_sound_group(::yog_api::SoundGroup::new({:?}).sounds({:?}, {:?}, {:?}, {:?}, {:?}).volume({}).pitch({}));",
3295            g.id,
3296            g.break_sound,
3297            g.step,
3298            g.place,
3299            g.hit,
3300            g.fall,
3301            float(g.volume as f64),
3302            float(g.pitch as f64)
3303        );
3304    }
3305    for id in &defs.dimensions {
3306        let _ = writeln!(
3307            code,
3308            "    registry.register_dimension_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3309            dimension_path(id)
3310        );
3311    }
3312    for (feature, biomes, step) in &defs.biome_features {
3313        let _ = writeln!(code, "    registry.add_biome_feature({feature:?}, {biomes:?}, {step:?});");
3314    }
3315    for m in &defs.tool_materials {
3316        let _ = writeln!(
3317            code,
3318            "    registry.register_tool_material(::yog_api::ToolMaterial {{ id: {:?}.into(), durability: {}, speed: {:?}, attack_damage: {:?}, enchantability: {}, level: {:?}.into(), repair: {:?}.into(), level_index: {}, incorrect: {:?}.into() }});",
3319            m.id, m.durability, m.speed, m.attack_damage, m.enchantability, m.level, m.repair, m.level_index, m.incorrect
3320        );
3321    }
3322    for m in &defs.armor_materials {
3323        let d = m.defense;
3324        let _ = writeln!(
3325            code,
3326            "    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() }});",
3327            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
3328        );
3329    }
3330    for r in recipes {
3331        let _ = writeln!(code, "    {}", when_loaded(&r.requires, &r.registration()));
3332    }
3333    for b in books {
3334        let _ = writeln!(
3335            code,
3336            "    registry.register_book_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3337            b.id,
3338            b.path()
3339        );
3340    }
3341    for (kind, id) in &defs.definitions {
3342        let _ = writeln!(
3343            code,
3344            "    registry.register_definition_data({kind:?}, {id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3345            definition_path(kind, id)
3346        );
3347    }
3348    for id in &defs.loot_modifiers {
3349        let _ = writeln!(
3350            code,
3351            "    registry.add_loot_modifier_data({id:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3352            loot_path(id)
3353        );
3354    }
3355    for e in entities {
3356        let _ = writeln!(
3357            code,
3358            "    registry.register_entity_data({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3359            e.id,
3360            e.path()
3361        );
3362    }
3363    for b in bodies {
3364        let _ = writeln!(
3365            code,
3366            "    registry.register_body_{}({:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{}\")));",
3367            if b.model { "model" } else { "anims" },
3368            b.id,
3369            b.path()
3370        );
3371    }
3372    code.push_str("}\n");
3373    for t in &defs.tables {
3374        code.push_str(&table::rust(t));
3375    }
3376    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() {
3377        code.push_str(
3378            "\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",
3379        );
3380    }
3381    code
3382}
3383
3384fn is_keyword(s: &str) -> bool {
3385    const KW: &[&str] = &[
3386        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
3387        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
3388        "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
3389        "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
3390        "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
3391    ];
3392    KW.contains(&s)
3393}
3394
3395/// Writes `compiled.source` to [`GENERATED_FILE`] (only when it changed);
3396/// deletes it when the mod has no content files.
3397pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
3398    write_tags(root, compiled)?;
3399    let out = root.join(GENERATED_FILE);
3400    let Some(code) = &compiled.source else {
3401        let _ = std::fs::remove_file(&out);
3402        return Ok(());
3403    };
3404    crate::write_if_changed(&out, code)?;
3405    let dir = out.parent().unwrap_or(root);
3406    for (path, bytes) in &compiled.data_files {
3407        let file = dir.join(path);
3408        if std::fs::read(&file).ok().as_deref() != Some(bytes.as_slice()) {
3409            if let Some(parent) = file.parent() {
3410                std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
3411            }
3412            std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))?;
3413        }
3414    }
3415    Ok(())
3416}
3417
3418/// Where the mod's tags are listed for tools (`#ns:path` → its entries, by
3419/// kind) — the IDE shows a tag's items from here, as the build makes them.
3420pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
3421
3422/// Writes [`TAGS_FILE`]: `{ "item": { "ns:path": ["ns:id", "#ns:tag", …] }, "block": … }`.
3423fn write_tags(root: &Path, compiled: &Compiled) -> Result<(), String> {
3424    let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
3425    for (path, entries) in &compiled.tags {
3426        // `[when/<loader>/<version>/]data/<ns>/tags/<kind>/<path>.json`
3427        let data = path.find("data/").map_or(path.as_str(), |i| &path[i + 5..]);
3428        let Some((ns, rest)) = data.split_once("/tags/") else { continue };
3429        let Some((kind, tag)) = rest.split_once('/') else { continue };
3430        let tag = tag.trim_end_matches(".json");
3431        by_kind.entry(kind.to_string()).or_default().entry(format!("{ns}:{tag}")).or_default().extend(entries.keys().cloned());
3432    }
3433    let text = serde_json::to_string_pretty(&by_kind).map_err(|e| e.to_string())?;
3434    crate::write_if_changed(&root.join(TAGS_FILE), &text)
3435}
3436
3437/// Outputs in order, keyed by path: [`OutputSet::put`] replaces a path's
3438/// output (moving it to the end) without rescanning the list.
3439#[derive(Default)]
3440pub struct OutputSet {
3441    items: Vec<Option<Output>>,
3442    at: HashMap<String, usize>,
3443}
3444
3445impl OutputSet {
3446    pub fn get(&self, path: &str) -> Option<&[u8]> {
3447        let i = *self.at.get(path)?;
3448        self.items[i].as_ref().map(|(_, b)| b.as_slice())
3449    }
3450
3451    pub fn contains(&self, path: &str) -> bool {
3452        self.at.contains_key(path)
3453    }
3454
3455    pub fn paths(&self) -> impl Iterator<Item = &String> {
3456        self.at.keys()
3457    }
3458
3459    /// Sets `path`'s output, dropping the one it had.
3460    pub fn put(&mut self, path: String, bytes: Vec<u8>) {
3461        if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
3462            self.items[i] = None;
3463        }
3464        self.items.push(Some((path, bytes)));
3465    }
3466
3467    pub fn into_vec(self) -> Vec<Output> {
3468        self.items.into_iter().flatten().collect()
3469    }
3470}
3471
3472impl From<Vec<Output>> for OutputSet {
3473    fn from(outputs: Vec<Output>) -> Self {
3474        let mut set = OutputSet::default();
3475        for (path, bytes) in outputs {
3476            set.put(path, bytes);
3477        }
3478        set
3479    }
3480}
3481
3482/// A JSON object output at `path`: the one already in `outputs`, else the
3483/// mod's own file there, else empty.
3484fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
3485    let read;
3486    let bytes = match outputs.get(path) {
3487        Some(b) => b,
3488        None => match std::fs::read(root.join(path)) {
3489            Ok(b) => {
3490                read = b;
3491                &read
3492            }
3493            Err(_) => return Ok(Map::new()),
3494        },
3495    };
3496    serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
3497}
3498
3499/// Adds the content's outputs to `outputs` (the `.yres` ones, say): lang
3500/// entries and tags merged into what's there (or into the mod's own files),
3501/// the rest appended.
3502pub fn merge_outputs(
3503    root: &Path,
3504    outputs: &mut Vec<Output>,
3505    compiled: Compiled,
3506) -> Result<(), String> {
3507    let mut set = OutputSet::from(std::mem::take(outputs));
3508    let mut errors = Vec::new();
3509    // `.yres` texts under the keys the game looks up: in every lang file
3510    // that has the text.
3511    if !compiled.lang_aliases.is_empty() {
3512        let mut lang_files: BTreeSet<String> =
3513            set.paths().filter(|p| is_lang_path(p)).cloned().collect();
3514        if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
3515            for ns in dirs.flatten() {
3516                if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
3517                    for f in files.flatten() {
3518                        if let Ok(rel) = f.path().strip_prefix(root) {
3519                            let rel = rel.to_string_lossy().replace('\\', "/");
3520                            if is_lang_path(&rel) {
3521                                lang_files.insert(rel);
3522                            }
3523                        }
3524                    }
3525                }
3526            }
3527        }
3528        for path in lang_files {
3529            let mut map = match json_output(&set, root, &path) {
3530                Ok(m) => m,
3531                Err(e) => {
3532                    errors.push(e);
3533                    continue;
3534                }
3535            };
3536            let mut changed = false;
3537            for (from, to) in &compiled.lang_aliases {
3538                if let Some(text) = map.get(from).cloned()
3539                    && !map.contains_key(to)
3540                {
3541                    map.insert(to.clone(), text);
3542                    changed = true;
3543                }
3544            }
3545            if changed {
3546                set.put(path, pretty(&Value::Object(map)));
3547            }
3548        }
3549    }
3550    for ((ns, locale), entries) in &compiled.lang {
3551        let path = format!("assets/{ns}/lang/{locale}.json");
3552        let mut map = match json_output(&set, root, &path) {
3553            Ok(m) => m,
3554            Err(e) => {
3555                errors.push(e);
3556                continue;
3557            }
3558        };
3559        for (k, v) in entries {
3560            map.entry(k.clone())
3561                .or_insert_with(|| Value::String(v.clone()));
3562        }
3563        set.put(path, pretty(&Value::Object(map)));
3564    }
3565    for (path, ids) in &compiled.tags {
3566        // The mod's own tag file may use the pre-1.21 spelling; the loader
3567        // maps both to one file, so merge into whichever it has.
3568        let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
3569        let path = if !set.contains(path) && root.join(&plural).is_file() {
3570            plural
3571        } else {
3572            path.clone()
3573        };
3574        let mut map = match json_output(&set, root, &path) {
3575            Ok(m) => m,
3576            Err(e) => {
3577                errors.push(e);
3578                continue;
3579            }
3580        };
3581        let values = map
3582            .entry("values")
3583            .or_insert_with(|| Value::Array(Vec::new()));
3584        if let Value::Array(list) = values {
3585            for (id, &required) in ids {
3586                let listed = |v: &Value| v.as_str().or_else(|| v.get("id").and_then(Value::as_str)) == Some(id.as_str());
3587                if !list.iter().any(listed) {
3588                    list.push(if required { Value::String(id.clone()) } else { serde_json::json!({ "id": id, "required": false }) });
3589                }
3590            }
3591        }
3592        map.entry("replace").or_insert(Value::Bool(false));
3593        set.put(path, pretty(&Value::Object(map)));
3594    }
3595    for (path, bytes) in compiled.outputs {
3596        set.put(path, bytes);
3597    }
3598    *outputs = set.into_vec();
3599    if errors.is_empty() {
3600        Ok(())
3601    } else {
3602        Err(format!(
3603            "merging blocks/items into the mod's files:\n  {}",
3604            errors.join("\n  ")
3605        ))
3606    }
3607}
3608
3609fn is_lang_path(p: &str) -> bool {
3610    let parts: Vec<&str> = p.split('/').collect();
3611    parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
3612}
3613
3614#[cfg(test)]
3615mod tests {
3616    use super::*;
3617
3618    fn project(files: &[(&str, &str)]) -> PathBuf {
3619        use std::sync::atomic::{AtomicUsize, Ordering};
3620        static N: AtomicUsize = AtomicUsize::new(0);
3621        let dir = std::env::temp_dir().join(format!(
3622            "yog-content-test-{}-{}",
3623            std::process::id(),
3624            N.fetch_add(1, Ordering::SeqCst)
3625        ));
3626        let _ = std::fs::remove_dir_all(&dir);
3627        for (path, text) in files {
3628            let p = dir.join(path);
3629            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
3630            std::fs::write(p, text).unwrap();
3631        }
3632        dir
3633    }
3634
3635    fn output(c: &Compiled, path: &str) -> Value {
3636        let (_, b) = c
3637            .outputs
3638            .iter()
3639            .find(|(p, _)| p == path)
3640            .unwrap_or_else(|| {
3641                panic!(
3642                    "no {path} in {:?}",
3643                    c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
3644                )
3645            });
3646        serde_json::from_slice(b).unwrap()
3647    }
3648
3649    #[test]
3650    fn compiles_a_cube_block() {
3651        let root = project(&[
3652            ("resources/block/ruby_ore.png", "png"),
3653            ("content/ruby.yitem", r#"{ model: { parent: "generated", textures: { layer0: "minecraft:item/emerald" } } }"#),
3654            (
3655                "content/blocks/ruby_ore.yblock",
3656                r#"{
3657  "name": "Ruby Ore",
3658  "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
3659             "drops": { "item": "@item:mymod:ruby", "count": [1, 3] } },
3660  "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
3661}"#,
3662            ),
3663        ]);
3664        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3665        assert_eq!(c.blocks, 1);
3666        assert_eq!(
3667            output(&c, "assets/mymod/models/block/ruby_ore.json"),
3668            json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
3669        );
3670        assert_eq!(
3671            output(&c, "assets/mymod/blockstates/ruby_ore.json"),
3672            json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
3673        );
3674        assert_eq!(
3675            output(&c, "assets/mymod/models/item/ruby_ore.json"),
3676            json!({ "parent": "mymod:block/ruby_ore" })
3677        );
3678        assert_eq!(
3679            output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
3680            json!("mymod:item/ruby_ore")
3681        );
3682        let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
3683        assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
3684        assert_eq!(
3685            loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
3686            json!(3)
3687        );
3688        assert!(
3689            c.outputs
3690                .iter()
3691                .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
3692        );
3693        let src = c.source.as_ref().unwrap();
3694        assert!(
3695            src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
3696            "{src}"
3697        );
3698        assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
3699        assert!(src.contains(".requires_tool()"), "{src}");
3700        assert!(
3701            src.contains("registry.register_block(Blocks::ruby_ore());"),
3702            "{src}"
3703        );
3704
3705        let mut outputs = Vec::new();
3706        merge_outputs(&root, &mut outputs, c).unwrap();
3707        let find = |p: &str| -> Value {
3708            serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
3709        };
3710        assert_eq!(
3711            find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
3712            json!("Ruby Ore")
3713        );
3714        assert_eq!(
3715            find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
3716            json!(["mymod:ruby_ore"])
3717        );
3718        assert_eq!(
3719            find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
3720            json!(["mymod:ruby_ore"])
3721        );
3722        let _ = std::fs::remove_dir_all(root);
3723    }
3724
3725    #[test]
3726    fn raw_parent_with_elements_gets_a_particle() {
3727        let root = project(&[(
3728            "content/cable.yblock",
3729            r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
3730                 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
3731        )]);
3732        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3733        let m = output(&c, "assets/mymod/models/block/cable.json");
3734        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3735        let _ = std::fs::remove_dir_all(root);
3736    }
3737
3738    #[test]
3739    fn a_facing_block_turns_to_its_placer() {
3740        let root = project(&[(
3741            "content/screen.yblock",
3742            r##"{ "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } },
3743                 "states": { "facing": "horizontal" } }"##,
3744        )]);
3745        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3746        let bs = output(&c, "assets/mymod/blockstates/screen.json");
3747        assert_eq!(bs["variants"]["facing=north"], json!({ "model": "mymod:block/screen" }));
3748        assert_eq!(bs["variants"]["facing=west"]["y"], json!(270));
3749        assert!(c.source.unwrap().contains(".facing(\"horizontal\", \"toward\")"));
3750        let _ = std::fs::remove_dir_all(root);
3751    }
3752
3753    #[test]
3754    fn compiles_elements_shape_and_connect() {
3755        let root = project(&[(
3756            "content/pipe.yblock",
3757            r##"{
3758  "block": { "render_layer": "cutout" },
3759  "model": {
3760    "textures": { "0": "minecraft:block/stone" },
3761    "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
3762                    "rotation": { "axis": "y", "angle": 22.5 },
3763                    "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
3764  },
3765  "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
3766     "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
3767}"##,
3768        )]);
3769        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3770        let m = output(&c, "assets/mymod/models/block/pipe.json");
3771        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
3772        assert_eq!(m["render_type"], json!("minecraft:cutout"));
3773        assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
3774        assert_eq!(m["parent"], json!("minecraft:block/block"), "shown in a slot and held as a block");
3775        let bs = output(&c, "assets/mymod/blockstates/pipe.json");
3776        assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
3777        assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
3778        let src = c.source.unwrap();
3779        assert!(
3780            src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
3781            "{src}"
3782        );
3783        assert!(src.contains(".connects_to_neighbors()"), "{src}");
3784        assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
3785        let _ = std::fs::remove_dir_all(root);
3786    }
3787
3788    #[test]
3789    fn sound_groups_are_the_games_or_a_mods_ydef() {
3790        let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3791        let crystal = r#"{ kind: "sound_group", id: "crystal", break: "crystal.break", step: "minecraft:block.glass.step",
3792  place: "crystal.place", hit: "crystal.hit", fall: "crystal.fall", pitch: 1.5 }"#;
3793        let a = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:crystal" }} }}"#);
3794        let b = format!(r#"{{ {cube}, block: {{ sound: "amethyst" }} }}"#);
3795        let c = compile(&project(&[("content/a.yblock", &a), ("content/b.yblock", &b), ("content/crystal.ydef", crystal)]), "mymod", &yres::Refs::default()).unwrap();
3796        let src = c.source.unwrap();
3797        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}");
3798        assert!(src.contains(r#".sound("mymod:crystal")"#) && src.contains(r#".sound("minecraft:amethyst")"#), "{src}");
3799
3800        let bad = format!(r#"{{ {cube}, block: {{ sound: "crystal" }} }}"#);
3801        let missing = format!(r#"{{ {cube}, block: {{ sound: "@sound_group:mymod:nope" }} }}"#);
3802        let e = compile(&project(&[("content/a.yblock", &bad), ("content/b.yblock", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
3803        assert!(e.contains("`crystal`: not one of the game's sound groups"), "{e}");
3804        assert!(e.contains("no sound group mymod:nope"), "{e}");
3805    }
3806
3807    #[test]
3808    fn tools_are_tags_and_levels_are_definitions_a_mod_can_add() {
3809        let cube = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
3810        let mythril = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
3811        let block = format!(r#"{{ {cube}, block: {{ tool: ["pickaxe", "@tag:block:mymod:mineable/hammer"], tool_level: "@level:mymod:mythril" }} }}"#);
3812        let c = compile(&project(&[("content/ore.yblock", &block), ("content/mythril.ydef", mythril)]), "mymod", &yres::Refs::default()).unwrap();
3813        for tag in [
3814            "data/minecraft/tags/block/mineable/pickaxe.json",
3815            "data/mymod/tags/block/mineable/hammer.json",
3816            // Above diamond, on every version its own way.
3817            "data/minecraft/tags/block/needs_diamond_tool.json",
3818            "when/any/1.21/data/minecraft/tags/block/incorrect_for_diamond_tool.json",
3819            "when/fabric/1.20/data/fabric/tags/block/needs_tool_level_4.json",
3820            "when/forge/1.20/data/forge/tags/block/needs_netherite_tool.json",
3821        ] {
3822            assert!(c.tags.get(tag).is_some_and(|ids| ids.contains_key("mymod:ore")), "{tag}: {:?}", c.tags);
3823        }
3824        // The game's levels by name.
3825        let iron = format!(r#"{{ {cube}, block: {{ tool: "pickaxe", tool_level: "iron" }} }}"#);
3826        let c = compile(&project(&[("content/ore.yblock", &iron)]), "mymod", &yres::Refs::default()).unwrap();
3827        assert!(c.tags["data/minecraft/tags/block/needs_iron_tool.json"].contains_key("mymod:ore"));
3828        assert_eq!(c.tags.len(), 2);
3829        // Mistakes.
3830        let bad = format!(r#"{{ {cube}, block: {{ tool: "hammer", tool_level: "mythril" }} }}"#);
3831        let e = compile(&project(&[("content/ore.yblock", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3832        assert!(e.contains("block.tool: `hammer`: one of pickaxe, axe, shovel, hoe — or a tag of the mod's own tools"), "{e}");
3833        assert!(e.contains("block.tool_level: `mythril`: one of wood, stone, iron, diamond, netherite — or a level of the tool ladder"), "{e}");
3834        let volts = r#"{ kind: "ladder", id: "voltage" }"#;
3835        let lv = r#"{ kind: "level", id: "lv", ladder: "@ladder:mymod:voltage" }"#;
3836        let loop_ = r#"{ kind: "level", id: "hv", ladder: "@ladder:mymod:voltage", above: "@level:mymod:nope" }"#;
3837        let wrong = format!(r#"{{ {cube}, block: {{ tool_level: "@level:mymod:lv", drops: {{ item: "@item:mymod:nope" }} }} }}"#);
3838        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();
3839        assert!(e.contains("level mymod:hv: above mymod:nope, which isn't a level of mymod:voltage"), "{e}");
3840        assert!(e.contains("block.tool_level: mymod:lv is a level of mymod:voltage, not of the tool ladder"), "{e}");
3841        assert!(e.contains("block.drops.item: no item mymod:nope in this mod"), "{e}");
3842    }
3843
3844    #[test]
3845    fn reports_errors_with_lines() {
3846        // The schema first: every structural problem, with its line and path.
3847        let root = project(&[(
3848            "content/bad.yblock",
3849            "{\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}",
3850        )]);
3851        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3852        for want in [
3853            "content/bad.yblock:5: model.elements[0].to: 99 is out of range (-16..=32)",
3854            "content/bad.yblock:6: model.elements[0].rotation.axis: unknown `q` — did you mean `x`?",
3855            "content/bad.yblock:7: model.elements[0].faces.nort: unknown field `nort` — did you mean `north`?",
3856            "content/bad.yblock:10: bogus: unknown field `bogus`",
3857        ] {
3858            assert!(e.contains(want), "missing {want}\n{e}");
3859        }
3860        let _ = std::fs::remove_dir_all(root);
3861        // Then what it means: rotations Minecraft can't do, slots that aren't there.
3862        let root = project(&[(
3863            "content/bad.yblock",
3864            "{\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}",
3865        )]);
3866        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3867        for want in [
3868            "content/bad.yblock:6: model.elements[0].rotation.angle: 30 — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees",
3869            "content/bad.yblock:7: model.elements[0].faces.north.texture: `#1`: no such slot in textures",
3870        ] {
3871            assert!(e.contains(want), "missing {want}\n{e}");
3872        }
3873        let _ = std::fs::remove_dir_all(root);
3874    }
3875
3876    #[test]
3877    fn compiles_recipes() {
3878        let root = project(&[
3879            ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
3880            (
3881                "content/recipes/ruby_block.yrecipe",
3882                r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
3883            ),
3884            (
3885                "content/recipes/ruby.yrecipe",
3886                r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
3887            ),
3888            (
3889                "content/recipes/smelt_ruby.yrecipe",
3890                r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
3891            ),
3892            (
3893                "content/recipes/cut.yrecipe",
3894                r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
3895            ),
3896        ]);
3897        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3898        assert_eq!(c.recipes, 4);
3899        let src = c.source.clone().unwrap();
3900        for want in [
3901            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"#,
3902            r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
3903            r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
3904            r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
3905            r##"group: Some("rubies".into())"##,
3906        ] {
3907            assert!(src.contains(want), "missing {want}\n{src}");
3908        }
3909        let _ = std::fs::remove_dir_all(root);
3910    }
3911
3912    #[test]
3913    fn recipe_mistakes() {
3914        let bad = |recipe: &str| {
3915            let root = project(&[("content/r.yrecipe", recipe)]);
3916            let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3917            let _ = std::fs::remove_dir_all(root);
3918            e
3919        };
3920        let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
3921        assert!(e.contains("pattern uses 'B'"), "{e}");
3922        let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
3923        assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
3924        let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
3925        assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
3926        let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
3927        assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
3928        let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
3929        assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
3930        let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
3931        assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
3932        let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
3933        assert!(e.contains("unknown field `pattern`"), "{e}");
3934        let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
3935        assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
3936    }
3937
3938    #[test]
3939    fn block_inventory_names_a_screen() {
3940        let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
3941        let root = project(&[
3942            ("content/bench.yblock", block),
3943            ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
3944        ]);
3945        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
3946        assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
3947        let _ = std::fs::remove_dir_all(root);
3948
3949        let root = project(&[("content/bench.yblock", block)]);
3950        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
3951        assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
3952        let _ = std::fs::remove_dir_all(root);
3953    }
3954
3955    #[test]
3956    fn compiles_a_book() {
3957        let book = r#"{
3958            "id": "guide",
3959            "name": "@Book.title",
3960            "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
3961            "entries": [{
3962                "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
3963                "pages": [
3964                    { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
3965                    { "type": "spotlight", "item": "@item:mymod:ruby" },
3966                    { "type": "entity", "entity": "@entity:minecraft:allay" }
3967                ]
3968            }]
3969        }"#;
3970        let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
3971        let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
3972        let mut refs = yres::Refs::default();
3973        refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
3974        let out = compile(&root, "mymod", &refs).unwrap();
3975        assert_eq!(out.books, 1);
3976        let src = out.source.unwrap();
3977        assert!(src.contains(r#"registry.register_book_data("mymod:guide", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/books/mymod__guide.ydata")));"#), "{src}");
3978        // The runtime reads the bytes by the book's schema; references come back as ids.
3979        assert_eq!(out.data_files[0].0, "books/mymod__guide.ydata");
3980        let data = yog_data::binary::decode(&out.data_files[0].1).unwrap();
3981        let text = yog_data::text::to_string(&data);
3982        assert!(text.contains("mymod.book.title") && text.contains("item.mymod.ruby"), "{text}");
3983        assert!(text.contains("not a ref") && text.contains("i18n"), "{text}");
3984        let read = yog_data::from_value::<yog_book::Book>(&data).unwrap().value;
3985        assert_eq!(read.entries[0].category, "mymod:basics");
3986        assert!(matches!(&read.entries[0].pages[1], yog_book::BookPage::Spotlight { item, .. } if item == "mymod:ruby"), "references become ids");
3987
3988        // Bare ids, missing content and version-only ids are errors, each on its line.
3989        let bad = r#"{ "id": "x",
3990            "categories": [{ "id": "c", "name": "C" }],
3991            "entries": [{ "id": "e", "name": "E", "category": "c",
3992                "pages": [
3993                    { "type": "spotlight", "item": "minecraft:stick" },
3994                    { "type": "spotlight", "item": "@item:minecraft:grass" },
3995                    { "type": "recipe", "recipe": "@recipe:mymod:nope" },
3996                    { "type": "entity", "entity": "@entity:create:train" }
3997                ] }] }"#;
3998        let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
3999        assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
4000        assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
4001        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}");
4002        assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
4003        assert!(e.contains("[external.create]"), "{e}");
4004
4005        let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
4006        let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4007        assert!(e.contains("entries[0].pages[0].type: unknown page type `nope`"), "{e}");
4008        let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
4009        let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
4010        assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
4011    }
4012
4013    #[test]
4014    fn books_use_the_mods_own_page_types() {
4015        let ty = r#"<Component name="PatternPage" book_page="pattern">
4016            <Property name="name" type="book_text"/>
4017            <Property name="patterns" type="data" default="[]"/>
4018            <BookHeader text="{x:Bind name}"/>
4019            <Drawn drawer="mymod:pattern" h="64" data="{x:Bind patterns}"/>
4020        </Component>"#;
4021        let book = |page: &str| format!(r#"{{ id: "guide", name: "G", categories: [{{ id: "mymod:c", name: "C" }}],
4022            entries: [{{ id: "mymod:e", name: "E", category: "@category:mymod:c", pages: [{page}] }}] }}"#);
4023        let ok = book(r#"{ type: "mymod:pattern", name: "@item:minecraft:stick", patterns: [{ startdir: "EAST", signature: "qaq" }] }"#);
4024        let c = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &ok)]), "mymod", &yres::Refs::default()).unwrap();
4025        let b = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4026        assert_eq!(b.page_types.len(), 1, "the type goes with the book");
4027        let yog_book::BookPage::Custom(p) = &b.entries[0].pages[0] else { panic!("{:?}", b.entries[0].pages[0]) };
4028        assert_eq!(p.fields.get("name").and_then(|n| n.as_str()), Some("item.minecraft.stick"), "texts resolve as the book's do");
4029        assert!(b.i18n);
4030
4031        let unknown = book(r#"{ type: "mymod:pattern", name: "N", colour: 3 }"#);
4032        let e = compile(&project(&[("src/ui/pattern_page.yui", ty), ("content/guide.ybook", &unknown)]), "mymod", &yres::Refs::default()).unwrap_err();
4033        assert!(e.contains("mymod:pattern has no field `colour` (name, patterns)"), "{e}");
4034        let missing = book(r#"{ type: "mymod:nope" }"#);
4035        let e = compile(&project(&[("content/guide.ybook", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4036        assert!(e.contains("no page type mymod:nope"), "{e}");
4037    }
4038
4039    #[test]
4040    fn tools_and_armor_of_the_games_materials_or_the_mods() {
4041        let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/iron_pickaxe" } }"#;
4042        let level = r#"{ kind: "level", id: "mythril", ladder: "@ladder:minecraft:tool", above: "@level:minecraft:diamond" }"#;
4043        let tool = r#"{ kind: "tool_material", id: "mythril", durability: 1800, speed: 10, attack_damage: 3, enchantability: 18,
4044            level: "@level:mymod:mythril", repair: "@tag:item:c:ingots" }"#;
4045        let armor = r#"{ kind: "armor_material", id: "mythril", durability: 30, defense: { head: 3, chest: 8, legs: 6, feet: 3 },
4046            enchantability: 18, equip_sound: "minecraft:item.armor.equip_diamond", repair: "@item:minecraft:diamond" }"#;
4047        let pick = format!(r#"{{ {model}, item: {{ tool: {{ kind: "pickaxe", material: "@tool_material:mymod:mythril" }} }} }}"#);
4048        let helm = format!(r#"{{ {model}, item: {{ armor: {{ slot: "head", material: "iron" }} }} }}"#);
4049        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())];
4050        let c = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap();
4051        let src = c.source.unwrap();
4052        assert!(src.contains(r#".tool("pickaxe", "mymod:mythril", 1.0, -2.8)"#), "the kind's attack: {src}");
4053        assert!(src.contains(r#".armor("head", "minecraft:iron")"#), "{src}");
4054        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}");
4055        assert!(src.contains(r#"registry.register_armor_material(::yog_api::ArmorMaterial { id: "mymod:mythril".into()"#) && src.contains(r#"texture: "mymod:mythril".into()"#), "{src}");
4056
4057        let bad = format!(r#"{{ {model}, item: {{ tool: {{ kind: "spoon", material: "bronze" }} }} }}"#);
4058        let e = compile(&project(&[("content/bad.yitem", &bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4059        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}");
4060        let missing = format!(r#"{{ {model}, item: {{ armor: {{ slot: "feet", material: "@armor_material:mymod:nope" }} }} }}"#);
4061        let e = compile(&project(&[("content/m.yitem", &missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4062        assert!(e.contains("no armor material mymod:nope"), "{e}");
4063    }
4064
4065    #[test]
4066    fn data_naming_renamed_content_gets_each_versions_name() {
4067        let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4068        let block = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:short_grass" }} }} }}"#);
4069        let c = compile(&project(&[("content/tuft.yblock", &block)]), "mymod", &yres::Refs::default()).unwrap();
4070        let loot = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| String::from_utf8_lossy(b).to_string());
4071        assert!(loot("data/mymod/loot_table/blocks/tuft.json").is_some_and(|l| l.contains("minecraft:short_grass")), "the datapack's name");
4072        let old = loot("when/any/1.20.1/data/mymod/loot_table/blocks/tuft.json").expect("1.20.1's overlay");
4073        assert!(old.contains("minecraft:grass") && !old.contains("short_grass"), "{old}");
4074        let crafter = format!(r#"{{ {model}, block: {{ drops: {{ item: "@item:minecraft:crafter" }} }} }}"#);
4075        let c = compile(&project(&[("content/c.yblock", &crafter)]), "mymod", &yres::Refs::default()).unwrap();
4076        assert!(c.warnings.iter().any(|w| w.contains("minecraft:crafter, which Minecraft 1.20.1 hasn't got")), "{:?}", c.warnings);
4077    }
4078
4079    #[test]
4080    fn ores_become_features_in_biomes_on_every_loader() {
4081        let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4082        let block = format!(r#"{{ {model} }}"#);
4083        let ore = r#"{ kind: "ore", id: "mythril_ore", targets: [{ replaces: "stone", block: "@block:mymod:mythril_ore" },
4084            { replaces: "@block:minecraft:basalt", block: "@block:mymod:mythril_ore" }], size: 6, per_chunk: 4,
4085            height: { min: -48, max: 16, shape: "triangle" } }"#;
4086        let c = compile(&project(&[("content/mythril_ore.yblock", &block), ("content/mythril_ore.ydef", ore)]), "mymod", &yres::Refs::default()).unwrap();
4087        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}"));
4088        let cf = get("data/mymod/worldgen/configured_feature/mythril_ore.json");
4089        assert_eq!(cf["config"]["targets"][0]["target"]["tag"], "minecraft:stone_ore_replaceables");
4090        assert_eq!(cf["config"]["targets"][1]["target"]["block"], "minecraft:basalt");
4091        let pf = get("data/mymod/worldgen/placed_feature/mythril_ore.json");
4092        assert_eq!(pf["placement"][2]["height"]["type"], "minecraft:trapezoid");
4093        assert_eq!(get("when/neoforge/1.21/data/mymod/neoforge/biome_modifier/mythril_ore.json")["type"], "neoforge:add_features");
4094        assert_eq!(get("when/forge/any/data/mymod/forge/biome_modifier/mythril_ore.json")["biomes"], "#minecraft:is_overworld");
4095        let src = c.source.unwrap();
4096        assert!(src.contains(r#"registry.add_biome_feature("mymod:mythril_ore", "minecraft:is_overworld", "underground_ores");"#), "{src}");
4097        let bad = r#"{ kind: "ore", id: "x", targets: [{ replaces: "granite", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4098        let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4099        assert!(e.contains("`granite`: one of stone, deepslate, netherrack, end_stone"), "{e}");
4100        let missing = r#"{ kind: "ore", id: "x", targets: [{ replaces: "stone", block: "@block:mymod:nope" }], size: 6, per_chunk: 4, height: { min: 0, max: 10 } }"#;
4101        let e = compile(&project(&[("content/x.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4102        assert!(e.contains("no block mymod:nope"), "{e}");
4103    }
4104
4105    #[test]
4106    fn a_mod_has_loot_tables_of_its_own() {
4107        let table = r#"{ kind: "loot_table", id: "random_gem", add: [{ item: "@item:minecraft:diamond", count: [1, 3] }] }"#;
4108        let c = compile(&project(&[("content/random_gem.ydef", table)]), "mymod", &yres::Refs::default()).unwrap();
4109        let get = |p: &str| c.outputs.iter().find(|(q, _)| q == p).map(|(_, b)| serde_json::from_slice::<Value>(b).unwrap());
4110        assert_eq!(get("data/mymod/loot_table/random_gem.json").expect("the table")["pools"], json!([]));
4111        let src = c.source.unwrap();
4112        assert!(src.contains(r#"registry.add_loot_modifier_data("mymod:random_gem""#), "{src}");
4113        let modifier = r#"{ kind: "loot_modifier", id: "more_gems", tables: ["@loot_table:minecraft:chests/simple_dungeon"], add: [{ item: "@item:minecraft:emerald", chance: 0.5 }] }"#;
4114        let c = compile(&project(&[("content/more_gems.ydef", modifier)]), "mymod", &yres::Refs::default()).unwrap();
4115        assert!(c.source.unwrap().contains(r#"registry.add_loot_modifier_data("mymod:more_gems""#));
4116        let missing = r#"{ kind: "loot_modifier", id: "m", tables: ["@loot_table:minecraft:chests/nope"], add: [] }"#;
4117        assert!(compile(&project(&[("content/m.ydef", missing)]), "mymod", &yres::Refs::default()).unwrap_err().contains("chests/nope"));
4118        let bad = r#"{ kind: "loot_table", id: "minecraft:nope", add: [] }"#;
4119        assert!(compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err().contains("the mod's own"));
4120    }
4121
4122    #[test]
4123    fn a_block_says_how_its_item_stacks() {
4124        let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4125        let block = format!(r#"{{ item: {{ stack_size: 16, fire_resistant: true, fuel_ticks: 300 }}, {model} }}"#);
4126        let plain = format!(r#"{{ {model} }}"#);
4127        let c = compile(&project(&[("content/log.yblock", &block), ("content/stone.yblock", &plain)]), "mymod", &yres::Refs::default()).unwrap();
4128        let src = c.source.unwrap();
4129        assert!(src.contains(r#"registry.register_item(::yog_api::ItemDef::new("mymod:log").max_stack(16).fire_resistant().fuel(300));"#), "{src}");
4130        assert!(!src.contains(r#"ItemDef::new("mymod:stone")"#), "no item form of its own: the host's default");
4131    }
4132
4133    #[test]
4134    fn blocks_and_items_join_their_tags() {
4135        let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4136        let log = format!(r#"{{ tags: ["minecraft:logs", "edified_logs"], {model} }}"#);
4137        let staff = r#"{ tags: ["staves"], model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } } }"#;
4138        let c = compile(&project(&[("content/log.yblock", &log), ("content/staff.yitem", staff)]), "mymod", &yres::Refs::default()).unwrap();
4139        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"] {
4140            assert!(c.tags[t].contains_key("mymod:log"), "{t}");
4141        }
4142        assert!(c.tags["data/mymod/tags/item/staves.json"].contains_key("mymod:staff"));
4143        assert!(!c.tags.contains_key("data/mymod/tags/block/staves.json"), "an item has no block tag");
4144    }
4145
4146    #[test]
4147    fn trees_are_definitions() {
4148        let model = r#"model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } }"#;
4149        let block = format!(r#"{{ {model} }}"#);
4150        let tree = r#"{ kind: "tree", id: "glass_tree", min_clipped_height: 6,
4151            trunk: { blocks: [{ block: "@block:mymod:log", weight: 8 }, { block: "@block:minecraft:oak_log" }], shape: "fancy", base_height: 5, random_a: 5, random_b: 3 },
4152            leaves: { blocks: [{ block: "@block:mymod:leaves" }], shape: "fancy", radius: 1, offset: 5, height: 5 } }"#;
4153        let c = compile(&project(&[("content/log.yblock", &block), ("content/leaves.yblock", &block), ("content/glass_tree.ydef", tree)]), "mymod", &yres::Refs::default()).unwrap();
4154        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}"));
4155        let f = &get("data/mymod/worldgen/configured_feature/glass_tree.json")["config"];
4156        assert_eq!(f["trunk_placer"]["type"], "minecraft:fancy_trunk_placer");
4157        assert_eq!(f["trunk_provider"]["entries"][0]["weight"], 8);
4158        assert_eq!(f["foliage_provider"]["state"]["Name"], "mymod:leaves");
4159        assert_eq!((f["foliage_placer"]["height"].as_u64(), f["minimum_size"]["min_clipped_height"].as_u64()), (Some(5), Some(6)));
4160        let bad = r#"{ kind: "tree", id: "t", trunk: { blocks: [{ block: "@block:mymod:nope" }], shape: "straight", base_height: 4 },
4161            leaves: { blocks: [{ block: "@block:minecraft:oak_leaves" }], shape: "blob", radius: 2 } }"#;
4162        let e = compile(&project(&[("content/t.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4163        assert!(e.contains("no block mymod:nope"), "{e}");
4164    }
4165
4166    #[test]
4167    fn what_a_version_or_loader_hasnt_got_keeps_a_recipe_out_of_it() {
4168        let recipe = |key: &str| format!(r#"{{ type: "shaped", pattern: ["D"], key: {{ D: {key} }}, result: "@item:minecraft:stick" }}"#);
4169        let check = |key: &str| {
4170            let c = compile(&project(&[("content/r.yrecipe", &recipe(key))]), "mymod", &yres::Refs::default());
4171            c.map(|c| c.warnings.iter().filter(|w| w.contains("left out")).cloned().collect::<Vec<_>>().join("\n"))
4172        };
4173        let w = check(r#""@tag:item:minecraft:music_discs""#).unwrap();
4174        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}");
4175        assert_eq!(check(r#""@tag:item:yog:music_discs""#).unwrap(), "");
4176        assert_eq!(check(r#"["@tag:item:c:iron_ingots", "@item:minecraft:iron_ingot"]"#).unwrap(), "", "an alternative that's everywhere");
4177        let w = check(r#""@tag:item:c:iron_ingots""#).unwrap();
4178        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}");
4179        let e = check(r#""@tag:item:c:nope""#).unwrap_err();
4180        assert!(e.contains("no item tag c:nope in Minecraft or its loaders"), "{e}");
4181        let e = check(r#""@tag:item:yog:nope""#).unwrap_err();
4182        assert!(e.contains("no item tag yog:nope among Yog's common tags"), "{e}");
4183        assert!(check(r#""@tag:item:othermod:anything""#).is_ok(), "another mod's tags aren't known here");
4184        let block = r#"{ tags: ["yog:planks", "yog:nope"], model: { parent: "cube_all", textures: { all: "minecraft:block/stone" } } }"#;
4185        let e = compile(&project(&[("content/p.yblock", block)]), "mymod", &yres::Refs::default()).unwrap_err();
4186        assert!(e.contains("tags[1]: no block tag yog:nope") && !e.contains("yog:planks"), "{e}");
4187    }
4188
4189    #[test]
4190    fn tags_and_damage_types_are_definitions() {
4191        let tag = r#"{ kind: "tag", of: "entity", id: "sticky_teleporters", values: ["@entity:minecraft:pig", "@tag:entity:c:bosses"],
4192            optional: ["@entity:create:seat"] }"#;
4193        let logs = r#"{ kind: "tag", of: "block", id: "minecraft:logs", values: ["@block:minecraft:stone"] }"#;
4194        let dmg = r#"{ kind: "damage_type", id: "overcast", scaling: "never", tags: ["minecraft:bypasses_armor", "bypasses_effects"] }"#;
4195        let c = compile(&project(&[("content/a.ydef", tag), ("content/b.ydef", logs), ("content/c.ydef", dmg)]), "mymod", &yres::Refs::default()).unwrap();
4196        let sticky = &c.tags["data/mymod/tags/entity_type/sticky_teleporters.json"];
4197        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");
4198        assert!(c.tags["data/minecraft/tags/block/logs.json"].contains_key("minecraft:stone"));
4199        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}"));
4200        let d = get("data/mymod/damage_type/overcast.json");
4201        assert_eq!((d["message_id"].as_str(), d["scaling"].as_str()), (Some("mymod.overcast"), Some("never")));
4202        for t in ["data/minecraft/tags/damage_type/bypasses_armor.json", "data/minecraft/tags/damage_type/bypasses_effects.json"] {
4203            assert!(c.tags[t].contains_key("mymod:overcast"), "{t}");
4204        }
4205        let bad = r#"{ kind: "tag", of: "entity", id: "x", values: ["@entity:minecraft:nope", "@block:minecraft:stone"] }"#;
4206        let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4207        assert!(e.contains("nope") && e.contains("`@block:minecraft:stone` in a tag of entity"), "{e}");
4208    }
4209
4210    #[test]
4211    fn a_dimension_and_its_terrain_go_to_the_runtime_as_data() {
4212        let dim = r#"{ kind: "dimension", id: "flatlands", dimension_type: { has_sky_light: true }, biome: "@biome:minecraft:desert",
4213            terrain: { seed: 3, base_height: 70, noise: [{ scale: 80, amplitude: 6 }], fill: "@block:minecraft:sandstone",
4214                layers: [{ block: "@block:minecraft:sand", depth: 3 }] } }"#;
4215        let c = compile(&project(&[("content/flatlands.ydef", dim)]), "mymod", &yres::Refs::default()).unwrap();
4216        let (path, data) = c.data_files.iter().find(|(p, _)| p.starts_with("dimensions/")).expect("the dimension's data");
4217        assert_eq!(path, "dimensions/mymod__flatlands.ydata");
4218        let v = yog_data::binary::decode(data).unwrap();
4219        assert_eq!(v.get("biome"), Some(&yog_data::Value::from("minecraft:desert")), "a plain id: the hosts read it");
4220        assert_eq!(v.get("generator_type"), Some(&yog_data::Value::from("mymod:flatlands")));
4221        let t = yog_data::read_value::<yog_worldgen::Terrain>(v.get("terrain").unwrap()).unwrap();
4222        assert_eq!((t.base_height, t.fill.as_str()), (70, "minecraft:sandstone"));
4223        let src = c.source.unwrap();
4224        assert!(src.contains(r#"registry.register_dimension_data("mymod:flatlands", include_bytes!("#), "{src}");
4225        let bad = r#"{ kind: "dimension", id: "x", terrain: { fill: "@block:minecraft:nope" } }"#;
4226        let e = compile(&project(&[("content/x.ydef", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4227        assert!(e.contains("no block minecraft:nope"), "{e}");
4228    }
4229
4230    #[test]
4231    fn content_for_other_mods_is_registered_only_with_them() {
4232        let model = r#"model: { parent: "generated", textures: { layer0: "minecraft:item/stick" } }"#;
4233        let item = format!(r#"{{ requires: ["pehkui", "create"], {model} }}"#);
4234        let recipe = r#"{ requires: ["pehkui"], type: "shapeless", ingredients: ["@item:minecraft:stick"], result: "@item:minecraft:stick" }"#;
4235        let c = compile(&project(&[("content/shrink.yitem", &item), ("content/r.yrecipe", recipe)]), "mymod", &yres::Refs::default()).unwrap();
4236        let src = c.source.unwrap();
4237        assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") && ::yog_api::mod_loaded("create") { registry.register_item(Items::shrink()); }"#), "{src}");
4238        assert!(src.contains(r#"if ::yog_api::mod_loaded("pehkui") { registry.add_recipe("#), "{src}");
4239    }
4240
4241    #[test]
4242    fn books_lay_out_the_mods_own_recipe_kinds() {
4243        let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
4244        let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
4245            entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
4246                pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
4247        let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true } } }"#;
4248        let layout = r#"<Component name="InfusionLayout" recipe_layout="infusion" width="96" height="24">
4249                <RecipeSlot slot="core" x="4" y="4" frame="true"/>
4250                <ItemSlot item="minecraft:beacon" x="40" y="4"/>
4251                <RecipeResult x="76" y="4" frame="true"/>
4252            </Component>"#;
4253        let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book), ("content/infusion.ydef", kind)];
4254        let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
4255        assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion"), "{e}");
4256        let c = compile(&project(&[files[0], files[1], files[2], ("src/ui/infusion.yui", layout)]), "mymod", &yres::Refs::default()).unwrap();
4257        let book = yog_data::read_bytes::<yog_book::Book>(&c.data_files[0].1).unwrap();
4258        assert_eq!(book.layouts[0].kinds, ["mymod:infusion"], "the book carries the mod's layouts");
4259        // What a layout names is checked: the kinds, a mod kind's slots, the stations.
4260        let bad = r#"<Component recipe_layout="create:mixing infusion" width="1" height="1">
4261                <RecipeSlot slot="ring"/><ItemSlot item="minecraft:nope"/></Component>"#;
4262        let e = compile(&project(&[files[2], ("src/ui/bad.yui", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4263        assert!(e.contains("`create:mixing`: one of shaped"), "{e}");
4264        assert!(e.contains("mymod:infusion has no slot `ring` — core"), "{e}");
4265        assert!(e.contains("<ItemSlot item=\"minecraft:nope\">: no item minecraft:nope in Minecraft"), "{e}");
4266    }
4267
4268    #[test]
4269    fn compiles_a_recipe_of_the_mods_own_kind() {
4270        let kind = r#"{ kind: "recipe_kind", id: "infusion", slots: { core: { required: true }, ring: { max: 2 } }, values: { time: "number" } }"#;
4271        let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
4272                     values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
4273        let c = compile(&project(&[("content/infusion.ydef", kind), ("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
4274        let src = c.source.unwrap();
4275        assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
4276        assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
4277
4278        // Against its kind: slots, how many, values and their types.
4279        let bad = r##"{ type: "mymod:infusion", slots: { ring: ["@item:minecraft:coal", "@item:minecraft:coal", "@item:minecraft:coal"], nope: ["@item:minecraft:coal"] },
4280                       values: { time: "soon", heat: 3 }, result: "@item:minecraft:emerald" }"##;
4281        let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4282        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)"] {
4283            assert!(e.contains(want), "{want}: {e}");
4284        }
4285        let undeclared = r#"{ "type": "mymod:infusion", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4286        let e = compile(&project(&[("content/u.yrecipe", undeclared)]), "mymod", &yres::Refs::default()).unwrap_err();
4287        assert!(e.contains("type `mymod:infusion`: no such kind — declare it: content/infusion.ydef"), "{e}");
4288        let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
4289        let e = compile(&project(&[("content/infusion.ydef", kind), ("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4290        assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
4291        // Another mod's kind isn't the mod's own: not a Yog recipe type.
4292        let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
4293        let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
4294        assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
4295    }
4296
4297    #[test]
4298    fn compiles_an_item_with_resource_refs() {
4299        let root = project(&[(
4300            "content/items/ruby.yitem",
4301            r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
4302                 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
4303                 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
4304        )]);
4305        let mut refs = yres::Refs::default();
4306        refs.texts.insert(
4307            "Items.ruby".into(),
4308            ("item.mymod.ruby".into(), "Ruby".into()),
4309        );
4310        refs.texts.insert(
4311            "Strings.ruby_tip".into(),
4312            ("mymod.ruby_tip".into(), "Shiny".into()),
4313        );
4314        refs.images
4315            .insert("Textures.ruby".into(), "mymod:item/ruby".into());
4316        let c = compile(&root, "mymod", &refs).unwrap();
4317        assert_eq!(
4318            output(&c, "assets/mymod/models/item/ruby.json"),
4319            json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
4320        );
4321        let src = c.source.clone().unwrap();
4322        assert!(src.contains(".max_stack(16)"), "{src}");
4323        assert!(
4324            src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
4325            "{src}"
4326        );
4327        // The .yres lang file gets the tooltip under the key the game reads.
4328        let mut outputs = vec![(
4329            "assets/mymod/lang/ru_ru.json".to_string(),
4330            r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
4331                .as_bytes()
4332                .to_vec(),
4333        )];
4334        merge_outputs(&root, &mut outputs, c).unwrap();
4335        let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
4336        assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
4337        let _ = std::fs::remove_dir_all(root);
4338    }
4339
4340    #[test]
4341    fn namespace_needed_for_odd_mod_ids() {
4342        let root = project(&[(
4343            "content/x.yitem",
4344            r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
4345        )]);
4346        let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
4347        assert!(e.contains("give the namespace"), "{e}");
4348        let _ = std::fs::remove_dir_all(root);
4349    }
4350
4351    #[test]
4352    fn body_files_compile_and_register() {
4353        let model = r#"{ "texture": "misc/wings.png", "bones": [
4354            { "name": "wing", "pivot": [5, 0, 0], "cubes": [{ "origin": [-10, 0, 0], "size": [10, 20, 2], "uv": [22, 0] }] } ] }"#;
4355        let anim = r#"{ "spread": { "length": 0.5, "bones": { "wing": { "rotation": { "0": [0, 0, 0], "0.5": [0, 0, -90] } } } } }"#;
4356        let out = compile(&project(&[("content/wings.ymodel", model), ("content/wings.yanim", anim)]), "mymod", &yres::Refs::default()).unwrap();
4357        assert!(out.data_files.iter().any(|(p, _)| p == "body/models/mymod__wings.ydata"));
4358        let src = out.source.unwrap();
4359        assert!(src.contains("registry.register_body_model(\"mymod:wings\""), "{src}");
4360        assert!(src.contains("registry.register_body_anims(\"mymod:wings\""), "{src}");
4361        assert!(src.contains("pub const WINGS: &'static str = \"mymod:wings\";"), "{src}");
4362        let bad = r#"{ "texture": "x.png", "bones": [{ "name": "a", "parent": "b" }] }"#;
4363        let e = compile(&project(&[("content/bad.ymodel", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
4364        assert!(e.contains("must come before it"), "{e}");
4365    }
4366
4367    #[test]
4368    fn entities_compile_register_and_name_their_models() {
4369        let model = r#"{ "texture": "entity/wisp.png", "bones": [{ "name": "body", "cubes": [{ "origin": [-2, 0, -2], "size": [4, 4, 4] }] }] }"#;
4370        let wisp = r#"{ "name": "Wisp", "kind": "mob", "width": 0.5, "height": 0.5, "model": "wisp", "goals": ["float", "wander"],
4371            "attributes": { "max_health": 6, "movement_speed": 0.3 } }"#;
4372        let out = compile(&project(&[("content/wisp.ymodel", model), ("content/wisp.yentity", wisp)]), "mymod", &yres::Refs::default()).unwrap();
4373        let src = out.source.as_ref().unwrap();
4374        assert!(src.contains("registry.register_entity_data(\"mymod:wisp\""), "{src}");
4375        assert!(src.contains("pub const WISP: &'static str = \"mymod:wisp\";"), "{src}");
4376        assert!(out.data_files.iter().any(|(p, _)| p == "entities/mymod__wisp.ydata"));
4377        let (_, data) = out.data_files.iter().find(|(p, _)| p == "entities/mymod__wisp.ydata").unwrap();
4378        let def: yog_registry::EntityDef = yog_data::read_bytes(data).unwrap();
4379        assert_eq!((def.kind, def.model.as_deref()), (yog_registry::EntityKind::Mob, Some("mymod:wisp")));
4380        let missing = r#"{ "kind": "object", "width": 1, "height": 1, "model": "nope" }"#;
4381        let e = compile(&project(&[("content/thing.yentity", missing)]), "mymod", &yres::Refs::default()).unwrap_err();
4382        assert!(e.contains("model `mymod:nope`: no such file"), "{e}");
4383    }
4384
4385}