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;
25
26pub use import::import_command;
27
28use crate::yres;
29use json::{J, V, num_value};
30use serde_json::{Map, Value, json};
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::fmt::Write as _;
33use std::path::{Path, PathBuf};
34
35/// Where content files live, under the mod root.
36pub const CONTENT_DIR: &str = "content";
37/// The generated Rust code, under the mod root.
38pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
39
40/// A generated file: path inside the `.yog` and its bytes.
41pub type Output = (String, Vec<u8>);
42
43/// What a content file defines.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum Kind {
46    /// `.yblock` — a block and its item form.
47    Block,
48    /// `.yitem` — an item.
49    Item,
50    /// `.yrecipe` — a recipe.
51    Recipe,
52    /// `.ybook` — a guide book (`yog_book::Book` as JSON).
53    Book,
54}
55
56impl Kind {
57    pub fn of(path: &Path) -> Option<Kind> {
58        match path.extension()?.to_str()? {
59            "yblock" => Some(Kind::Block),
60            "yitem" => Some(Kind::Item),
61            "yrecipe" => Some(Kind::Recipe),
62            "ybook" => Some(Kind::Book),
63            _ => None,
64        }
65    }
66
67    fn word(self) -> &'static str {
68        match self {
69            Kind::Block => "block",
70            Kind::Item => "item",
71            Kind::Recipe => "recipe",
72            Kind::Book => "book",
73        }
74    }
75}
76
77/// Everything `yog build` makes of a mod's content files.
78#[derive(Debug, Default)]
79pub struct Compiled {
80    /// Models, blockstates, item definitions, loot tables, textures.
81    pub outputs: Vec<Output>,
82    /// Lang entries: `(namespace, locale)` → key → text, merged into the
83    /// lang files by [`merge_outputs`].
84    lang: BTreeMap<(String, String), BTreeMap<String, String>>,
85    /// `.yres` text keys to copy under another key: (from, to).
86    lang_aliases: Vec<(String, String)>,
87    /// Block tag path (`data/minecraft/tags/block/...json`) → entries.
88    tags: BTreeMap<String, BTreeSet<String>>,
89    /// The generated Rust code; `None` when the mod has no content files.
90    pub source: Option<String>,
91    pub warnings: Vec<String>,
92    pub blocks: usize,
93    pub items: usize,
94    pub recipes: usize,
95    pub books: usize,
96}
97
98// ── Vocabulary ───────────────────────────────────────────────────────────────
99
100pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
101pub const DISPLAY_SLOTS: [&str; 8] = [
102    "thirdperson_righthand",
103    "thirdperson_lefthand",
104    "firstperson_righthand",
105    "firstperson_lefthand",
106    "gui",
107    "head",
108    "ground",
109    "fixed",
110];
111/// The sound groups the loaders map (`BlockDef::sound`).
112pub const SOUNDS: [&str; 10] = [
113    "stone",
114    "wood",
115    "grass",
116    "gravel",
117    "sand",
118    "snow",
119    "metal",
120    "glass",
121    "wool",
122    "nether_brick",
123];
124pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
125pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
126pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
127pub const RECIPE_TYPES: [&str; 8] =
128    ["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
129
130/// A model template — `"parent": "cube_all"` — and the texture slots it takes.
131pub struct Template {
132    pub name: &'static str,
133    /// The vanilla model it compiles to.
134    pub parent: &'static str,
135    /// Its texture slots; for item sprites, `layer0` (more layers allowed).
136    pub slots: &'static [&'static str],
137    /// An item sprite template (`generated`, `handheld`) — item models only.
138    pub sprite: bool,
139    /// The parent model defines `particle` itself.
140    pub has_particle: bool,
141}
142
143pub const TEMPLATES: &[Template] = &[
144    Template {
145        name: "cube_all",
146        parent: "minecraft:block/cube_all",
147        slots: &["all"],
148        sprite: false,
149        has_particle: true,
150    },
151    Template {
152        name: "cube_column",
153        parent: "minecraft:block/cube_column",
154        slots: &["end", "side"],
155        sprite: false,
156        has_particle: true,
157    },
158    Template {
159        name: "cube_bottom_top",
160        parent: "minecraft:block/cube_bottom_top",
161        slots: &["top", "bottom", "side"],
162        sprite: false,
163        has_particle: true,
164    },
165    Template {
166        name: "cube",
167        parent: "minecraft:block/cube",
168        slots: &["down", "up", "north", "south", "west", "east"],
169        sprite: false,
170        has_particle: false,
171    },
172    Template {
173        name: "orientable",
174        parent: "minecraft:block/orientable",
175        slots: &["front", "side", "top"],
176        sprite: false,
177        has_particle: true,
178    },
179    Template {
180        name: "cross",
181        parent: "minecraft:block/cross",
182        slots: &["cross"],
183        sprite: false,
184        has_particle: true,
185    },
186    Template {
187        name: "generated",
188        parent: "minecraft:item/generated",
189        slots: &["layer0"],
190        sprite: true,
191        has_particle: true,
192    },
193    Template {
194        name: "handheld",
195        parent: "minecraft:item/handheld",
196        slots: &["layer0"],
197        sprite: true,
198        has_particle: true,
199    },
200];
201
202pub fn template(name: &str) -> Option<&'static Template> {
203    TEMPLATES.iter().find(|t| t.name == name)
204}
205
206/// `[a-z0-9_.-]+` — a Minecraft namespace.
207pub fn valid_namespace(s: &str) -> bool {
208    !s.is_empty()
209        && s.bytes().all(|b| {
210            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
211        })
212}
213
214/// `[a-z0-9_./-]+` — a Minecraft resource path.
215pub fn valid_path(s: &str) -> bool {
216    !s.is_empty()
217        && !s.starts_with('/')
218        && !s.ends_with('/')
219        && s.bytes().all(|b| {
220            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
221        })
222}
223
224/// `ns:path` (or `path`, in `minecraft`) as a valid resource id.
225pub fn valid_id(s: &str) -> bool {
226    match s.split_once(':') {
227        Some((ns, path)) => valid_namespace(ns) && valid_path(path),
228        None => valid_path(s),
229    }
230}
231
232// ── Finding ──────────────────────────────────────────────────────────────────
233
234/// Every `content/**/*.yblock|*.yitem|*.yrecipe|*.ybook` of the mod at `root`, sorted.
235pub fn find(root: &Path) -> Vec<PathBuf> {
236    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
237        let Ok(entries) = std::fs::read_dir(dir) else {
238            return;
239        };
240        for entry in entries.flatten() {
241            let path = entry.path();
242            if path.is_dir() {
243                walk(&path, out);
244            } else if Kind::of(&path).is_some() {
245                out.push(path);
246            }
247        }
248    }
249    let mut out = Vec::new();
250    walk(&root.join(CONTENT_DIR), &mut out);
251    out.sort();
252    out
253}
254
255fn display(root: &Path, p: &Path) -> String {
256    p.strip_prefix(root)
257        .unwrap_or(p)
258        .to_string_lossy()
259        .replace('\\', "/")
260}
261
262// ── Compiling ────────────────────────────────────────────────────────────────
263
264/// A name or tooltip.
265#[derive(Debug, Clone)]
266enum Text {
267    /// A `.yres` text: its key.
268    Key(String),
269    Literal(String),
270}
271
272#[derive(Debug, Clone, Default)]
273struct BlockProps {
274    hardness: Option<f64>,
275    resistance: Option<f64>,
276    sound: Option<String>,
277    light: u8,
278    tool: Option<String>,
279    tool_level: Option<String>,
280    requires_tool: bool,
281    no_collision: bool,
282    slipperiness: Option<f64>,
283    /// `None` = full cube.
284    shape: Option<[f64; 6]>,
285    connect_groups: Vec<String>,
286    connects: bool,
287    /// The inventory screen it opens (a `.yui` `<Screen>` with `<Slot>`s).
288    inventory: Option<String>,
289    /// Gives off redstone power, set by the mod per position.
290    redstone: bool,
291}
292
293#[derive(Debug, Clone, Default)]
294struct ItemProps {
295    stack_size: Option<u8>,
296    durability: u32,
297    fire_resistant: bool,
298    fuel_ticks: u32,
299    /// (nutrition, saturation, always_edible)
300    food: Option<(u32, f64, bool)>,
301}
302
303/// One checked content file — what the Rust code needs.
304#[derive(Debug, Clone)]
305struct Entry {
306    kind: Kind,
307    id: String,
308    file: String,
309    auto: bool,
310    block: BlockProps,
311    item: ItemProps,
312}
313
314/// Per-file checking state.
315struct Ctx<'a> {
316    root: &'a Path,
317    refs: &'a yres::Refs,
318    file: String,
319    ns: String,
320    errors: &'a mut Vec<String>,
321    warnings: &'a mut Vec<String>,
322    /// Bundled texture path → its source file.
323    textures: &'a mut BTreeMap<String, PathBuf>,
324    /// `block.inventory` screen ids named so far: (file, line, id) —
325    /// checked against the mod's screens once every file is read.
326    inventories: &'a mut Vec<(String, usize, String)>,
327}
328
329impl Ctx<'_> {
330    fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
331        self.errors.push(format!("{}:{line}: {msg}", self.file));
332    }
333
334    fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
335        self.warnings.push(format!("{}:{line}: {msg}", self.file));
336    }
337
338    /// Reports keys of `obj` not in `known`.
339    fn unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
340        for (k, v) in obj.members() {
341            if !known.contains(&k.as_str()) && !is_metadata(k) {
342                self.err(
343                    v.line,
344                    format!(
345                        "unknown key {k:?} in {what} (expected one of: {})",
346                        known.join(", ")
347                    ),
348                );
349            }
350        }
351    }
352
353    fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
354        match j.num() {
355            Some(n) if (min..=max).contains(&n) => Some(n),
356            Some(n) => {
357                self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
358                None
359            }
360            None => {
361                self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
362                None
363            }
364        }
365    }
366
367    fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
368        let b = j.bool();
369        if b.is_none() {
370            self.err(j.line, format!("{what} must be true or false"));
371        }
372        b
373    }
374
375    fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
376        let s = j.str();
377        if s.is_none() {
378            self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
379        }
380        s
381    }
382
383    fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
384        let s = self.string(j, what)?;
385        if allowed.contains(&s) {
386            Some(s.to_string())
387        } else {
388            self.err(
389                j.line,
390                format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
391            );
392            None
393        }
394    }
395
396    fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
397        let items = j.items();
398        if !j.is_arr() || items.len() != N {
399            self.err(j.line, format!("{what} must be an array of {N} numbers"));
400            return None;
401        }
402        let mut out = [0.0; N];
403        for (i, item) in items.iter().enumerate() {
404            out[i] = self.number(item, what, min, max)?;
405        }
406        Some(out)
407    }
408
409    /// A name/tooltip: `@<Set>.<name>` of a `.yres` text, `@@...` for a
410    /// literal `@`, or a literal.
411    fn text(&mut self, j: &J, what: &str) -> Option<Text> {
412        let s = self.string(j, what)?;
413        match s.strip_prefix('@') {
414            Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
415            Some(rest) => match self.refs.texts.get(rest) {
416                Some((key, _)) => Some(Text::Key(key.clone())),
417                None => {
418                    self.err(
419                        j.line,
420                        format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
421                    );
422                    None
423                }
424            },
425            None => Some(Text::Literal(s.to_string())),
426        }
427    }
428
429    /// A texture: `#slot`, `@<Set>.<name>` (a `.yres` image), a `.png` path
430    /// inside `resources/` (bundled), or a texture id (`ns:block/x`,
431    /// `block/stone` = `minecraft:block/stone`). → the model's form.
432    fn texture(&mut self, j: &J, what: &str) -> Option<String> {
433        let s = self.string(j, what)?.trim().to_string();
434        if let Some(slot) = s.strip_prefix('#') {
435            if slot.is_empty() {
436                self.err(j.line, format!("{what}: `#` needs a slot name"));
437                return None;
438            }
439            return Some(s);
440        }
441        if let Some(rest) = s.strip_prefix('@') {
442            return match self.refs.images.get(rest) {
443                Some(id) => Some(id.clone()),
444                None => {
445                    self.err(
446                        j.line,
447                        format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
448                    );
449                    None
450                }
451            };
452        }
453        if let Some(stem) = s.strip_suffix(".png") {
454            let rel = stem.trim_start_matches("./").trim_start_matches('/');
455            let rel = rel.strip_prefix("resources/").unwrap_or(rel);
456            if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
457                self.err(
458                    j.line,
459                    format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
460                );
461                return None;
462            }
463            let file = self
464                .root
465                .join(yres::RESOURCES_DIR)
466                .join(format!("{rel}.png"));
467            if !file.is_file() {
468                self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
469                return None;
470            }
471            let target = format!("assets/{}/textures/{rel}.png", self.ns);
472            if let Some(other) = self.textures.insert(target.clone(), file.clone())
473                && other != file
474            {
475                self.err(
476                    j.line,
477                    format!(
478                        "{what}: {target} also comes from {}",
479                        display(self.root, &other)
480                    ),
481                );
482            }
483            let meta = self
484                .root
485                .join(yres::RESOURCES_DIR)
486                .join(format!("{rel}.png.mcmeta"));
487            if meta.is_file() {
488                self.textures.insert(format!("{target}.mcmeta"), meta);
489            }
490            return Some(format!("{}:{rel}", self.ns));
491        }
492        if !valid_id(&s) {
493            self.err(
494                j.line,
495                format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
496            );
497            return None;
498        }
499        let id = if s.contains(':') {
500            s
501        } else {
502            format!("minecraft:{s}")
503        };
504        let (ns, path) = id.split_once(':').unwrap_or_default();
505        if ns == self.ns {
506            let file = self
507                .root
508                .join("assets")
509                .join(ns)
510                .join("textures")
511                .join(format!("{path}.png"));
512            let bundled = self
513                .textures
514                .contains_key(&format!("assets/{ns}/textures/{path}.png"))
515                || self.refs.images.values().any(|v| *v == id);
516            if !file.is_file() && !bundled {
517                self.warn(
518                    j.line,
519                    format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
520                );
521            }
522        }
523        Some(id)
524    }
525}
526
527/// A checked model: Minecraft's JSON, its slots, its bounding box.
528struct Model {
529    json: Value,
530    /// Elements' bounding box in pixels, when it has elements.
531    bounds: Option<[f64; 6]>,
532}
533
534/// Checks a model object → Minecraft's model JSON. `item` = an item model
535/// (sprite templates allowed).
536fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
537    if !j.is_obj() {
538        cx.err(j.line, format!("{what} must be an object"));
539        return None;
540    }
541    cx.unknown_keys(
542        j,
543        what,
544        &[
545            "parent",
546            "textures",
547            "elements",
548            "display",
549            "ambient_occlusion",
550            "gui_light",
551        ],
552    );
553    let mut out = Map::new();
554    let mut tpl: Option<&Template> = None;
555    let mut raw_parent = false;
556    if let Some(p) = j.get("parent") {
557        let name = cx.string(p, &format!("{what}.parent"))?;
558        if let Some(t) = template(name) {
559            if t.sprite && !item {
560                cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
561            }
562            out.insert("parent".into(), Value::String(t.parent.into()));
563            tpl = Some(t);
564        } else if name.contains(':') && valid_id(name) {
565            out.insert("parent".into(), Value::String(name.into()));
566            raw_parent = true;
567        } else {
568            let names: Vec<&str> = TEMPLATES
569                .iter()
570                .filter(|t| item || !t.sprite)
571                .map(|t| t.name)
572                .collect();
573            cx.err(
574                p.line,
575                format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
576            );
577            return None;
578        }
579    }
580
581    // Textures.
582    let mut slots: Vec<String> = Vec::new();
583    let mut textures = Map::new();
584    if let Some(t) = j.get("textures") {
585        if !t.is_obj() {
586            cx.err(
587                t.line,
588                format!("{what}.textures must be an object of slot → texture"),
589            );
590        }
591        for (slot, v) in t.members() {
592            if slot.is_empty() || slot.starts_with('#') {
593                cx.err(
594                    v.line,
595                    format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
596                );
597                continue;
598            }
599            if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
600                textures.insert(slot.clone(), Value::String(tex));
601                slots.push(slot.clone());
602            }
603        }
604    }
605    let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
606    if let Some(t) = tpl {
607        if t.sprite {
608            if !defined("layer0", &textures) {
609                cx.err(
610                    j.line,
611                    format!("{what}: parent {:?} needs textures.layer0", t.name),
612                );
613            }
614            for slot in &slots {
615                let n = slot
616                    .strip_prefix("layer")
617                    .and_then(|n| n.parse::<usize>().ok());
618                match n {
619                    Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
620                    Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
621                    None if slot == "particle" => {}
622                    None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
623                }
624            }
625        } else {
626            for slot in t.slots {
627                if !defined(slot, &textures) {
628                    cx.err(
629                        j.line,
630                        format!("{what}: parent {:?} needs textures.{slot}", t.name),
631                    );
632                }
633            }
634        }
635    }
636
637    // Elements.
638    let mut bounds: Option<[f64; 6]> = None;
639    if let Some(e) = j.get("elements") {
640        if let Some(t) = tpl {
641            cx.err(
642                e.line,
643                format!(
644                    "{what}: elements replace the shape of parent {:?} — remove one of them",
645                    t.name
646                ),
647            );
648        }
649        if !e.is_arr() {
650            cx.err(e.line, format!("{what}.elements must be an array"));
651        }
652        let mut elements = Vec::new();
653        for (i, el) in e.items().iter().enumerate() {
654            if let Some((json, from, to)) = element(
655                cx,
656                el,
657                &format!("{what}.elements[{i}]"),
658                &textures,
659                raw_parent,
660            ) {
661                let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
662                for a in 0..3 {
663                    b[a] = b[a].min(from[a].min(to[a]));
664                    b[a + 3] = b[a + 3].max(from[a].max(to[a]));
665                }
666                elements.push(json);
667            }
668        }
669        out.insert("elements".into(), Value::Array(elements));
670    } else if tpl.is_none() && !raw_parent {
671        cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
672    }
673
674    // `particle` — the break/run particles. Default: the first texture —
675    // also under a raw parent when the model brings its own elements (a
676    // parent like `minecraft:block/block` names no particle texture).
677    if !textures.contains_key("particle")
678        && !tpl.is_some_and(|t| t.has_particle)
679        && (!raw_parent || j.get("elements").is_some())
680        && let Some(first) = slots.first()
681    {
682        let v = textures[first].clone();
683        textures.insert("particle".into(), v);
684    }
685    if !textures.is_empty() {
686        out.insert("textures".into(), Value::Object(textures));
687    }
688
689    if let Some(d) = j.get("display") {
690        if !d.is_obj() {
691            cx.err(d.line, format!("{what}.display must be an object"));
692        }
693        let mut display = Map::new();
694        for (slot, t) in d.members() {
695            let w = format!("{what}.display.{slot}");
696            if !DISPLAY_SLOTS.contains(&slot.as_str()) {
697                cx.err(
698                    t.line,
699                    format!(
700                        "{w}: unknown display position (expected one of: {})",
701                        DISPLAY_SLOTS.join(", ")
702                    ),
703                );
704                continue;
705            }
706            cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
707            let mut m = Map::new();
708            for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
709                if let Some(v) = t.get(key)
710                    && let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
711                {
712                    m.insert(
713                        key.into(),
714                        Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
715                    );
716                }
717            }
718            display.insert(slot.clone(), Value::Object(m));
719        }
720        out.insert("display".into(), Value::Object(display));
721    }
722    if let Some(ao) = j.get("ambient_occlusion")
723        && let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
724    {
725        out.insert("ambientocclusion".into(), Value::Bool(b));
726    }
727    if let Some(gl) = j.get("gui_light")
728        && let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
729    {
730        out.insert("gui_light".into(), Value::String(s));
731    }
732    Some(Model {
733        json: Value::Object(out),
734        bounds,
735    })
736}
737
738/// Checks one element → (its JSON, from, to).
739fn element(
740    cx: &mut Ctx,
741    j: &J,
742    what: &str,
743    textures: &Map<String, Value>,
744    raw_parent: bool,
745) -> Option<(Value, [f64; 3], [f64; 3])> {
746    if !j.is_obj() {
747        cx.err(j.line, format!("{what} must be an object"));
748        return None;
749    }
750    cx.unknown_keys(
751        j,
752        what,
753        &["name", "from", "to", "rotation", "shade", "faces"],
754    );
755    if let Some(n) = j.get("name") {
756        cx.string(n, &format!("{what}.name"));
757    }
758    let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
759        cx.err(j.line, format!("{what} needs `from` and `to`"));
760        return None;
761    };
762    let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
763    let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
764    let mut out = Map::new();
765    out.insert(
766        "from".into(),
767        Value::Array(from.iter().map(|n| num_value(*n)).collect()),
768    );
769    out.insert(
770        "to".into(),
771        Value::Array(to.iter().map(|n| num_value(*n)).collect()),
772    );
773    if let Some(r) = j.get("rotation") {
774        let w = format!("{what}.rotation");
775        cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
776        let axis = r
777            .get("axis")
778            .and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
779        let angle = r
780            .get("angle")
781            .and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
782        if let Some(a) = angle
783            && (a / 22.5).fract() != 0.0
784        {
785            cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
786        }
787        let origin = match r.get("origin") {
788            Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
789            None => Some([8.0, 8.0, 8.0]),
790        };
791        if axis.is_none() || angle.is_none() {
792            if r.get("axis").is_none() || r.get("angle").is_none() {
793                cx.err(r.line, format!("{w} needs `axis` and `angle`"));
794            }
795        } else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
796            let mut rot = Map::new();
797            rot.insert(
798                "origin".into(),
799                Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
800            );
801            rot.insert("axis".into(), Value::String(axis));
802            rot.insert("angle".into(), num_value(angle));
803            if let Some(rs) = r.get("rescale")
804                && let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
805                && b
806            {
807                rot.insert("rescale".into(), Value::Bool(true));
808            }
809            out.insert("rotation".into(), Value::Object(rot));
810        }
811    }
812    if let Some(s) = j.get("shade")
813        && let Some(b) = cx.boolean(s, &format!("{what}.shade"))
814        && !b
815    {
816        out.insert("shade".into(), Value::Bool(false));
817    }
818    let mut faces = Map::new();
819    match j.get("faces") {
820        None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
821        Some(f) => {
822            if !f.is_obj() {
823                cx.err(
824                    f.line,
825                    format!("{what}.faces must be an object of direction → face"),
826                );
827            }
828            for (dir, face) in f.members() {
829                let w = format!("{what}.faces.{dir}");
830                if !DIRECTIONS.contains(&dir.as_str()) {
831                    cx.err(
832                        face.line,
833                        format!(
834                            "{w}: unknown face (expected one of: {})",
835                            DIRECTIONS.join(", ")
836                        ),
837                    );
838                    continue;
839                }
840                cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
841                let mut fo = Map::new();
842                match face.get("texture") {
843                    None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
844                    Some(t) => {
845                        if let Some(s) = cx.string(t, &format!("{w}.texture")) {
846                            let slot = s.strip_prefix('#').unwrap_or(s);
847                            if !textures.contains_key(slot) && !raw_parent {
848                                cx.err(
849                                    t.line,
850                                    format!("{w}.texture `#{slot}`: no such slot in textures"),
851                                );
852                            }
853                            fo.insert("texture".into(), Value::String(format!("#{slot}")));
854                        }
855                    }
856                }
857                if let Some(uv) = face.get("uv")
858                    && let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
859                {
860                    fo.insert(
861                        "uv".into(),
862                        Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
863                    );
864                }
865                if let Some(r) = face.get("rotation") {
866                    match r.num() {
867                        Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
868                            if n != 0.0 {
869                                fo.insert("rotation".into(), num_value(n));
870                            }
871                        }
872                        _ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
873                    }
874                }
875                if let Some(c) = face.get("cullface")
876                    && let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
877                {
878                    fo.insert("cullface".into(), Value::String(c));
879                }
880                if let Some(t) = face.get("tint")
881                    && let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
882                    && n >= 0.0
883                {
884                    fo.insert("tintindex".into(), num_value(n.floor()));
885                }
886                faces.insert(dir.clone(), Value::Object(fo));
887            }
888        }
889    }
890    out.insert("faces".into(), Value::Object(faces));
891    Some((Value::Object(out), from, to))
892}
893
894/// The id of a file: `"id"` or the file stem; `ns:` defaults to the mod's.
895fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
896    let (raw, line) = match j.get("id") {
897        Some(v) => (cx.string(v, "id")?.to_string(), v.line),
898        None => (
899            path.file_stem()
900                .unwrap_or_default()
901                .to_string_lossy()
902                .into_owned(),
903            1,
904        ),
905    };
906    let (ns, p) = match raw.split_once(':') {
907        Some((ns, p)) => (ns.to_string(), p.to_string()),
908        None => match mod_ns {
909            Some(ns) => (ns.to_string(), raw.clone()),
910            None => {
911                cx.err(
912                    line,
913                    "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
914                );
915                return None;
916            }
917        },
918    };
919    if !valid_namespace(&ns) || !valid_path(&p) {
920        cx.err(
921            line,
922            format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
923        );
924        return None;
925    }
926    Some((ns, p))
927}
928
929/// The JSON of one block state: `{"model": ..., "x": .., "y": .., ...}`.
930fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
931    let mut v = Map::new();
932    v.insert("model".into(), Value::String(model.into()));
933    if x != 0.0 {
934        v.insert("x".into(), num_value(x));
935    }
936    if y != 0.0 {
937        v.insert("y".into(), num_value(y));
938    }
939    if uvlock {
940        v.insert("uvlock".into(), Value::Bool(true));
941    }
942    if let Some(w) = weight
943        && w != 1.0
944    {
945        v.insert("weight".into(), num_value(w));
946    }
947    Value::Object(v)
948}
949
950fn pretty(v: &Value) -> Vec<u8> {
951    let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
952    s.push('\n');
953    s.into_bytes()
954}
955
956/// Compiles one file; pushes its outputs.
957#[allow(clippy::too_many_arguments)]
958fn compile_file(
959    cx: &mut Ctx,
960    j: &J,
961    kind: Kind,
962    path: &Path,
963    mod_ns: Option<&str>,
964    out: &mut Compiled,
965    generated: &mut BTreeMap<String, String>,
966) -> Option<Entry> {
967    if !j.is_obj() {
968        cx.err(
969            j.line,
970            format!("a .y{} file is one JSON object", kind.word()),
971        );
972        return None;
973    }
974    let common = [
975        "$schema", "format", "id", "name", "tooltip", "register", "model",
976    ];
977    let specific: &[&str] = match kind {
978        Kind::Block => &["block", "item_model", "states"],
979        Kind::Item => &["item"],
980        Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
981    };
982    let known: Vec<&str> = common.iter().chain(specific).copied().collect();
983    cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
984    if let Some(f) = j.get("format")
985        && f.num() != Some(1.0)
986    {
987        cx.err(f.line, "format must be 1 (this yog understands format 1)");
988    }
989    let (ns, p) = entry_id(cx, j, path, mod_ns)?;
990    cx.ns = ns.clone();
991    let id = format!("{ns}:{p}");
992
993    let auto = match j.get("register") {
994        None => true,
995        Some(r) => cx
996            .one_of(r, "register", &["auto", "manual"])
997            .is_none_or(|s| s == "auto"),
998    };
999
1000    // Name and tooltip → lang, under the keys the game looks up.
1001    let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
1002    for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
1003        let Some(v) = j.get(field) else { continue };
1004        match cx.text(v, field)? {
1005            Text::Key(from) if from == target => {}
1006            Text::Key(from) => out.lang_aliases.push((from, target)),
1007            Text::Literal(s) => {
1008                out.lang
1009                    .entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
1010                    .or_default()
1011                    .insert(target, s);
1012            }
1013        }
1014    }
1015
1016    let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
1017        let full = format!("assets/{ns}/{rel}");
1018        if cx.root.join(&full).is_file() {
1019            cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
1020        }
1021        if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
1022            cx.err(1, format!("{full} is also written by {other}"));
1023        }
1024        out.outputs.push((full, pretty(value)));
1025    };
1026
1027    let mut entry = Entry {
1028        kind,
1029        id: id.clone(),
1030        file: cx.file.clone(),
1031        auto,
1032        block: BlockProps::default(),
1033        item: ItemProps::default(),
1034    };
1035
1036    match kind {
1037        Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
1038        Kind::Item => {
1039            if let Some(it) = j.get("item") {
1040                cx.unknown_keys(
1041                    it,
1042                    "item",
1043                    &[
1044                        "stack_size",
1045                        "durability",
1046                        "fire_resistant",
1047                        "fuel_ticks",
1048                        "food",
1049                    ],
1050                );
1051                let props = &mut entry.item;
1052                if let Some(v) = it.get("stack_size") {
1053                    props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
1054                }
1055                if let Some(v) = it.get("durability") {
1056                    props.durability =
1057                        cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
1058                }
1059                if let Some(v) = it.get("fire_resistant") {
1060                    props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
1061                }
1062                if let Some(v) = it.get("fuel_ticks") {
1063                    props.fuel_ticks =
1064                        cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
1065                }
1066                if let Some(f) = it.get("food") {
1067                    cx.unknown_keys(
1068                        f,
1069                        "item.food",
1070                        &["nutrition", "saturation", "always_edible"],
1071                    );
1072                    let n = f
1073                        .get("nutrition")
1074                        .and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
1075                    let s = f
1076                        .get("saturation")
1077                        .and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
1078                    let a = f
1079                        .get("always_edible")
1080                        .and_then(|v| cx.boolean(v, "item.food.always_edible"));
1081                    if n.is_none() {
1082                        cx.err(f.line, "item.food needs nutrition");
1083                    }
1084                    props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
1085                }
1086                if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
1087                    cx.warn(
1088                        it.line,
1089                        "item.stack_size is ignored: an item with durability stacks to 1",
1090                    );
1091                }
1092            }
1093            let Some(mj) = j.get("model") else {
1094                cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
1095                return Some(entry);
1096            };
1097            let m = model(cx, mj, "model", true)?;
1098            emit(cx, format!("models/item/{p}.json"), &m.json);
1099            emit(
1100                cx,
1101                format!("items/{p}.json"),
1102                &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1103            );
1104        }
1105        Kind::Block => {
1106            let mut render_layer: Option<String> = None;
1107            let mut drops = Value::Null;
1108            let mut shape_auto = true;
1109            if let Some(b) = j.get("block") {
1110                cx.unknown_keys(
1111                    b,
1112                    "block",
1113                    &[
1114                        "hardness",
1115                        "resistance",
1116                        "sound",
1117                        "light",
1118                        "tool",
1119                        "tool_level",
1120                        "requires_tool",
1121                        "collision",
1122                        "slipperiness",
1123                        "shape",
1124                        "render_layer",
1125                        "drops",
1126                        "connect_groups",
1127                        "inventory",
1128                        "redstone",
1129                    ],
1130                );
1131                let props = &mut entry.block;
1132                if let Some(v) = b.get("hardness") {
1133                    props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
1134                }
1135                if let Some(v) = b.get("resistance") {
1136                    props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
1137                }
1138                if let Some(v) = b.get("sound") {
1139                    props.sound = cx.one_of(v, "block.sound", &SOUNDS);
1140                }
1141                if let Some(v) = b.get("light") {
1142                    props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
1143                }
1144                if let Some(v) = b.get("tool") {
1145                    props.tool = cx.one_of(v, "block.tool", &TOOLS);
1146                }
1147                if let Some(v) = b.get("tool_level") {
1148                    props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
1149                }
1150                if let Some(v) = b.get("requires_tool") {
1151                    props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
1152                }
1153                if let Some(v) = b.get("collision") {
1154                    props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
1155                }
1156                if let Some(v) = b.get("slipperiness") {
1157                    props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
1158                }
1159                if let Some(v) = b.get("shape") {
1160                    match &v.v {
1161                        V::Str(s) if s == "auto" => {}
1162                        V::Str(s) if s == "full" => shape_auto = false,
1163                        V::Arr(_) => {
1164                            shape_auto = false;
1165                            props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
1166                        }
1167                        _ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
1168                    }
1169                }
1170                if let Some(v) = b.get("render_layer") {
1171                    render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
1172                }
1173                if let Some(v) = b.get("redstone") {
1174                    props.redstone = cx.boolean(v, "block.redstone").unwrap_or(false);
1175                }
1176                if let Some(v) = b.get("inventory")
1177                    && let Some(id) = cx.string(v, "block.inventory")
1178                {
1179                    if valid_id(id) && id.contains(':') {
1180                        props.inventory = Some(id.to_string());
1181                        let at = (cx.file.clone(), v.line, id.to_string());
1182                        cx.inventories.push(at);
1183                    } else {
1184                        cx.err(v.line, format!("block.inventory `{id}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
1185                    }
1186                }
1187                if let Some(v) = b.get("connect_groups") {
1188                    for g in v.items() {
1189                        if let Some(s) = cx.string(g, "block.connect_groups[]") {
1190                            props.connect_groups.push(s.to_string());
1191                        }
1192                    }
1193                    if !v.is_arr() {
1194                        cx.err(
1195                            v.line,
1196                            "block.connect_groups must be an array of group names",
1197                        );
1198                    }
1199                }
1200                if props.tool_level.is_some() && props.tool.is_none() {
1201                    cx.warn(
1202                        b.line,
1203                        "block.tool_level without block.tool — any tool of that level mines it",
1204                    );
1205                }
1206                if let Some(d) = b.get("drops") {
1207                    drops = drops_table(cx, d, &id);
1208                }
1209            }
1210            if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
1211                drops = drops_table_self(&id);
1212            }
1213
1214            let Some(mj) = j.get("model") else {
1215                cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
1216                return Some(entry);
1217            };
1218            let mut m = model(cx, mj, "model", false)?;
1219            if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
1220                // Forge/NeoForge read it from the model; Fabric ignores it.
1221                o.insert(
1222                    "render_type".into(),
1223                    Value::String(format!("minecraft:{layer}")),
1224                );
1225            }
1226            if shape_auto {
1227                entry.block.shape = m
1228                    .bounds
1229                    .filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
1230                    .map(|b| {
1231                        [
1232                            b[0].max(0.0),
1233                            b[1].max(0.0),
1234                            b[2].max(0.0),
1235                            b[3].min(16.0),
1236                            b[4].min(16.0),
1237                            b[5].min(16.0),
1238                        ]
1239                    });
1240            }
1241            let main = format!("{ns}:block/{p}");
1242            emit(cx, format!("models/block/{p}.json"), &m.json);
1243
1244            // Block states.
1245            let mut rotations: Vec<Value> = Vec::new();
1246            let mut arm: Option<(String, bool)> = None;
1247            if let Some(s) = j.get("states") {
1248                cx.unknown_keys(s, "states", &["rotations", "connect"]);
1249                if let Some(r) = s.get("rotations") {
1250                    if !r.is_arr() || r.items().is_empty() {
1251                        cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
1252                    }
1253                    for (i, rot) in r.items().iter().enumerate() {
1254                        let w = format!("states.rotations[{i}]");
1255                        cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
1256                        let mut angle = |k: &str| -> f64 {
1257                            match rot.get(k).map(|v| (v, v.num())) {
1258                                None => 0.0,
1259                                Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
1260                                Some((v, _)) => {
1261                                    cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
1262                                    0.0
1263                                }
1264                            }
1265                        };
1266                        let (x, y) = (angle("x"), angle("y"));
1267                        let weight = rot
1268                            .get("weight")
1269                            .and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
1270                        let uvlock = rot
1271                            .get("uvlock")
1272                            .and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
1273                            .unwrap_or(false);
1274                        rotations.push(variant(&main, x, y, uvlock, weight));
1275                    }
1276                }
1277                if let Some(c) = s.get("connect") {
1278                    cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
1279                    let uvlock = c
1280                        .get("uvlock")
1281                        .and_then(|v| cx.boolean(v, "states.connect.uvlock"))
1282                        .unwrap_or(false);
1283                    match c.get("arm") {
1284                        None => cx.err(
1285                            c.line,
1286                            "states.connect needs `arm` — the model of the arm towards north",
1287                        ),
1288                        Some(a) => {
1289                            if let Some(am) = model(cx, a, "states.connect.arm", false) {
1290                                let mut json = am.json;
1291                                if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
1292                                {
1293                                    o.insert(
1294                                        "render_type".into(),
1295                                        Value::String(format!("minecraft:{layer}")),
1296                                    );
1297                                }
1298                                emit(cx, format!("models/block/{p}_arm.json"), &json);
1299                                arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
1300                            }
1301                        }
1302                    }
1303                    entry.block.connects = true;
1304                    if entry.block.connect_groups.is_empty() {
1305                        entry.block.connect_groups.push(id.clone());
1306                    }
1307                }
1308            }
1309            let base: Value = match rotations.len() {
1310                0 => variant(&main, 0.0, 0.0, false, None),
1311                1 => rotations.remove(0),
1312                _ => Value::Array(rotations),
1313            };
1314            let blockstate = match &arm {
1315                None => json!({ "variants": { "": base } }),
1316                Some((arm, uvlock)) => {
1317                    let mut parts = vec![json!({ "apply": base })];
1318                    for (dir, x, y) in [
1319                        ("north", 0.0, 0.0),
1320                        ("east", 0.0, 90.0),
1321                        ("south", 0.0, 180.0),
1322                        ("west", 0.0, 270.0),
1323                        ("up", 270.0, 0.0),
1324                        ("down", 90.0, 0.0),
1325                    ] {
1326                        parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
1327                    }
1328                    json!({ "multipart": parts })
1329                }
1330            };
1331            emit(cx, format!("blockstates/{p}.json"), &blockstate);
1332
1333            // The item form: the block model, or its own.
1334            let item_model = match j.get("item_model") {
1335                None => Some(json!({ "parent": main })),
1336                Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
1337            };
1338            if let Some(im) = item_model {
1339                emit(cx, format!("models/item/{p}.json"), &im);
1340            }
1341            emit(
1342                cx,
1343                format!("items/{p}.json"),
1344                &json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
1345            );
1346
1347            if !drops.is_null() {
1348                let full = format!("data/{ns}/loot_table/blocks/{p}.json");
1349                let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
1350                if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
1351                    cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
1352                }
1353                out.outputs.push((full, pretty(&drops)));
1354            }
1355            if let Some(tool) = &entry.block.tool {
1356                out.tags
1357                    .entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
1358                    .or_default()
1359                    .insert(id.clone());
1360            }
1361            if let Some(level) = &entry.block.tool_level {
1362                out.tags
1363                    .entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
1364                    .or_default()
1365                    .insert(id.clone());
1366            }
1367        }
1368    }
1369    Some(entry)
1370}
1371
1372fn drops_table_self(id: &str) -> Value {
1373    json!({
1374        "type": "minecraft:block",
1375        "pools": [{
1376            "rolls": 1,
1377            "entries": [{ "type": "minecraft:item", "name": id }],
1378            "conditions": [{ "condition": "minecraft:survives_explosion" }]
1379        }]
1380    })
1381}
1382
1383/// `"self"`, `"none"` or `{ "item": "ns:x", "count": 2 | [1, 3] }`.
1384fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
1385    match &d.v {
1386        V::Str(s) if s == "self" => drops_table_self(id),
1387        V::Str(s) if s == "none" => Value::Null,
1388        V::Obj(_) => {
1389            cx.unknown_keys(d, "block.drops", &["item", "count"]);
1390            let item = match d.get("item") {
1391                Some(i) => match cx.string(i, "block.drops.item") {
1392                    Some(s) if valid_id(s) => {
1393                        if s.contains(':') {
1394                            s.to_string()
1395                        } else {
1396                            format!("minecraft:{s}")
1397                        }
1398                    }
1399                    Some(s) => {
1400                        cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
1401                        return Value::Null;
1402                    }
1403                    None => return Value::Null,
1404                },
1405                None => id.to_string(),
1406            };
1407            let mut entry = json!({ "type": "minecraft:item", "name": item });
1408            if let Some(c) = d.get("count") {
1409                let count = match &c.v {
1410                    V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
1411                    V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
1412                        json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
1413                    }),
1414                    _ => {
1415                        cx.err(c.line, "block.drops.count must be a number or [min, max]");
1416                        None
1417                    }
1418                };
1419                if let Some(count) = count {
1420                    entry["functions"] =
1421                        json!([{ "function": "minecraft:set_count", "count": count }]);
1422                }
1423            }
1424            json!({
1425                "type": "minecraft:block",
1426                "pools": [{
1427                    "rolls": 1,
1428                    "entries": [entry],
1429                    "conditions": [{ "condition": "minecraft:survives_explosion" }]
1430                }]
1431            })
1432        }
1433        _ => {
1434            cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
1435            Value::Null
1436        }
1437    }
1438}
1439
1440// ── Recipes ──────────────────────────────────────────────────────────────────
1441
1442/// What a recipe takes.
1443#[derive(Debug, Clone)]
1444enum RecipeKind {
1445    /// A crafting grid pattern: rows of key characters (space = empty);
1446    /// each key any of several items.
1447    Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
1448    /// Crafting ingredients in any arrangement.
1449    Shapeless(Vec<Vec<String>>),
1450    /// Cooking (`method`: `Smelting`, `Blasting`, `Smoking`, `Campfire`).
1451    Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
1452    Stonecutting(Vec<String>),
1453    Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
1454    /// The mod's own kind of recipe (`mymod:infusion`), for its machines
1455    /// and books — not the game's: named slots, each a list of ingredients
1456    /// (an ingredient any of several items), and values (`time`, `energy`…).
1457    Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
1458}
1459
1460/// One checked `.ybook`.
1461struct BookFile {
1462    id: String,
1463    /// The book as the runtime reads it, references resolved.
1464    json: String,
1465}
1466
1467/// Checks one `.ybook` (`yog_book::Book`'s schema), whose references
1468/// (`@item:minecraft:stick`, see `yog_book::refs`) must name what exists
1469/// — in this mod, on every Minecraft version, or among the other mods'
1470/// ids `yog.toml` declares — and come out as plain ids. Texts may be
1471/// `@<Set>.<name>` (a `.yres` text), `@item:<id>` / `@block:<id>` /
1472/// `@entity:<id>` (its name) or `@@…` (a literal `@`); a book with text
1473/// references is shown in the player's language. An `id` without a
1474/// namespace gets the mod's.
1475fn compile_book(cx: &mut Ctx, text: &str, mod_ns: Option<&str>, known: &refs::Known) -> Option<BookFile> {
1476    use refs::RefKind;
1477    use yog_data::schema::{Resolve, Text};
1478    use yog_data::{Path, Ref, Res};
1479
1480    struct Check<'a> {
1481        known: &'a refs::Known,
1482        texts: &'a HashMap<String, (String, String)>,
1483        entries: HashSet<String>,
1484        categories: HashSet<String>,
1485        text_refs: bool,
1486    }
1487
1488    impl Resolve for Check<'_> {
1489        fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
1490            let own = match r.kind.as_str() {
1491                "entry" => Some(&self.entries),
1492                "category" => Some(&self.categories),
1493                _ => None,
1494            };
1495            match (own, RefKind::from_word(&r.kind)) {
1496                (Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
1497                (Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
1498                (None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
1499                (None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
1500            }
1501        }
1502
1503        fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
1504            match t {
1505                Text::Literal(s) => Ok(s.clone()),
1506                // A name: the game's translation of the item, block or entity.
1507                Text::NameOf(r) => {
1508                    if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
1509                        return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
1510                    }
1511                    self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
1512                    let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
1513                    self.text_refs = true;
1514                    Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
1515                }
1516                // Any translation key, the game's or a mod's.
1517                Text::Lang(key) => {
1518                    self.text_refs = true;
1519                    Ok(key.clone())
1520                }
1521                Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
1522                    Some((key, _)) => {
1523                        self.text_refs = true;
1524                        Ok(key.clone())
1525                    }
1526                    None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
1527                },
1528            }
1529        }
1530
1531        fn words(&mut self, r: &Res) -> Option<String> {
1532            self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
1533        }
1534    }
1535
1536    // Entries and categories may name each other in any order: their ids first.
1537    let doc = match yog_data::text::parse(text) {
1538        Ok(d) => d,
1539        Err(e) => {
1540            cx.err(e.at.line as usize, e.message);
1541            return None;
1542        }
1543    };
1544    let ids = |key: &str| -> HashSet<String> {
1545        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()
1546    };
1547    let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
1548    let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
1549        Ok(read) => read.value,
1550        Err(errors) => {
1551            for e in errors.0 {
1552                let line = e.at.map_or(1, |a| a.line as usize);
1553                if e.path.is_root() {
1554                    cx.err(line, e.message);
1555                } else {
1556                    cx.err(line, format!("{}: {}", e.path, e.message));
1557                }
1558            }
1559            return None;
1560        }
1561    };
1562    if !book.id.contains(':') {
1563        book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
1564    }
1565    // References to entries and categories always have a namespace, so their ids must too.
1566    let ns = mod_ns.unwrap_or("minecraft");
1567    let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
1568        .chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
1569        .filter(|(_, id)| !id.contains(':'))
1570        .map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
1571        .collect();
1572    if !unnamespaced.is_empty() {
1573        for e in unnamespaced {
1574            cx.err(1, e);
1575        }
1576        return None;
1577    }
1578    if !valid_id(&book.id) {
1579        cx.err(1, format!("book id `{}`: not a valid id", book.id));
1580        return None;
1581    }
1582    book.i18n |= check.text_refs;
1583    Some(BookFile { id: book.id.clone(), json: book.to_json() })
1584}
1585
1586/// Keys a source file may carry for its authors and tools — `$…`, `_…`,
1587/// `comment`, `meta` — which nothing ships.
1588fn is_metadata(key: &str) -> bool {
1589    key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
1590}
1591
1592/// One checked `.yrecipe`.
1593#[derive(Debug, Clone)]
1594struct Recipe {
1595    id: String,
1596    file: String,
1597    kind: RecipeKind,
1598    result: String,
1599    count: u32,
1600    group: Option<String>,
1601    category: Option<String>,
1602    /// Item ids it names (not tags), by line — the mod's own are checked
1603    /// against its blocks and items.
1604    items: Vec<(usize, String)>,
1605}
1606
1607impl Recipe {
1608    /// The `registry.add_recipe(...)` call registering it.
1609    fn registration(&self) -> String {
1610        let list = |items: &[String]| {
1611            let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1612            format!("vec![{}]", quoted.join(", "))
1613        };
1614        let kind = match &self.kind {
1615            RecipeKind::Shaped { pattern, key } => {
1616                let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1617                let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1618                format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1619            }
1620            RecipeKind::Shapeless(ingredients) => {
1621                let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1622                format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1623            }
1624            RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1625                "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1626                list(input),
1627                float(*experience)
1628            ),
1629            RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1630            RecipeKind::Custom { kind, slots, values } => {
1631                // Yog's recipe JSON as is: the host keeps it out of the game's data.
1632                let mut o = Map::new();
1633                o.insert("type".into(), kind.clone().into());
1634                o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
1635                if !values.is_empty() {
1636                    o.insert("values".into(), Value::Object(values.clone()));
1637                }
1638                o.insert("result".into(), self.result.clone().into());
1639                if self.count != 1 {
1640                    o.insert("count".into(), self.count.into());
1641                }
1642                if let Some(g) = &self.group {
1643                    o.insert("group".into(), g.clone().into());
1644                }
1645                return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
1646            }
1647            RecipeKind::Smithing { template, base, addition } => format!(
1648                "Smithing {{ template: {}, base: {}, addition: {} }}",
1649                list(template),
1650                list(base),
1651                list(addition)
1652            ),
1653        };
1654        let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1655        format!(
1656            "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1657            self.id,
1658            self.result,
1659            self.count,
1660            opt(&self.group),
1661            opt(&self.category)
1662        )
1663    }
1664}
1665
1666/// An item id (`minecraft:paper`), or — where `tags` — an item tag
1667/// (`#c:dusts`); the namespace is always written out.
1668fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1669    let s = cx.string(j, what)?.trim().to_string();
1670    let (tag, id) = match s.strip_prefix('#') {
1671        Some(rest) => (true, rest),
1672        None => (false, s.as_str()),
1673    };
1674    if tag && !tags {
1675        cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1676        return None;
1677    }
1678    let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1679    if !ok {
1680        let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1681        cx.err(
1682            j.line,
1683            format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1684        );
1685        return None;
1686    }
1687    if !tag {
1688        items.push((j.line, id.to_string()));
1689    }
1690    Some(s)
1691}
1692
1693/// A recipe of the mod's own kind `kind`: `slots` (name → a list of
1694/// ingredients), `values`, `result`, `count`, `group`.
1695fn compile_custom_recipe(cx: &mut Ctx, j: &J, kind: String, id: String) -> Option<Recipe> {
1696    for key in ["pattern", "key", "ingredients", "ingredient", "experience", "cook_time", "template", "base", "addition", "category"] {
1697        if let Some(v) = j.get(key) {
1698            cx.err(v.line, format!("{key} is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
1699        }
1700    }
1701    let mut items = Vec::new();
1702    let mut slots = Vec::new();
1703    match j.get("slots") {
1704        Some(sv) if sv.is_obj() => {
1705            for (name, list) in sv.members() {
1706                if !list.is_arr() || list.items().is_empty() {
1707                    cx.err(list.line, format!("slots.{name}: a list of ingredients — [\"minecraft:diamond\"], [[\"minecraft:coal\", \"minecraft:charcoal\"]]"));
1708                    continue;
1709                }
1710                let ings: Vec<Vec<String>> = list
1711                    .items()
1712                    .iter()
1713                    .filter_map(|i| recipe_ingredient(cx, i, &format!("slots.{name}[]"), &mut items))
1714                    .collect();
1715                slots.push((name.clone(), ings));
1716            }
1717        }
1718        Some(sv) => cx.err(sv.line, "slots must be an object: { \"core\": [\"minecraft:diamond\"] }"),
1719        None => cx.err(j.line, format!("a {kind} recipe needs \"slots\": what goes in, by slot name")),
1720    }
1721    let values = match j.get("values") {
1722        Some(v) if v.is_obj() => v.members().iter().map(|(k, x)| (k.clone(), json_value(x))).collect(),
1723        Some(v) => {
1724            cx.err(v.line, "values must be an object: { \"time\": 200 }");
1725            Map::new()
1726        }
1727        None => Map::new(),
1728    };
1729    let result = match j.get("result") {
1730        Some(v) => recipe_item(cx, v, "result", false, &mut items),
1731        None => {
1732            cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1733            None
1734        }
1735    };
1736    let count = match j.get("count") {
1737        Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1738        None => Some(1),
1739    };
1740    let group = j.get("group").and_then(|v| cx.string(v, "group").map(str::to_owned));
1741    Some(Recipe {
1742        id,
1743        file: cx.file.clone(),
1744        kind: RecipeKind::Custom { kind, slots, values },
1745        result: result?,
1746        count: count?,
1747        group,
1748        category: None,
1749        items,
1750    })
1751}
1752
1753/// `j` as plain JSON.
1754fn json_value(j: &J) -> Value {
1755    match &j.v {
1756        V::Null => Value::Null,
1757        V::Bool(b) => Value::Bool(*b),
1758        V::Num(n) => num_value(*n),
1759        V::Str(s) => Value::String(s.clone()),
1760        V::Arr(items) => Value::Array(items.iter().map(json_value).collect()),
1761        V::Obj(members) => Value::Object(members.iter().map(|(k, v)| (k.clone(), json_value(v))).collect()),
1762    }
1763}
1764
1765/// Checks one `.yrecipe`.
1766fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1767    if !j.is_obj() {
1768        cx.err(j.line, "a .yrecipe file is one JSON object");
1769        return None;
1770    }
1771    cx.unknown_keys(
1772        j,
1773        "a .yrecipe",
1774        &[
1775            "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1776            "result", "count", "experience", "cook_time", "template", "base", "addition", "group",
1777            "category", "slots", "values",
1778        ],
1779    );
1780    if let Some(f) = j.get("format")
1781        && f.num() != Some(1.0)
1782    {
1783        cx.err(f.line, "format must be 1 (this yog understands format 1)");
1784    }
1785    let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1786    let Some(t) = j.get("type") else {
1787        cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1788        return None;
1789    };
1790    // The mod's own kind of recipe: `<mod id>:<kind>`.
1791    if let Some(own) = t.str().filter(|ty| mod_ns.is_some_and(|m| ty.split_once(':').is_some_and(|(n, k)| n == m && valid_path(k)))) {
1792        return compile_custom_recipe(cx, j, own.to_string(), format!("{ns}:{p}"));
1793    }
1794    let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1795    let cooking = matches!(ty.as_str(), "smelting" | "blasting" | "smoking" | "campfire_cooking");
1796    // Keys of the other types.
1797    for (key, owners) in [
1798        ("pattern", &["shaped"][..]),
1799        ("key", &["shaped"]),
1800        ("ingredients", &["shapeless"]),
1801        ("ingredient", &["smelting", "blasting", "smoking", "campfire_cooking", "stonecutting"]),
1802        ("experience", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1803        ("cook_time", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1804        ("template", &["smithing"]),
1805        ("base", &["smithing"]),
1806        ("addition", &["smithing"]),
1807    ] {
1808        if !owners.contains(&ty.as_str())
1809            && let Some(v) = j.get(key)
1810        {
1811            cx.err(v.line, format!("{key} is for {} recipes, this one is {ty}", owners.join("/")));
1812        }
1813    }
1814    let mut items = Vec::new();
1815    let result = match j.get("result") {
1816        Some(v) => recipe_item(cx, v, "result", false, &mut items),
1817        None => {
1818            cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1819            None
1820        }
1821    };
1822    let count = match j.get("count") {
1823        Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1824        None => Some(1),
1825    };
1826    let text = |cx: &mut Ctx, key: &str| j.get(key).and_then(|v| cx.string(v, key).map(str::to_owned));
1827    let group = text(cx, "group");
1828    let category = text(cx, "category");
1829    let kind = match ty.as_str() {
1830        "shaped" => {
1831            let Some(pv) = j.get("pattern") else {
1832                cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1833                return None;
1834            };
1835            let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1836            let width = rows.first().map_or(0, |r| r.chars().count());
1837            if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1838                cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1839                return None;
1840            }
1841            let mut key = Vec::new();
1842            match j.get("key") {
1843                Some(kv) if kv.is_obj() => {
1844                    for (k, v) in kv.members() {
1845                        let mut chars = k.chars();
1846                        match (chars.next(), chars.next()) {
1847                            (Some(c), None) if c != ' ' => {
1848                                if let Some(alts) = recipe_ingredient(cx, v, &format!("key {k:?}"), &mut items) {
1849                                    if !rows.iter().any(|r| r.contains(c)) {
1850                                        cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1851                                    }
1852                                    key.push((c, alts));
1853                                }
1854                            }
1855                            _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1856                        }
1857                    }
1858                }
1859                Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1860                None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1861            }
1862            for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1863                if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1864                    cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1865                    return None;
1866                }
1867            }
1868            RecipeKind::Shaped { pattern: rows, key }
1869        }
1870        "shapeless" => {
1871            let Some(iv) = j.get("ingredients") else {
1872                cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1873                return None;
1874            };
1875            let list: Vec<Vec<String>> = iv
1876                .items()
1877                .iter()
1878                .filter_map(|i| recipe_ingredient(cx, i, "ingredients[]", &mut items))
1879                .collect();
1880            if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1881                cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1882                return None;
1883            }
1884            RecipeKind::Shapeless(list)
1885        }
1886        "smithing" => {
1887            let mut part = |cx: &mut Ctx, key: &str| match j.get(key) {
1888                Some(v) => recipe_ingredient(cx, v, key, &mut items),
1889                None => {
1890                    cx.err(j.line, format!("a smithing recipe needs a \"{key}\""));
1891                    None
1892                }
1893            };
1894            let template = part(cx, "template");
1895            let base = part(cx, "base");
1896            let addition = part(cx, "addition");
1897            RecipeKind::Smithing { template: template?, base: base?, addition: addition? }
1898        }
1899        _ => {
1900            let Some(iv) = j.get("ingredient") else {
1901                cx.err(j.line, format!("a {ty} recipe needs an \"ingredient\": the item or tag it takes"));
1902                return None;
1903            };
1904            let input = recipe_ingredient(cx, iv, "ingredient", &mut items)?;
1905            if !cooking {
1906                RecipeKind::Stonecutting(input)
1907            } else {
1908                let (method, default_time) = match ty.as_str() {
1909                    "smelting" => ("Smelting", 200.0),
1910                    "blasting" => ("Blasting", 100.0),
1911                    "smoking" => ("Smoking", 100.0),
1912                    _ => ("Campfire", 600.0),
1913                };
1914                let experience = match j.get("experience") {
1915                    Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1916                    None => 0.1,
1917                };
1918                let cook_time = match j.get("cook_time") {
1919                    Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)?,
1920                    None => default_time,
1921                } as u32;
1922                RecipeKind::Cooking { method, input, experience, cook_time }
1923            }
1924        }
1925    };
1926    Some(Recipe {
1927        id: format!("{ns}:{p}"),
1928        file: cx.file.clone(),
1929        kind,
1930        result: result?,
1931        count: count?,
1932        group,
1933        category,
1934        items,
1935    })
1936}
1937
1938/// An ingredient: an item, a tag, or an array of either (any of them).
1939fn recipe_ingredient(cx: &mut Ctx, j: &J, what: &str, items: &mut Vec<(usize, String)>) -> Option<Vec<String>> {
1940    if j.is_arr() {
1941        let alts: Vec<String> = j.items().iter().filter_map(|i| recipe_item(cx, i, what, true, items)).collect();
1942        if alts.is_empty() {
1943            cx.err(j.line, format!("{what}: an array of items or tags, at least one"));
1944            return None;
1945        }
1946        return Some(alts);
1947    }
1948    recipe_item(cx, j, what, true, items).map(|i| vec![i])
1949}
1950
1951/// Ids of the `.yui` screens under `dir` that lay out an inventory (have
1952/// `<Slot>`s or a `<PlayerInventory>`).
1953fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1954    let mut out = BTreeSet::new();
1955    let Ok(entries) = std::fs::read_dir(dir) else {
1956        return out;
1957    };
1958    for e in entries.flatten() {
1959        let path = e.path();
1960        if path.is_dir() {
1961            out.extend(inventory_screens(&path));
1962            continue;
1963        }
1964        if path.extension().is_none_or(|x| x != "yui") {
1965            continue;
1966        }
1967        let Ok(text) = std::fs::read_to_string(&path) else { continue };
1968        if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
1969            continue;
1970        }
1971        if let Some(screen) = text.find("<Screen")
1972            && let Some(at) = text[screen..].find(" id=\"")
1973        {
1974            let rest = &text[screen + at + 5..];
1975            if let Some(end) = rest.find('"') {
1976                out.insert(rest[..end].to_string());
1977            }
1978        }
1979    }
1980    out
1981}
1982
1983/// Compiles the mod's content files (see the module docs). Any error fails
1984/// the whole thing, listing every problem.
1985pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
1986    let mut out = Compiled::default();
1987    let files = find(root);
1988    if files.is_empty() {
1989        return Ok(out);
1990    }
1991    let mod_ns = valid_namespace(mod_id).then_some(mod_id);
1992    let mut errors: Vec<String> = Vec::new();
1993    let mut warnings: Vec<String> = Vec::new();
1994    let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
1995    let mut generated: BTreeMap<String, String> = BTreeMap::new();
1996    let mut entries: Vec<Entry> = Vec::new();
1997    let mut ids: HashMap<String, String> = HashMap::new();
1998    let mut recipes: Vec<Recipe> = Vec::new();
1999    let mut recipe_ids: HashMap<String, String> = HashMap::new();
2000    let mut books: Vec<BookFile> = Vec::new();
2001    let mut book_ids: HashMap<String, String> = HashMap::new();
2002    let mut inventories: Vec<(String, usize, String)> = Vec::new();
2003    let mut pending_books: Vec<(String, String)> = Vec::new();
2004    for path in &files {
2005        let shown = display(root, path);
2006        let Some(kind) = Kind::of(path) else { continue };
2007        let text = match std::fs::read_to_string(path) {
2008            Ok(t) => t,
2009            Err(e) => {
2010                errors.push(format!("{shown}: {e}"));
2011                continue;
2012            }
2013        };
2014        if kind == Kind::Book {
2015            // Checked once every block, item and recipe is known.
2016            pending_books.push((shown.clone(), text));
2017            continue;
2018        }
2019        let j = match json::parse(&text) {
2020            Ok(j) => j,
2021            Err(e) => {
2022                errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
2023                continue;
2024            }
2025        };
2026        let mut cx = Ctx {
2027            root,
2028            refs,
2029            file: shown.clone(),
2030            ns: mod_ns.unwrap_or("minecraft").to_string(),
2031            errors: &mut errors,
2032            warnings: &mut warnings,
2033            textures: &mut textures,
2034            inventories: &mut inventories,
2035        };
2036        if kind == Kind::Recipe {
2037            if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
2038                if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2039                    errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2040                }
2041                recipes.push(r);
2042            }
2043            continue;
2044        }
2045        if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
2046        {
2047            if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2048                errors.push(format!(
2049                    "{shown}: id {} is also defined by {other}",
2050                    entry.id
2051                ));
2052            }
2053            entries.push(entry);
2054        }
2055    }
2056    // Books name the mod's content, vanilla's and other mods' declared ids.
2057    let externals = crate::manifest::YogToml::read(&root.join("yog.toml")).map(|t| t.externals).unwrap_or_default();
2058    let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals);
2059    for e in &entries {
2060        known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
2061    }
2062    for r in &recipes {
2063        known.add(refs::RefKind::Recipe, r.id.clone());
2064    }
2065    for (shown, text) in &pending_books {
2066        let mut cx = Ctx {
2067            root,
2068            refs,
2069            file: shown.clone(),
2070            ns: mod_ns.unwrap_or("minecraft").to_string(),
2071            errors: &mut errors,
2072            warnings: &mut warnings,
2073            textures: &mut textures,
2074            inventories: &mut inventories,
2075        };
2076        if let Some(b) = compile_book(&mut cx, text, mod_ns, &known) {
2077            if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2078                errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2079            }
2080            books.push(b);
2081        }
2082    }
2083    // What recipes name from this mod must be its blocks and items (vanilla
2084    // and other mods' ids can't be checked here).
2085    if let Some(ns) = mod_ns {
2086        for r in &recipes {
2087            for (line, item) in &r.items {
2088                if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
2089                    errors.push(format!(
2090                        "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
2091                        r.file
2092                    ));
2093                }
2094            }
2095        }
2096        let screens = inventory_screens(&root.join("src"));
2097        for (file, line, id) in &inventories {
2098            if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2099                errors.push(format!(
2100                    "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2101                ));
2102            }
2103        }
2104    }
2105    for (target, source) in &textures {
2106        match std::fs::read(source) {
2107            Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2108            Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2109        }
2110    }
2111    out.warnings = warnings;
2112    if !errors.is_empty() {
2113        return Err(format!(
2114            "invalid blocks/items in content/:\n  {}",
2115            errors.join("\n  ")
2116        ));
2117    }
2118    out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2119    out.items = entries.len() - out.blocks;
2120    out.recipes = recipes.len();
2121    out.books = books.len();
2122    if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
2123        && !mentions_content_macro(&root.join("src"))
2124    {
2125        out.warnings.push(format!(
2126            "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2127            CONTENT_DIR
2128        ));
2129    }
2130    out.source = Some(rust_source(&entries, &recipes, &books));
2131    Ok(out)
2132}
2133
2134/// Whether any `src/**/*.rs` has `content!(` — the include of the generated code.
2135fn mentions_content_macro(dir: &Path) -> bool {
2136    let Ok(entries) = std::fs::read_dir(dir) else {
2137        return false;
2138    };
2139    entries.flatten().any(|e| {
2140        let p = e.path();
2141        if p.is_dir() {
2142            mentions_content_macro(&p)
2143        } else {
2144            p.extension().is_some_and(|x| x == "rs")
2145                && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
2146        }
2147    })
2148}
2149
2150/// `ruby_ore`, `ores/ruby` → `ores_ruby`; a Rust keyword gets `r#`.
2151fn rust_ident(path: &str) -> String {
2152    let mut s: String = path
2153        .chars()
2154        .map(|c| {
2155            if c.is_ascii_alphanumeric() {
2156                c.to_ascii_lowercase()
2157            } else {
2158                '_'
2159            }
2160        })
2161        .collect();
2162    if s.starts_with(|c: char| c.is_ascii_digit()) {
2163        s.insert(0, '_');
2164    }
2165    s
2166}
2167
2168fn float(n: f64) -> String {
2169    let s = format!("{n:?}");
2170    if s.contains('.') || s.contains('e') {
2171        s
2172    } else {
2173        format!("{s}.0")
2174    }
2175}
2176
2177fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
2178    let mut code = String::from(
2179        "// <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",
2180    );
2181    for (kind, set, def) in [
2182        (Kind::Block, "Blocks", "BlockDef"),
2183        (Kind::Item, "Items", "ItemDef"),
2184    ] {
2185        let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2186        let _ = writeln!(
2187            code,
2188            "\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} {{",
2189            if kind == Kind::Block {
2190                "blocks"
2191            } else {
2192                "items"
2193            }
2194        );
2195        let mut used: HashMap<String, usize> = HashMap::new();
2196        for e in mine {
2197            let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2198            let mut ident = rust_ident(path);
2199            let n = used.entry(ident.clone()).or_insert(0);
2200            *n += 1;
2201            if *n > 1 {
2202                let _ = write!(ident, "_{n}");
2203            }
2204            let upper = ident.to_ascii_uppercase();
2205            let ident = if is_keyword(&ident) {
2206                format!("r#{ident}")
2207            } else {
2208                ident
2209            };
2210            let _ = writeln!(
2211                code,
2212                "    /// `{}` — `{}`.\n    pub const {upper}: &'static str = {:?};",
2213                e.id, e.file, e.id
2214            );
2215            let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2216            match kind {
2217                Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2218                Kind::Block => {
2219                    let p = &e.block;
2220                    if p.hardness.is_some() || p.resistance.is_some() {
2221                        let _ = write!(
2222                            b,
2223                            "\n            .strength({}, {})",
2224                            float(p.hardness.unwrap_or(1.5)),
2225                            float(p.resistance.unwrap_or(6.0))
2226                        );
2227                    }
2228                    if let Some(s) = &p.sound {
2229                        let _ = write!(b, "\n            .sound({s:?})");
2230                    }
2231                    if p.light > 0 {
2232                        let _ = write!(b, "\n            .light_level({})", p.light);
2233                    }
2234                    if p.requires_tool {
2235                        b.push_str("\n            .requires_tool()");
2236                    }
2237                    if p.no_collision {
2238                        b.push_str("\n            .no_collision()");
2239                    }
2240                    if let Some(s) = p.slipperiness {
2241                        let _ = write!(b, "\n            .slipperiness({})", float(s));
2242                    }
2243                    if let Some(s) = p.shape {
2244                        let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2245                        let _ = write!(b, "\n            .shape({})", args.join(", "));
2246                    }
2247                    if p.connects {
2248                        b.push_str("\n            .connects_to_neighbors()");
2249                    }
2250                    if !p.connect_groups.is_empty() {
2251                        let groups: Vec<String> =
2252                            p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2253                        let _ =
2254                            write!(b, "\n            .connect_groups(&[{}])", groups.join(", "));
2255                    }
2256                    if let Some(inv) = &p.inventory {
2257                        let _ = write!(b, "\n            .inventory({inv:?})");
2258                    }
2259                    if p.redstone {
2260                        b.push_str("\n            .redstone_source()");
2261                    }
2262                }
2263                Kind::Item => {
2264                    let p = &e.item;
2265                    if let Some(s) = p.stack_size {
2266                        let _ = write!(b, "\n            .max_stack({s})");
2267                    }
2268                    if p.durability > 0 {
2269                        let _ = write!(b, "\n            .max_damage({})", p.durability);
2270                    }
2271                    if p.fire_resistant {
2272                        b.push_str("\n            .fire_resistant()");
2273                    }
2274                    if p.fuel_ticks > 0 {
2275                        let _ = write!(b, "\n            .fuel({})", p.fuel_ticks);
2276                    }
2277                    if let Some((n, s, always)) = p.food {
2278                        let _ = write!(
2279                            b,
2280                            "\n            .food(::yog_api::FoodDef::new({n}, {}){})",
2281                            float(s),
2282                            if always { ".can_always_eat()" } else { "" }
2283                        );
2284                    }
2285                }
2286            }
2287            let _ = writeln!(
2288                code,
2289                "    /// The definition from `{}` (name and tooltip come from the lang files).\n    pub fn {ident}() -> ::yog_api::{def} {{\n        {b}\n    }}",
2290                e.file
2291            );
2292        }
2293        code.push_str("}\n");
2294    }
2295
2296    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");
2297    let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2298    for e in entries {
2299        let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2300        let mut ident = rust_ident(path);
2301        let n = used.entry((e.kind, ident.clone())).or_insert(0);
2302        *n += 1;
2303        if *n > 1 {
2304            let _ = write!(ident, "_{n}");
2305        }
2306        let ident = if is_keyword(&ident) {
2307            format!("r#{ident}")
2308        } else {
2309            ident
2310        };
2311        if !e.auto {
2312            continue;
2313        }
2314        let _ = match e.kind {
2315            Kind::Block => writeln!(code, "    registry.register_block(Blocks::{ident}());"),
2316            Kind::Item => writeln!(code, "    registry.register_item(Items::{ident}());"),
2317            Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2318        };
2319    }
2320    for r in recipes {
2321        let _ = writeln!(code, "    {}", r.registration());
2322    }
2323    for b in books {
2324        let _ = writeln!(code, "    registry.register_book_json({:?}, {:?});", b.id, b.json);
2325    }
2326    code.push_str("}\n");
2327    if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
2328        code.push_str(
2329            "\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",
2330        );
2331    }
2332    code
2333}
2334
2335fn is_keyword(s: &str) -> bool {
2336    const KW: &[&str] = &[
2337        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2338        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2339        "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2340        "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2341        "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2342    ];
2343    KW.contains(&s)
2344}
2345
2346/// Writes `compiled.source` to [`GENERATED_FILE`] (only when it changed);
2347/// deletes it when the mod has no content files.
2348pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2349    let out = root.join(GENERATED_FILE);
2350    let Some(code) = &compiled.source else {
2351        let _ = std::fs::remove_file(&out);
2352        return Ok(());
2353    };
2354    crate::write_if_changed(&out, code)?;
2355    Ok(())
2356}
2357
2358/// Outputs in order, keyed by path: [`OutputSet::put`] replaces a path's
2359/// output (moving it to the end) without rescanning the list.
2360#[derive(Default)]
2361pub struct OutputSet {
2362    items: Vec<Option<Output>>,
2363    at: HashMap<String, usize>,
2364}
2365
2366impl OutputSet {
2367    pub fn get(&self, path: &str) -> Option<&[u8]> {
2368        let i = *self.at.get(path)?;
2369        self.items[i].as_ref().map(|(_, b)| b.as_slice())
2370    }
2371
2372    pub fn contains(&self, path: &str) -> bool {
2373        self.at.contains_key(path)
2374    }
2375
2376    pub fn paths(&self) -> impl Iterator<Item = &String> {
2377        self.at.keys()
2378    }
2379
2380    /// Sets `path`'s output, dropping the one it had.
2381    pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2382        if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2383            self.items[i] = None;
2384        }
2385        self.items.push(Some((path, bytes)));
2386    }
2387
2388    pub fn into_vec(self) -> Vec<Output> {
2389        self.items.into_iter().flatten().collect()
2390    }
2391}
2392
2393impl From<Vec<Output>> for OutputSet {
2394    fn from(outputs: Vec<Output>) -> Self {
2395        let mut set = OutputSet::default();
2396        for (path, bytes) in outputs {
2397            set.put(path, bytes);
2398        }
2399        set
2400    }
2401}
2402
2403/// A JSON object output at `path`: the one already in `outputs`, else the
2404/// mod's own file there, else empty.
2405fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2406    let read;
2407    let bytes = match outputs.get(path) {
2408        Some(b) => b,
2409        None => match std::fs::read(root.join(path)) {
2410            Ok(b) => {
2411                read = b;
2412                &read
2413            }
2414            Err(_) => return Ok(Map::new()),
2415        },
2416    };
2417    serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2418}
2419
2420/// Adds the content's outputs to `outputs` (the `.yres` ones, say): lang
2421/// entries and tags merged into what's there (or into the mod's own files),
2422/// the rest appended.
2423pub fn merge_outputs(
2424    root: &Path,
2425    outputs: &mut Vec<Output>,
2426    compiled: Compiled,
2427) -> Result<(), String> {
2428    let mut set = OutputSet::from(std::mem::take(outputs));
2429    let mut errors = Vec::new();
2430    // `.yres` texts under the keys the game looks up: in every lang file
2431    // that has the text.
2432    if !compiled.lang_aliases.is_empty() {
2433        let mut lang_files: BTreeSet<String> =
2434            set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2435        if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2436            for ns in dirs.flatten() {
2437                if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2438                    for f in files.flatten() {
2439                        if let Ok(rel) = f.path().strip_prefix(root) {
2440                            let rel = rel.to_string_lossy().replace('\\', "/");
2441                            if is_lang_path(&rel) {
2442                                lang_files.insert(rel);
2443                            }
2444                        }
2445                    }
2446                }
2447            }
2448        }
2449        for path in lang_files {
2450            let mut map = match json_output(&set, root, &path) {
2451                Ok(m) => m,
2452                Err(e) => {
2453                    errors.push(e);
2454                    continue;
2455                }
2456            };
2457            let mut changed = false;
2458            for (from, to) in &compiled.lang_aliases {
2459                if let Some(text) = map.get(from).cloned()
2460                    && !map.contains_key(to)
2461                {
2462                    map.insert(to.clone(), text);
2463                    changed = true;
2464                }
2465            }
2466            if changed {
2467                set.put(path, pretty(&Value::Object(map)));
2468            }
2469        }
2470    }
2471    for ((ns, locale), entries) in &compiled.lang {
2472        let path = format!("assets/{ns}/lang/{locale}.json");
2473        let mut map = match json_output(&set, root, &path) {
2474            Ok(m) => m,
2475            Err(e) => {
2476                errors.push(e);
2477                continue;
2478            }
2479        };
2480        for (k, v) in entries {
2481            map.entry(k.clone())
2482                .or_insert_with(|| Value::String(v.clone()));
2483        }
2484        set.put(path, pretty(&Value::Object(map)));
2485    }
2486    for (path, ids) in &compiled.tags {
2487        // The mod's own tag file may use the pre-1.21 spelling; the loader
2488        // maps both to one file, so merge into whichever it has.
2489        let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2490        let path = if !set.contains(path) && root.join(&plural).is_file() {
2491            plural
2492        } else {
2493            path.clone()
2494        };
2495        let mut map = match json_output(&set, root, &path) {
2496            Ok(m) => m,
2497            Err(e) => {
2498                errors.push(e);
2499                continue;
2500            }
2501        };
2502        let values = map
2503            .entry("values")
2504            .or_insert_with(|| Value::Array(Vec::new()));
2505        if let Value::Array(list) = values {
2506            for id in ids {
2507                if !list.iter().any(|v| v.as_str() == Some(id)) {
2508                    list.push(Value::String(id.clone()));
2509                }
2510            }
2511        }
2512        map.entry("replace").or_insert(Value::Bool(false));
2513        set.put(path, pretty(&Value::Object(map)));
2514    }
2515    for (path, bytes) in compiled.outputs {
2516        set.put(path, bytes);
2517    }
2518    *outputs = set.into_vec();
2519    if errors.is_empty() {
2520        Ok(())
2521    } else {
2522        Err(format!(
2523            "merging blocks/items into the mod's files:\n  {}",
2524            errors.join("\n  ")
2525        ))
2526    }
2527}
2528
2529fn is_lang_path(p: &str) -> bool {
2530    let parts: Vec<&str> = p.split('/').collect();
2531    parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2532}
2533
2534#[cfg(test)]
2535mod tests {
2536    use super::*;
2537
2538    fn project(files: &[(&str, &str)]) -> PathBuf {
2539        use std::sync::atomic::{AtomicUsize, Ordering};
2540        static N: AtomicUsize = AtomicUsize::new(0);
2541        let dir = std::env::temp_dir().join(format!(
2542            "yog-content-test-{}-{}",
2543            std::process::id(),
2544            N.fetch_add(1, Ordering::SeqCst)
2545        ));
2546        let _ = std::fs::remove_dir_all(&dir);
2547        for (path, text) in files {
2548            let p = dir.join(path);
2549            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2550            std::fs::write(p, text).unwrap();
2551        }
2552        dir
2553    }
2554
2555    fn output(c: &Compiled, path: &str) -> Value {
2556        let (_, b) = c
2557            .outputs
2558            .iter()
2559            .find(|(p, _)| p == path)
2560            .unwrap_or_else(|| {
2561                panic!(
2562                    "no {path} in {:?}",
2563                    c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2564                )
2565            });
2566        serde_json::from_slice(b).unwrap()
2567    }
2568
2569    #[test]
2570    fn compiles_a_cube_block() {
2571        let root = project(&[
2572            ("resources/block/ruby_ore.png", "png"),
2573            (
2574                "content/blocks/ruby_ore.yblock",
2575                r#"{
2576  "name": "Ruby Ore",
2577  "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2578             "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2579  "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2580}"#,
2581            ),
2582        ]);
2583        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2584        assert_eq!(c.blocks, 1);
2585        assert_eq!(
2586            output(&c, "assets/mymod/models/block/ruby_ore.json"),
2587            json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2588        );
2589        assert_eq!(
2590            output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2591            json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2592        );
2593        assert_eq!(
2594            output(&c, "assets/mymod/models/item/ruby_ore.json"),
2595            json!({ "parent": "mymod:block/ruby_ore" })
2596        );
2597        assert_eq!(
2598            output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2599            json!("mymod:item/ruby_ore")
2600        );
2601        let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2602        assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2603        assert_eq!(
2604            loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2605            json!(3)
2606        );
2607        assert!(
2608            c.outputs
2609                .iter()
2610                .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2611        );
2612        let src = c.source.as_ref().unwrap();
2613        assert!(
2614            src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2615            "{src}"
2616        );
2617        assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2618        assert!(src.contains(".requires_tool()"), "{src}");
2619        assert!(
2620            src.contains("registry.register_block(Blocks::ruby_ore());"),
2621            "{src}"
2622        );
2623
2624        let mut outputs = Vec::new();
2625        merge_outputs(&root, &mut outputs, c).unwrap();
2626        let find = |p: &str| -> Value {
2627            serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2628        };
2629        assert_eq!(
2630            find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2631            json!("Ruby Ore")
2632        );
2633        assert_eq!(
2634            find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2635            json!(["mymod:ruby_ore"])
2636        );
2637        assert_eq!(
2638            find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2639            json!(["mymod:ruby_ore"])
2640        );
2641        let _ = std::fs::remove_dir_all(root);
2642    }
2643
2644    #[test]
2645    fn raw_parent_with_elements_gets_a_particle() {
2646        let root = project(&[(
2647            "content/cable.yblock",
2648            r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2649                 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2650        )]);
2651        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2652        let m = output(&c, "assets/mymod/models/block/cable.json");
2653        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2654        let _ = std::fs::remove_dir_all(root);
2655    }
2656
2657    #[test]
2658    fn compiles_elements_shape_and_connect() {
2659        let root = project(&[(
2660            "content/pipe.yblock",
2661            r##"{
2662  "block": { "render_layer": "cutout" },
2663  "model": {
2664    "textures": { "0": "minecraft:block/stone" },
2665    "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2666                    "rotation": { "axis": "y", "angle": 22.5 },
2667                    "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2668  },
2669  "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2670     "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2671}"##,
2672        )]);
2673        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2674        let m = output(&c, "assets/mymod/models/block/pipe.json");
2675        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2676        assert_eq!(m["render_type"], json!("minecraft:cutout"));
2677        assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2678        let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2679        assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2680        assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2681        let src = c.source.unwrap();
2682        assert!(
2683            src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2684            "{src}"
2685        );
2686        assert!(src.contains(".connects_to_neighbors()"), "{src}");
2687        assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2688        let _ = std::fs::remove_dir_all(root);
2689    }
2690
2691    #[test]
2692    fn reports_errors_with_lines() {
2693        let root = project(&[(
2694            "content/bad.yblock",
2695            "{\n  \"model\": {\n    \"textures\": { \"0\": \"minecraft:block/stone\" },\n    \"elements\": [ {\n      \"from\": [0, 0, 0], \"to\": [16, 16, 16],\n      \"rotation\": { \"axis\": \"q\", \"angle\": 30 },\n      \"faces\": { \"north\": { \"texture\": \"#1\" } }\n    } ]\n  },\n  \"bogus\": 1\n}",
2696        )]);
2697        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2698        assert!(
2699            e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2700            "{e}"
2701        );
2702        assert!(
2703            e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2704            "{e}"
2705        );
2706        assert!(
2707            e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2708            "{e}"
2709        );
2710        assert!(
2711            e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2712            "{e}"
2713        );
2714        let _ = std::fs::remove_dir_all(root);
2715    }
2716
2717    #[test]
2718    fn compiles_recipes() {
2719        let root = project(&[
2720            ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2721            (
2722                "content/recipes/ruby_block.yrecipe",
2723                r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2724            ),
2725            (
2726                "content/recipes/ruby.yrecipe",
2727                r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2728            ),
2729            (
2730                "content/recipes/smelt_ruby.yrecipe",
2731                r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2732            ),
2733            (
2734                "content/recipes/cut.yrecipe",
2735                r##"{ "type": "stonecutting", "ingredient": ["minecraft:stone", "minecraft:andesite"], "result": "mymod:ruby", "group": "rubies" }"##,
2736            ),
2737        ]);
2738        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2739        assert_eq!(c.recipes, 4);
2740        let src = c.source.clone().unwrap();
2741        for want in [
2742            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"#,
2743            r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
2744            r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
2745            r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
2746            r##"group: Some("rubies".into())"##,
2747        ] {
2748            assert!(src.contains(want), "missing {want}\n{src}");
2749        }
2750        let _ = std::fs::remove_dir_all(root);
2751    }
2752
2753    #[test]
2754    fn recipe_mistakes() {
2755        let bad = |recipe: &str| {
2756            let root = project(&[("content/r.yrecipe", recipe)]);
2757            let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2758            let _ = std::fs::remove_dir_all(root);
2759            e
2760        };
2761        let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2762        assert!(e.contains("pattern uses 'B'"), "{e}");
2763        let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2764        assert!(e.contains("write `minecraft:stick`"), "{e}");
2765        let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2766        assert!(e.contains("must be an item, not a tag"), "{e}");
2767        let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2768        assert!(e.contains("no block or item mymod:nothing"), "{e}");
2769        let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2770        assert!(e.contains("pattern is for shaped recipes"), "{e}");
2771    }
2772
2773    #[test]
2774    fn block_inventory_names_a_screen() {
2775        let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2776        let root = project(&[
2777            ("content/bench.yblock", block),
2778            ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2779        ]);
2780        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2781        assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2782        let _ = std::fs::remove_dir_all(root);
2783
2784        let root = project(&[("content/bench.yblock", block)]);
2785        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2786        assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2787        let _ = std::fs::remove_dir_all(root);
2788    }
2789
2790    #[test]
2791    fn compiles_a_book() {
2792        let book = r#"{
2793            "id": "guide",
2794            "name": "@Book.title",
2795            "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
2796            "entries": [{
2797                "id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
2798                "pages": [
2799                    { "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
2800                    { "type": "spotlight", "item": "@item:mymod:ruby" },
2801                    { "type": "entity", "entity": "@entity:minecraft:allay" }
2802                ]
2803            }]
2804        }"#;
2805        let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
2806        let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
2807        let mut refs = yres::Refs::default();
2808        refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
2809        let out = compile(&root, "mymod", &refs).unwrap();
2810        assert_eq!(out.books, 1);
2811        let src = out.source.unwrap();
2812        assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
2813        assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
2814        assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
2815        assert!(src.contains(r#"\"item\":\"mymod:ruby\""#) && src.contains(r#"\"category\":\"mymod:basics\""#), "references become ids: {src}");
2816
2817        // Bare ids, missing content and version-only ids are errors, each on its line.
2818        let bad = r#"{ "id": "x",
2819            "categories": [{ "id": "c", "name": "C" }],
2820            "entries": [{ "id": "e", "name": "E", "category": "c",
2821                "pages": [
2822                    { "type": "spotlight", "item": "minecraft:stick" },
2823                    { "type": "spotlight", "item": "@item:minecraft:short_grass" },
2824                    { "type": "recipe", "recipe": "@recipe:mymod:nope" },
2825                    { "type": "entity", "entity": "@entity:create:train" }
2826                ] }] }"#;
2827        let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2828        assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
2829        assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
2830        assert!(e.contains("not in 1.20.1"), "{e}");
2831        assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
2832        assert!(e.contains("[external.create]"), "{e}");
2833
2834        let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
2835        let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2836        assert!(e.contains("entries[0].pages[0].type: unknown `nope`"), "{e}");
2837        let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
2838        let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2839        assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
2840    }
2841
2842    #[test]
2843    fn compiles_a_recipe_of_the_mods_own_kind() {
2844        let r = r##"{ "type": "mymod:infusion", "slots": { "core": ["minecraft:diamond"], "ring": [["minecraft:coal", "minecraft:charcoal"], "#c:dusts"] },
2845                     "values": { "time": 200 }, "result": "minecraft:emerald", "count": 2 }"##;
2846        let c = compile(&project(&[("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
2847        let src = c.source.unwrap();
2848        assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
2849        assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
2850
2851        let bad = r#"{ "type": "mymod:infusion", "ingredient": "minecraft:stone", "result": "minecraft:emerald" }"#;
2852        let e = compile(&project(&[("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2853        assert!(e.contains("takes \"slots\"") && e.contains("needs \"slots\""), "{e}");
2854        // Another mod's kind isn't the mod's own: not a Yog recipe type.
2855        let other = r#"{ "type": "create:mixing", "result": "minecraft:emerald" }"#;
2856        assert!(compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).is_err());
2857    }
2858
2859    #[test]
2860    fn compiles_an_item_with_resource_refs() {
2861        let root = project(&[(
2862            "content/items/ruby.yitem",
2863            r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2864                 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2865                 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2866        )]);
2867        let mut refs = yres::Refs::default();
2868        refs.texts.insert(
2869            "Items.ruby".into(),
2870            ("item.mymod.ruby".into(), "Ruby".into()),
2871        );
2872        refs.texts.insert(
2873            "Strings.ruby_tip".into(),
2874            ("mymod.ruby_tip".into(), "Shiny".into()),
2875        );
2876        refs.images
2877            .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2878        let c = compile(&root, "mymod", &refs).unwrap();
2879        assert_eq!(
2880            output(&c, "assets/mymod/models/item/ruby.json"),
2881            json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2882        );
2883        let src = c.source.clone().unwrap();
2884        assert!(src.contains(".max_stack(16)"), "{src}");
2885        assert!(
2886            src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2887            "{src}"
2888        );
2889        // The .yres lang file gets the tooltip under the key the game reads.
2890        let mut outputs = vec![(
2891            "assets/mymod/lang/ru_ru.json".to_string(),
2892            r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2893                .as_bytes()
2894                .to_vec(),
2895        )];
2896        merge_outputs(&root, &mut outputs, c).unwrap();
2897        let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2898        assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2899        let _ = std::fs::remove_dir_all(root);
2900    }
2901
2902    #[test]
2903    fn namespace_needed_for_odd_mod_ids() {
2904        let root = project(&[(
2905            "content/x.yitem",
2906            r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2907        )]);
2908        let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2909        assert!(e.contains("give the namespace"), "{e}");
2910        let _ = std::fs::remove_dir_all(root);
2911    }
2912}