Skip to main content

yog_cli/
content.rs

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