Skip to main content

yog_cli/
content.rs

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