Skip to main content

yog_cli/
content.rs

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