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