Skip to main content

yog_cli/
content.rs

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