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