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