Skip to main content

yog_cli/
content.rs

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