Skip to main content

yog_cli/
content.rs

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