Skip to main content

yog_cli/
content.rs

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