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