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`: a `yog_book::Book` as JSON, 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, j: &J, mod_ns: Option<&str>, known: &refs::Known) -> Option<BookFile> {
1476    use yog_book::refs::{self as r, RefKind};
1477
1478    fn plain(j: &J) -> Value {
1479        match &j.v {
1480            V::Null => Value::Null,
1481            V::Bool(b) => Value::Bool(*b),
1482            V::Num(n) => serde_json::Number::from_f64(*n).map_or(Value::Null, |n| {
1483                // Whole numbers stay integers (sortnum, priority, version).
1484                if n.as_f64().is_some_and(|f| f.fract() == 0.0 && f.abs() < 9e15) {
1485                    Value::from(n.as_f64().unwrap_or(0.0) as i64)
1486                } else {
1487                    Value::Number(n)
1488                }
1489            }),
1490            V::Str(s) => Value::String(s.clone()),
1491            V::Arr(items) => Value::Array(items.iter().map(plain).collect()),
1492            V::Obj(members) => Value::Object(members.iter().map(|(k, v)| (k.clone(), plain(v))).collect()),
1493        }
1494    }
1495
1496    /// The line of `path` (`entries[2].pages[0].item`) in the file.
1497    fn line_of(j: &J, path: &str) -> usize {
1498        let mut at = j;
1499        for seg in path.split('.') {
1500            let (key, idx) = match seg.split_once('[') {
1501                Some((k, rest)) => (k, rest.trim_end_matches(']').parse::<usize>().ok()),
1502                None => (seg, None),
1503            };
1504            let Some(next) = at.get(key) else { break };
1505            at = next;
1506            if let Some(i) = idx
1507                && let Some(item) = at.items().get(i)
1508            {
1509                at = item;
1510            }
1511        }
1512        at.line
1513    }
1514
1515    struct Check<'a> {
1516        known: &'a refs::Known,
1517        texts: &'a HashMap<String, (String, String)>,
1518        entries: HashSet<String>,
1519        categories: HashSet<String>,
1520        text_refs: bool,
1521        errors: Vec<String>,
1522    }
1523
1524    impl r::Resolver for Check<'_> {
1525        fn reference(&mut self, kind: RefKind, id: &str, _path: &str) -> Result<String, String> {
1526            let own = match kind {
1527                RefKind::Entry => Some(&self.entries),
1528                RefKind::Category => Some(&self.categories),
1529                _ => None,
1530            };
1531            match own {
1532                Some(set) if set.contains(id) => Ok(id.to_string()),
1533                Some(_) => Err(format!("no {} {id} in this book", kind.word())),
1534                None => self.known.check(kind, id).map(|()| id.to_string()),
1535            }
1536        }
1537
1538        fn text(&mut self, text: &str, path: &str) -> Option<String> {
1539            let rest = text.strip_prefix('@')?;
1540            if let Some(lit) = rest.strip_prefix('@') {
1541                return Some(format!("@{lit}"));
1542            }
1543            // A name: the game's translation of the item, block or entity.
1544            if let Some((kind, id)) = r::parse(text) {
1545                let word = match kind {
1546                    RefKind::Item | RefKind::Block | RefKind::Entity => kind.word(),
1547                    _ => {
1548                        self.errors.push(format!("{path}: `{text}` has no name to show"));
1549                        return None;
1550                    }
1551                };
1552                if let Err(e) = self.known.check(kind, id) {
1553                    self.errors.push(format!("{path}: {e}"));
1554                }
1555                let (ns, p) = r::bare(id).split_once(':').unwrap_or(("minecraft", id));
1556                self.text_refs = true;
1557                return Some(format!("{word}.{ns}.{}", p.replace('/', ".")));
1558            }
1559            match self.texts.get(rest) {
1560                Some((key, _)) => {
1561                    self.text_refs = true;
1562                    Some(key.clone())
1563                }
1564                None => {
1565                    self.errors.push(format!("{path}: `{text}`: no text `{rest}` in resources/*.yres"));
1566                    None
1567                }
1568            }
1569        }
1570
1571        fn text_of(&mut self, text: &str) -> Option<String> {
1572            self.texts.get(text.strip_prefix('@')?).map(|(_, t)| t.clone())
1573        }
1574    }
1575
1576    if !matches!(j.v, V::Obj(_)) {
1577        cx.err(j.line, "a .ybook file is one JSON object");
1578        return None;
1579    }
1580    let mut value = plain(j);
1581    let obj = value.as_object_mut()?;
1582    let id = match obj.get("id").and_then(|v| v.as_str()) {
1583        Some(id) if id.contains(':') => id.to_string(),
1584        Some(id) => format!("{}:{id}", mod_ns.unwrap_or("minecraft")),
1585        None => {
1586            cx.err(j.line, "a book needs an `id` (the item that opens it)");
1587            return None;
1588        }
1589    };
1590    if !valid_id(&id) {
1591        cx.err(j.line, format!("book id `{id}`: not a valid id"));
1592        return None;
1593    }
1594    obj.insert("id".into(), id.clone().into());
1595    let ids = |key: &str| -> HashSet<String> {
1596        obj.get(key)
1597            .and_then(Value::as_array)
1598            .map(|a| a.iter().filter_map(|e| e.get("id")?.as_str().map(str::to_string)).collect())
1599            .unwrap_or_default()
1600    };
1601    let mut check = Check {
1602        known,
1603        texts: &cx.refs.texts,
1604        entries: ids("entries"),
1605        categories: ids("categories"),
1606        text_refs: false,
1607        errors: Vec::new(),
1608    };
1609    let mut errors = r::resolve(&mut value, &mut check);
1610    errors.append(&mut check.errors);
1611    let text_refs = check.text_refs;
1612    for e in &errors {
1613        let path = e.split(": ").next().unwrap_or_default();
1614        cx.err(line_of(j, path), e);
1615    }
1616    if !errors.is_empty() {
1617        return None;
1618    }
1619    if text_refs && let Some(obj) = value.as_object_mut() && !obj.contains_key("i18n") {
1620        obj.insert("i18n".into(), true.into());
1621    }
1622    if let Err(e) = serde_json::from_value::<yog_book::Book>(value.clone()) {
1623        cx.err(j.line, format!("not a book: {e}"));
1624        return None;
1625    }
1626    Some(BookFile { id, json: value.to_string() })
1627}
1628
1629/// Keys a source file may carry for its authors and tools — `$…`, `_…`,
1630/// `comment`, `meta` — which nothing ships.
1631fn is_metadata(key: &str) -> bool {
1632    key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
1633}
1634
1635/// One checked `.yrecipe`.
1636#[derive(Debug, Clone)]
1637struct Recipe {
1638    id: String,
1639    file: String,
1640    kind: RecipeKind,
1641    result: String,
1642    count: u32,
1643    group: Option<String>,
1644    category: Option<String>,
1645    /// Item ids it names (not tags), by line — the mod's own are checked
1646    /// against its blocks and items.
1647    items: Vec<(usize, String)>,
1648}
1649
1650impl Recipe {
1651    /// The `registry.add_recipe(...)` call registering it.
1652    fn registration(&self) -> String {
1653        let list = |items: &[String]| {
1654            let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
1655            format!("vec![{}]", quoted.join(", "))
1656        };
1657        let kind = match &self.kind {
1658            RecipeKind::Shaped { pattern, key } => {
1659                let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
1660                let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
1661                format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
1662            }
1663            RecipeKind::Shapeless(ingredients) => {
1664                let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
1665                format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
1666            }
1667            RecipeKind::Cooking { method, input, experience, cook_time } => format!(
1668                "Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
1669                list(input),
1670                float(*experience)
1671            ),
1672            RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
1673            RecipeKind::Custom { kind, slots, values } => {
1674                // Yog's recipe JSON as is: the host keeps it out of the game's data.
1675                let mut o = Map::new();
1676                o.insert("type".into(), kind.clone().into());
1677                o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
1678                if !values.is_empty() {
1679                    o.insert("values".into(), Value::Object(values.clone()));
1680                }
1681                o.insert("result".into(), self.result.clone().into());
1682                if self.count != 1 {
1683                    o.insert("count".into(), self.count.into());
1684                }
1685                if let Some(g) = &self.group {
1686                    o.insert("group".into(), g.clone().into());
1687                }
1688                return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
1689            }
1690            RecipeKind::Smithing { template, base, addition } => format!(
1691                "Smithing {{ template: {}, base: {}, addition: {} }}",
1692                list(template),
1693                list(base),
1694                list(addition)
1695            ),
1696        };
1697        let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
1698        format!(
1699            "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
1700            self.id,
1701            self.result,
1702            self.count,
1703            opt(&self.group),
1704            opt(&self.category)
1705        )
1706    }
1707}
1708
1709/// An item id (`minecraft:paper`), or — where `tags` — an item tag
1710/// (`#c:dusts`); the namespace is always written out.
1711fn recipe_item(cx: &mut Ctx, j: &J, what: &str, tags: bool, items: &mut Vec<(usize, String)>) -> Option<String> {
1712    let s = cx.string(j, what)?.trim().to_string();
1713    let (tag, id) = match s.strip_prefix('#') {
1714        Some(rest) => (true, rest),
1715        None => (false, s.as_str()),
1716    };
1717    if tag && !tags {
1718        cx.err(j.line, format!("{what} `{s}`: must be an item, not a tag"));
1719        return None;
1720    }
1721    let ok = id.split_once(':').is_some_and(|(ns, p)| valid_namespace(ns) && valid_path(p));
1722    if !ok {
1723        let hint = if id.contains(':') { String::new() } else { format!(" — write `minecraft:{id}`") };
1724        cx.err(
1725            j.line,
1726            format!("{what} `{s}`: expected an item id with its namespace (minecraft:paper){}{hint}", if tags { " or an item tag (#c:dusts)" } else { "" }),
1727        );
1728        return None;
1729    }
1730    if !tag {
1731        items.push((j.line, id.to_string()));
1732    }
1733    Some(s)
1734}
1735
1736/// A recipe of the mod's own kind `kind`: `slots` (name → a list of
1737/// ingredients), `values`, `result`, `count`, `group`.
1738fn compile_custom_recipe(cx: &mut Ctx, j: &J, kind: String, id: String) -> Option<Recipe> {
1739    for key in ["pattern", "key", "ingredients", "ingredient", "experience", "cook_time", "template", "base", "addition", "category"] {
1740        if let Some(v) = j.get(key) {
1741            cx.err(v.line, format!("{key} is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
1742        }
1743    }
1744    let mut items = Vec::new();
1745    let mut slots = Vec::new();
1746    match j.get("slots") {
1747        Some(sv) if sv.is_obj() => {
1748            for (name, list) in sv.members() {
1749                if !list.is_arr() || list.items().is_empty() {
1750                    cx.err(list.line, format!("slots.{name}: a list of ingredients — [\"minecraft:diamond\"], [[\"minecraft:coal\", \"minecraft:charcoal\"]]"));
1751                    continue;
1752                }
1753                let ings: Vec<Vec<String>> = list
1754                    .items()
1755                    .iter()
1756                    .filter_map(|i| recipe_ingredient(cx, i, &format!("slots.{name}[]"), &mut items))
1757                    .collect();
1758                slots.push((name.clone(), ings));
1759            }
1760        }
1761        Some(sv) => cx.err(sv.line, "slots must be an object: { \"core\": [\"minecraft:diamond\"] }"),
1762        None => cx.err(j.line, format!("a {kind} recipe needs \"slots\": what goes in, by slot name")),
1763    }
1764    let values = match j.get("values") {
1765        Some(v) if v.is_obj() => v.members().iter().map(|(k, x)| (k.clone(), json_value(x))).collect(),
1766        Some(v) => {
1767            cx.err(v.line, "values must be an object: { \"time\": 200 }");
1768            Map::new()
1769        }
1770        None => Map::new(),
1771    };
1772    let result = match j.get("result") {
1773        Some(v) => recipe_item(cx, v, "result", false, &mut items),
1774        None => {
1775            cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1776            None
1777        }
1778    };
1779    let count = match j.get("count") {
1780        Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1781        None => Some(1),
1782    };
1783    let group = j.get("group").and_then(|v| cx.string(v, "group").map(str::to_owned));
1784    Some(Recipe {
1785        id,
1786        file: cx.file.clone(),
1787        kind: RecipeKind::Custom { kind, slots, values },
1788        result: result?,
1789        count: count?,
1790        group,
1791        category: None,
1792        items,
1793    })
1794}
1795
1796/// `j` as plain JSON.
1797fn json_value(j: &J) -> Value {
1798    match &j.v {
1799        V::Null => Value::Null,
1800        V::Bool(b) => Value::Bool(*b),
1801        V::Num(n) => num_value(*n),
1802        V::Str(s) => Value::String(s.clone()),
1803        V::Arr(items) => Value::Array(items.iter().map(json_value).collect()),
1804        V::Obj(members) => Value::Object(members.iter().map(|(k, v)| (k.clone(), json_value(v))).collect()),
1805    }
1806}
1807
1808/// Checks one `.yrecipe`.
1809fn compile_recipe(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<Recipe> {
1810    if !j.is_obj() {
1811        cx.err(j.line, "a .yrecipe file is one JSON object");
1812        return None;
1813    }
1814    cx.unknown_keys(
1815        j,
1816        "a .yrecipe",
1817        &[
1818            "$schema", "format", "id", "type", "pattern", "key", "ingredients", "ingredient",
1819            "result", "count", "experience", "cook_time", "template", "base", "addition", "group",
1820            "category", "slots", "values",
1821        ],
1822    );
1823    if let Some(f) = j.get("format")
1824        && f.num() != Some(1.0)
1825    {
1826        cx.err(f.line, "format must be 1 (this yog understands format 1)");
1827    }
1828    let (ns, p) = entry_id(cx, j, path, mod_ns)?;
1829    let Some(t) = j.get("type") else {
1830        cx.err(j.line, format!("a recipe needs a \"type\": {}", RECIPE_TYPES.join(", ")));
1831        return None;
1832    };
1833    // The mod's own kind of recipe: `<mod id>:<kind>`.
1834    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)))) {
1835        return compile_custom_recipe(cx, j, own.to_string(), format!("{ns}:{p}"));
1836    }
1837    let ty = cx.one_of(t, "type", &RECIPE_TYPES)?;
1838    let cooking = matches!(ty.as_str(), "smelting" | "blasting" | "smoking" | "campfire_cooking");
1839    // Keys of the other types.
1840    for (key, owners) in [
1841        ("pattern", &["shaped"][..]),
1842        ("key", &["shaped"]),
1843        ("ingredients", &["shapeless"]),
1844        ("ingredient", &["smelting", "blasting", "smoking", "campfire_cooking", "stonecutting"]),
1845        ("experience", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1846        ("cook_time", &["smelting", "blasting", "smoking", "campfire_cooking"]),
1847        ("template", &["smithing"]),
1848        ("base", &["smithing"]),
1849        ("addition", &["smithing"]),
1850    ] {
1851        if !owners.contains(&ty.as_str())
1852            && let Some(v) = j.get(key)
1853        {
1854            cx.err(v.line, format!("{key} is for {} recipes, this one is {ty}", owners.join("/")));
1855        }
1856    }
1857    let mut items = Vec::new();
1858    let result = match j.get("result") {
1859        Some(v) => recipe_item(cx, v, "result", false, &mut items),
1860        None => {
1861            cx.err(j.line, "a recipe needs a \"result\": the item it makes");
1862            None
1863        }
1864    };
1865    let count = match j.get("count") {
1866        Some(v) => cx.number(v, "count", 1.0, 64.0).map(|n| n as u32),
1867        None => Some(1),
1868    };
1869    let text = |cx: &mut Ctx, key: &str| j.get(key).and_then(|v| cx.string(v, key).map(str::to_owned));
1870    let group = text(cx, "group");
1871    let category = text(cx, "category");
1872    let kind = match ty.as_str() {
1873        "shaped" => {
1874            let Some(pv) = j.get("pattern") else {
1875                cx.err(j.line, "a shaped recipe needs a \"pattern\": 1–3 rows of 1–3 key characters, e.g. [\"ISI\", \" S \"]");
1876                return None;
1877            };
1878            let rows: Vec<String> = pv.items().iter().filter_map(|r| cx.string(r, "pattern[]").map(str::to_owned)).collect();
1879            let width = rows.first().map_or(0, |r| r.chars().count());
1880            if !pv.is_arr() || rows.is_empty() || rows.len() > 3 || width == 0 || width > 3 || rows.iter().any(|r| r.chars().count() != width) {
1881                cx.err(pv.line, "pattern must be 1–3 rows of the same 1–3 characters each");
1882                return None;
1883            }
1884            let mut key = Vec::new();
1885            match j.get("key") {
1886                Some(kv) if kv.is_obj() => {
1887                    for (k, v) in kv.members() {
1888                        let mut chars = k.chars();
1889                        match (chars.next(), chars.next()) {
1890                            (Some(c), None) if c != ' ' => {
1891                                if let Some(alts) = recipe_ingredient(cx, v, &format!("key {k:?}"), &mut items) {
1892                                    if !rows.iter().any(|r| r.contains(c)) {
1893                                        cx.warn(v.line, format!("key {k:?} isn't used by the pattern"));
1894                                    }
1895                                    key.push((c, alts));
1896                                }
1897                            }
1898                            _ => cx.err(v.line, format!("key {k:?}: one character other than space")),
1899                        }
1900                    }
1901                }
1902                Some(kv) => cx.err(kv.line, "key must be an object: { \"I\": \"minecraft:iron_ingot\", ... }"),
1903                None => cx.err(j.line, "a shaped recipe needs a \"key\": what each pattern character stands for"),
1904            }
1905            for c in rows.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
1906                if !key.iter().any(|(k, _)| *k == c) && j.get("key").is_some_and(|kv| kv.get(&c.to_string()).is_none()) {
1907                    cx.err(pv.line, format!("pattern uses {c:?}, which key doesn't define"));
1908                    return None;
1909                }
1910            }
1911            RecipeKind::Shaped { pattern: rows, key }
1912        }
1913        "shapeless" => {
1914            let Some(iv) = j.get("ingredients") else {
1915                cx.err(j.line, "a shapeless recipe needs \"ingredients\": 1–9 items or tags");
1916                return None;
1917            };
1918            let list: Vec<Vec<String>> = iv
1919                .items()
1920                .iter()
1921                .filter_map(|i| recipe_ingredient(cx, i, "ingredients[]", &mut items))
1922                .collect();
1923            if !iv.is_arr() || iv.items().is_empty() || iv.items().len() > 9 {
1924                cx.err(iv.line, "ingredients must be an array of 1–9 items or tags");
1925                return None;
1926            }
1927            RecipeKind::Shapeless(list)
1928        }
1929        "smithing" => {
1930            let mut part = |cx: &mut Ctx, key: &str| match j.get(key) {
1931                Some(v) => recipe_ingredient(cx, v, key, &mut items),
1932                None => {
1933                    cx.err(j.line, format!("a smithing recipe needs a \"{key}\""));
1934                    None
1935                }
1936            };
1937            let template = part(cx, "template");
1938            let base = part(cx, "base");
1939            let addition = part(cx, "addition");
1940            RecipeKind::Smithing { template: template?, base: base?, addition: addition? }
1941        }
1942        _ => {
1943            let Some(iv) = j.get("ingredient") else {
1944                cx.err(j.line, format!("a {ty} recipe needs an \"ingredient\": the item or tag it takes"));
1945                return None;
1946            };
1947            let input = recipe_ingredient(cx, iv, "ingredient", &mut items)?;
1948            if !cooking {
1949                RecipeKind::Stonecutting(input)
1950            } else {
1951                let (method, default_time) = match ty.as_str() {
1952                    "smelting" => ("Smelting", 200.0),
1953                    "blasting" => ("Blasting", 100.0),
1954                    "smoking" => ("Smoking", 100.0),
1955                    _ => ("Campfire", 600.0),
1956                };
1957                let experience = match j.get("experience") {
1958                    Some(v) => cx.number(v, "experience", 0.0, 1000.0)?,
1959                    None => 0.1,
1960                };
1961                let cook_time = match j.get("cook_time") {
1962                    Some(v) => cx.number(v, "cook_time", 1.0, 32767.0)?,
1963                    None => default_time,
1964                } as u32;
1965                RecipeKind::Cooking { method, input, experience, cook_time }
1966            }
1967        }
1968    };
1969    Some(Recipe {
1970        id: format!("{ns}:{p}"),
1971        file: cx.file.clone(),
1972        kind,
1973        result: result?,
1974        count: count?,
1975        group,
1976        category,
1977        items,
1978    })
1979}
1980
1981/// An ingredient: an item, a tag, or an array of either (any of them).
1982fn recipe_ingredient(cx: &mut Ctx, j: &J, what: &str, items: &mut Vec<(usize, String)>) -> Option<Vec<String>> {
1983    if j.is_arr() {
1984        let alts: Vec<String> = j.items().iter().filter_map(|i| recipe_item(cx, i, what, true, items)).collect();
1985        if alts.is_empty() {
1986            cx.err(j.line, format!("{what}: an array of items or tags, at least one"));
1987            return None;
1988        }
1989        return Some(alts);
1990    }
1991    recipe_item(cx, j, what, true, items).map(|i| vec![i])
1992}
1993
1994/// Ids of the `.yui` screens under `dir` that lay out an inventory (have
1995/// `<Slot>`s or a `<PlayerInventory>`).
1996fn inventory_screens(dir: &Path) -> BTreeSet<String> {
1997    let mut out = BTreeSet::new();
1998    let Ok(entries) = std::fs::read_dir(dir) else {
1999        return out;
2000    };
2001    for e in entries.flatten() {
2002        let path = e.path();
2003        if path.is_dir() {
2004            out.extend(inventory_screens(&path));
2005            continue;
2006        }
2007        if path.extension().is_none_or(|x| x != "yui") {
2008            continue;
2009        }
2010        let Ok(text) = std::fs::read_to_string(&path) else { continue };
2011        if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
2012            continue;
2013        }
2014        if let Some(screen) = text.find("<Screen")
2015            && let Some(at) = text[screen..].find(" id=\"")
2016        {
2017            let rest = &text[screen + at + 5..];
2018            if let Some(end) = rest.find('"') {
2019                out.insert(rest[..end].to_string());
2020            }
2021        }
2022    }
2023    out
2024}
2025
2026/// Compiles the mod's content files (see the module docs). Any error fails
2027/// the whole thing, listing every problem.
2028pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
2029    let mut out = Compiled::default();
2030    let files = find(root);
2031    if files.is_empty() {
2032        return Ok(out);
2033    }
2034    let mod_ns = valid_namespace(mod_id).then_some(mod_id);
2035    let mut errors: Vec<String> = Vec::new();
2036    let mut warnings: Vec<String> = Vec::new();
2037    let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
2038    let mut generated: BTreeMap<String, String> = BTreeMap::new();
2039    let mut entries: Vec<Entry> = Vec::new();
2040    let mut ids: HashMap<String, String> = HashMap::new();
2041    let mut recipes: Vec<Recipe> = Vec::new();
2042    let mut recipe_ids: HashMap<String, String> = HashMap::new();
2043    let mut books: Vec<BookFile> = Vec::new();
2044    let mut book_ids: HashMap<String, String> = HashMap::new();
2045    let mut inventories: Vec<(String, usize, String)> = Vec::new();
2046    let mut pending_books: Vec<(String, J)> = Vec::new();
2047    for path in &files {
2048        let shown = display(root, path);
2049        let Some(kind) = Kind::of(path) else { continue };
2050        let text = match std::fs::read_to_string(path) {
2051            Ok(t) => t,
2052            Err(e) => {
2053                errors.push(format!("{shown}: {e}"));
2054                continue;
2055            }
2056        };
2057        let j = match json::parse(&text) {
2058            Ok(j) => j,
2059            Err(e) => {
2060                errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
2061                continue;
2062            }
2063        };
2064        let mut cx = Ctx {
2065            root,
2066            refs,
2067            file: shown.clone(),
2068            ns: mod_ns.unwrap_or("minecraft").to_string(),
2069            errors: &mut errors,
2070            warnings: &mut warnings,
2071            textures: &mut textures,
2072            inventories: &mut inventories,
2073        };
2074        if kind == Kind::Book {
2075            // Checked once every block, item and recipe is known.
2076            pending_books.push((shown.clone(), j));
2077            continue;
2078        }
2079        if kind == Kind::Recipe {
2080            if let Some(r) = compile_recipe(&mut cx, &j, path, mod_ns) {
2081                if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
2082                    errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
2083                }
2084                recipes.push(r);
2085            }
2086            continue;
2087        }
2088        if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
2089        {
2090            if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
2091                errors.push(format!(
2092                    "{shown}: id {} is also defined by {other}",
2093                    entry.id
2094                ));
2095            }
2096            entries.push(entry);
2097        }
2098    }
2099    // Books name the mod's content, vanilla's and other mods' declared ids.
2100    let externals = crate::manifest::YogToml::read(&root.join("yog.toml")).map(|t| t.externals).unwrap_or_default();
2101    let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals);
2102    for e in &entries {
2103        known.add(if e.kind == Kind::Block { yog_book::refs::RefKind::Block } else { yog_book::refs::RefKind::Item }, e.id.clone());
2104    }
2105    for r in &recipes {
2106        known.add(yog_book::refs::RefKind::Recipe, r.id.clone());
2107    }
2108    for (shown, j) in &pending_books {
2109        let mut cx = Ctx {
2110            root,
2111            refs,
2112            file: shown.clone(),
2113            ns: mod_ns.unwrap_or("minecraft").to_string(),
2114            errors: &mut errors,
2115            warnings: &mut warnings,
2116            textures: &mut textures,
2117            inventories: &mut inventories,
2118        };
2119        if let Some(b) = compile_book(&mut cx, j, mod_ns, &known) {
2120            if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
2121                errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
2122            }
2123            books.push(b);
2124        }
2125    }
2126    // What recipes name from this mod must be its blocks and items (vanilla
2127    // and other mods' ids can't be checked here).
2128    if let Some(ns) = mod_ns {
2129        for r in &recipes {
2130            for (line, item) in &r.items {
2131                if item.split_once(':').is_some_and(|(n, _)| n == ns) && !ids.contains_key(item) {
2132                    errors.push(format!(
2133                        "{}:{line}: no block or item {item} in {CONTENT_DIR}/",
2134                        r.file
2135                    ));
2136                }
2137            }
2138        }
2139        let screens = inventory_screens(&root.join("src"));
2140        for (file, line, id) in &inventories {
2141            if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
2142                errors.push(format!(
2143                    "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
2144                ));
2145            }
2146        }
2147    }
2148    for (target, source) in &textures {
2149        match std::fs::read(source) {
2150            Ok(bytes) => out.outputs.push((target.clone(), bytes)),
2151            Err(e) => errors.push(format!("{}: {e}", display(root, source))),
2152        }
2153    }
2154    out.warnings = warnings;
2155    if !errors.is_empty() {
2156        return Err(format!(
2157            "invalid blocks/items in content/:\n  {}",
2158            errors.join("\n  ")
2159        ));
2160    }
2161    out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
2162    out.items = entries.len() - out.blocks;
2163    out.recipes = recipes.len();
2164    out.books = books.len();
2165    if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
2166        && !mentions_content_macro(&root.join("src"))
2167    {
2168        out.warnings.push(format!(
2169            "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
2170            CONTENT_DIR
2171        ));
2172    }
2173    out.source = Some(rust_source(&entries, &recipes, &books));
2174    Ok(out)
2175}
2176
2177/// Whether any `src/**/*.rs` has `content!(` — the include of the generated code.
2178fn mentions_content_macro(dir: &Path) -> bool {
2179    let Ok(entries) = std::fs::read_dir(dir) else {
2180        return false;
2181    };
2182    entries.flatten().any(|e| {
2183        let p = e.path();
2184        if p.is_dir() {
2185            mentions_content_macro(&p)
2186        } else {
2187            p.extension().is_some_and(|x| x == "rs")
2188                && std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
2189        }
2190    })
2191}
2192
2193/// `ruby_ore`, `ores/ruby` → `ores_ruby`; a Rust keyword gets `r#`.
2194fn rust_ident(path: &str) -> String {
2195    let mut s: String = path
2196        .chars()
2197        .map(|c| {
2198            if c.is_ascii_alphanumeric() {
2199                c.to_ascii_lowercase()
2200            } else {
2201                '_'
2202            }
2203        })
2204        .collect();
2205    if s.starts_with(|c: char| c.is_ascii_digit()) {
2206        s.insert(0, '_');
2207    }
2208    s
2209}
2210
2211fn float(n: f64) -> String {
2212    let s = format!("{n:?}");
2213    if s.contains('.') || s.contains('e') {
2214        s
2215    } else {
2216        format!("{s}.0")
2217    }
2218}
2219
2220fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
2221    let mut code = String::from(
2222        "// <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",
2223    );
2224    for (kind, set, def) in [
2225        (Kind::Block, "Blocks", "BlockDef"),
2226        (Kind::Item, "Items", "ItemDef"),
2227    ] {
2228        let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
2229        let _ = writeln!(
2230            code,
2231            "\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} {{",
2232            if kind == Kind::Block {
2233                "blocks"
2234            } else {
2235                "items"
2236            }
2237        );
2238        let mut used: HashMap<String, usize> = HashMap::new();
2239        for e in mine {
2240            let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2241            let mut ident = rust_ident(path);
2242            let n = used.entry(ident.clone()).or_insert(0);
2243            *n += 1;
2244            if *n > 1 {
2245                let _ = write!(ident, "_{n}");
2246            }
2247            let upper = ident.to_ascii_uppercase();
2248            let ident = if is_keyword(&ident) {
2249                format!("r#{ident}")
2250            } else {
2251                ident
2252            };
2253            let _ = writeln!(
2254                code,
2255                "    /// `{}` — `{}`.\n    pub const {upper}: &'static str = {:?};",
2256                e.id, e.file, e.id
2257            );
2258            let mut b = format!("::yog_api::{def}::new(Self::{upper})");
2259            match kind {
2260                Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2261                Kind::Block => {
2262                    let p = &e.block;
2263                    if p.hardness.is_some() || p.resistance.is_some() {
2264                        let _ = write!(
2265                            b,
2266                            "\n            .strength({}, {})",
2267                            float(p.hardness.unwrap_or(1.5)),
2268                            float(p.resistance.unwrap_or(6.0))
2269                        );
2270                    }
2271                    if let Some(s) = &p.sound {
2272                        let _ = write!(b, "\n            .sound({s:?})");
2273                    }
2274                    if p.light > 0 {
2275                        let _ = write!(b, "\n            .light_level({})", p.light);
2276                    }
2277                    if p.requires_tool {
2278                        b.push_str("\n            .requires_tool()");
2279                    }
2280                    if p.no_collision {
2281                        b.push_str("\n            .no_collision()");
2282                    }
2283                    if let Some(s) = p.slipperiness {
2284                        let _ = write!(b, "\n            .slipperiness({})", float(s));
2285                    }
2286                    if let Some(s) = p.shape {
2287                        let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
2288                        let _ = write!(b, "\n            .shape({})", args.join(", "));
2289                    }
2290                    if p.connects {
2291                        b.push_str("\n            .connects_to_neighbors()");
2292                    }
2293                    if !p.connect_groups.is_empty() {
2294                        let groups: Vec<String> =
2295                            p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
2296                        let _ =
2297                            write!(b, "\n            .connect_groups(&[{}])", groups.join(", "));
2298                    }
2299                    if let Some(inv) = &p.inventory {
2300                        let _ = write!(b, "\n            .inventory({inv:?})");
2301                    }
2302                    if p.redstone {
2303                        b.push_str("\n            .redstone_source()");
2304                    }
2305                }
2306                Kind::Item => {
2307                    let p = &e.item;
2308                    if let Some(s) = p.stack_size {
2309                        let _ = write!(b, "\n            .max_stack({s})");
2310                    }
2311                    if p.durability > 0 {
2312                        let _ = write!(b, "\n            .max_damage({})", p.durability);
2313                    }
2314                    if p.fire_resistant {
2315                        b.push_str("\n            .fire_resistant()");
2316                    }
2317                    if p.fuel_ticks > 0 {
2318                        let _ = write!(b, "\n            .fuel({})", p.fuel_ticks);
2319                    }
2320                    if let Some((n, s, always)) = p.food {
2321                        let _ = write!(
2322                            b,
2323                            "\n            .food(::yog_api::FoodDef::new({n}, {}){})",
2324                            float(s),
2325                            if always { ".can_always_eat()" } else { "" }
2326                        );
2327                    }
2328                }
2329            }
2330            let _ = writeln!(
2331                code,
2332                "    /// The definition from `{}` (name and tooltip come from the lang files).\n    pub fn {ident}() -> ::yog_api::{def} {{\n        {b}\n    }}",
2333                e.file
2334            );
2335        }
2336        code.push_str("}\n");
2337    }
2338
2339    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");
2340    let mut used: HashMap<(Kind, String), usize> = HashMap::new();
2341    for e in entries {
2342        let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
2343        let mut ident = rust_ident(path);
2344        let n = used.entry((e.kind, ident.clone())).or_insert(0);
2345        *n += 1;
2346        if *n > 1 {
2347            let _ = write!(ident, "_{n}");
2348        }
2349        let ident = if is_keyword(&ident) {
2350            format!("r#{ident}")
2351        } else {
2352            ident
2353        };
2354        if !e.auto {
2355            continue;
2356        }
2357        let _ = match e.kind {
2358            Kind::Block => writeln!(code, "    registry.register_block(Blocks::{ident}());"),
2359            Kind::Item => writeln!(code, "    registry.register_item(Items::{ident}());"),
2360            Kind::Recipe | Kind::Book => unreachable!("only blocks and items get here"),
2361        };
2362    }
2363    for r in recipes {
2364        let _ = writeln!(code, "    {}", r.registration());
2365    }
2366    for b in books {
2367        let _ = writeln!(code, "    registry.register_book_json({:?}, {:?});", b.id, b.json);
2368    }
2369    code.push_str("}\n");
2370    if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
2371        code.push_str(
2372            "\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",
2373        );
2374    }
2375    code
2376}
2377
2378fn is_keyword(s: &str) -> bool {
2379    const KW: &[&str] = &[
2380        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
2381        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
2382        "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
2383        "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
2384        "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
2385    ];
2386    KW.contains(&s)
2387}
2388
2389/// Writes `compiled.source` to [`GENERATED_FILE`] (only when it changed);
2390/// deletes it when the mod has no content files.
2391pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
2392    let out = root.join(GENERATED_FILE);
2393    let Some(code) = &compiled.source else {
2394        let _ = std::fs::remove_file(&out);
2395        return Ok(());
2396    };
2397    crate::write_if_changed(&out, code)?;
2398    Ok(())
2399}
2400
2401/// Outputs in order, keyed by path: [`OutputSet::put`] replaces a path's
2402/// output (moving it to the end) without rescanning the list.
2403#[derive(Default)]
2404pub struct OutputSet {
2405    items: Vec<Option<Output>>,
2406    at: HashMap<String, usize>,
2407}
2408
2409impl OutputSet {
2410    pub fn get(&self, path: &str) -> Option<&[u8]> {
2411        let i = *self.at.get(path)?;
2412        self.items[i].as_ref().map(|(_, b)| b.as_slice())
2413    }
2414
2415    pub fn contains(&self, path: &str) -> bool {
2416        self.at.contains_key(path)
2417    }
2418
2419    pub fn paths(&self) -> impl Iterator<Item = &String> {
2420        self.at.keys()
2421    }
2422
2423    /// Sets `path`'s output, dropping the one it had.
2424    pub fn put(&mut self, path: String, bytes: Vec<u8>) {
2425        if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
2426            self.items[i] = None;
2427        }
2428        self.items.push(Some((path, bytes)));
2429    }
2430
2431    pub fn into_vec(self) -> Vec<Output> {
2432        self.items.into_iter().flatten().collect()
2433    }
2434}
2435
2436impl From<Vec<Output>> for OutputSet {
2437    fn from(outputs: Vec<Output>) -> Self {
2438        let mut set = OutputSet::default();
2439        for (path, bytes) in outputs {
2440            set.put(path, bytes);
2441        }
2442        set
2443    }
2444}
2445
2446/// A JSON object output at `path`: the one already in `outputs`, else the
2447/// mod's own file there, else empty.
2448fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
2449    let read;
2450    let bytes = match outputs.get(path) {
2451        Some(b) => b,
2452        None => match std::fs::read(root.join(path)) {
2453            Ok(b) => {
2454                read = b;
2455                &read
2456            }
2457            Err(_) => return Ok(Map::new()),
2458        },
2459    };
2460    serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
2461}
2462
2463/// Adds the content's outputs to `outputs` (the `.yres` ones, say): lang
2464/// entries and tags merged into what's there (or into the mod's own files),
2465/// the rest appended.
2466pub fn merge_outputs(
2467    root: &Path,
2468    outputs: &mut Vec<Output>,
2469    compiled: Compiled,
2470) -> Result<(), String> {
2471    let mut set = OutputSet::from(std::mem::take(outputs));
2472    let mut errors = Vec::new();
2473    // `.yres` texts under the keys the game looks up: in every lang file
2474    // that has the text.
2475    if !compiled.lang_aliases.is_empty() {
2476        let mut lang_files: BTreeSet<String> =
2477            set.paths().filter(|p| is_lang_path(p)).cloned().collect();
2478        if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
2479            for ns in dirs.flatten() {
2480                if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
2481                    for f in files.flatten() {
2482                        if let Ok(rel) = f.path().strip_prefix(root) {
2483                            let rel = rel.to_string_lossy().replace('\\', "/");
2484                            if is_lang_path(&rel) {
2485                                lang_files.insert(rel);
2486                            }
2487                        }
2488                    }
2489                }
2490            }
2491        }
2492        for path in lang_files {
2493            let mut map = match json_output(&set, root, &path) {
2494                Ok(m) => m,
2495                Err(e) => {
2496                    errors.push(e);
2497                    continue;
2498                }
2499            };
2500            let mut changed = false;
2501            for (from, to) in &compiled.lang_aliases {
2502                if let Some(text) = map.get(from).cloned()
2503                    && !map.contains_key(to)
2504                {
2505                    map.insert(to.clone(), text);
2506                    changed = true;
2507                }
2508            }
2509            if changed {
2510                set.put(path, pretty(&Value::Object(map)));
2511            }
2512        }
2513    }
2514    for ((ns, locale), entries) in &compiled.lang {
2515        let path = format!("assets/{ns}/lang/{locale}.json");
2516        let mut map = match json_output(&set, root, &path) {
2517            Ok(m) => m,
2518            Err(e) => {
2519                errors.push(e);
2520                continue;
2521            }
2522        };
2523        for (k, v) in entries {
2524            map.entry(k.clone())
2525                .or_insert_with(|| Value::String(v.clone()));
2526        }
2527        set.put(path, pretty(&Value::Object(map)));
2528    }
2529    for (path, ids) in &compiled.tags {
2530        // The mod's own tag file may use the pre-1.21 spelling; the loader
2531        // maps both to one file, so merge into whichever it has.
2532        let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
2533        let path = if !set.contains(path) && root.join(&plural).is_file() {
2534            plural
2535        } else {
2536            path.clone()
2537        };
2538        let mut map = match json_output(&set, root, &path) {
2539            Ok(m) => m,
2540            Err(e) => {
2541                errors.push(e);
2542                continue;
2543            }
2544        };
2545        let values = map
2546            .entry("values")
2547            .or_insert_with(|| Value::Array(Vec::new()));
2548        if let Value::Array(list) = values {
2549            for id in ids {
2550                if !list.iter().any(|v| v.as_str() == Some(id)) {
2551                    list.push(Value::String(id.clone()));
2552                }
2553            }
2554        }
2555        map.entry("replace").or_insert(Value::Bool(false));
2556        set.put(path, pretty(&Value::Object(map)));
2557    }
2558    for (path, bytes) in compiled.outputs {
2559        set.put(path, bytes);
2560    }
2561    *outputs = set.into_vec();
2562    if errors.is_empty() {
2563        Ok(())
2564    } else {
2565        Err(format!(
2566            "merging blocks/items into the mod's files:\n  {}",
2567            errors.join("\n  ")
2568        ))
2569    }
2570}
2571
2572fn is_lang_path(p: &str) -> bool {
2573    let parts: Vec<&str> = p.split('/').collect();
2574    parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
2575}
2576
2577#[cfg(test)]
2578mod tests {
2579    use super::*;
2580
2581    fn project(files: &[(&str, &str)]) -> PathBuf {
2582        use std::sync::atomic::{AtomicUsize, Ordering};
2583        static N: AtomicUsize = AtomicUsize::new(0);
2584        let dir = std::env::temp_dir().join(format!(
2585            "yog-content-test-{}-{}",
2586            std::process::id(),
2587            N.fetch_add(1, Ordering::SeqCst)
2588        ));
2589        let _ = std::fs::remove_dir_all(&dir);
2590        for (path, text) in files {
2591            let p = dir.join(path);
2592            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
2593            std::fs::write(p, text).unwrap();
2594        }
2595        dir
2596    }
2597
2598    fn output(c: &Compiled, path: &str) -> Value {
2599        let (_, b) = c
2600            .outputs
2601            .iter()
2602            .find(|(p, _)| p == path)
2603            .unwrap_or_else(|| {
2604                panic!(
2605                    "no {path} in {:?}",
2606                    c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
2607                )
2608            });
2609        serde_json::from_slice(b).unwrap()
2610    }
2611
2612    #[test]
2613    fn compiles_a_cube_block() {
2614        let root = project(&[
2615            ("resources/block/ruby_ore.png", "png"),
2616            (
2617                "content/blocks/ruby_ore.yblock",
2618                r#"{
2619  "name": "Ruby Ore",
2620  "block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
2621             "drops": { "item": "mymod:ruby", "count": [1, 3] } },
2622  "model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
2623}"#,
2624            ),
2625        ]);
2626        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2627        assert_eq!(c.blocks, 1);
2628        assert_eq!(
2629            output(&c, "assets/mymod/models/block/ruby_ore.json"),
2630            json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
2631        );
2632        assert_eq!(
2633            output(&c, "assets/mymod/blockstates/ruby_ore.json"),
2634            json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
2635        );
2636        assert_eq!(
2637            output(&c, "assets/mymod/models/item/ruby_ore.json"),
2638            json!({ "parent": "mymod:block/ruby_ore" })
2639        );
2640        assert_eq!(
2641            output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
2642            json!("mymod:item/ruby_ore")
2643        );
2644        let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
2645        assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
2646        assert_eq!(
2647            loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
2648            json!(3)
2649        );
2650        assert!(
2651            c.outputs
2652                .iter()
2653                .any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
2654        );
2655        let src = c.source.as_ref().unwrap();
2656        assert!(
2657            src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
2658            "{src}"
2659        );
2660        assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
2661        assert!(src.contains(".requires_tool()"), "{src}");
2662        assert!(
2663            src.contains("registry.register_block(Blocks::ruby_ore());"),
2664            "{src}"
2665        );
2666
2667        let mut outputs = Vec::new();
2668        merge_outputs(&root, &mut outputs, c).unwrap();
2669        let find = |p: &str| -> Value {
2670            serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
2671        };
2672        assert_eq!(
2673            find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
2674            json!("Ruby Ore")
2675        );
2676        assert_eq!(
2677            find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
2678            json!(["mymod:ruby_ore"])
2679        );
2680        assert_eq!(
2681            find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
2682            json!(["mymod:ruby_ore"])
2683        );
2684        let _ = std::fs::remove_dir_all(root);
2685    }
2686
2687    #[test]
2688    fn raw_parent_with_elements_gets_a_particle() {
2689        let root = project(&[(
2690            "content/cable.yblock",
2691            r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
2692                 "elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
2693        )]);
2694        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2695        let m = output(&c, "assets/mymod/models/block/cable.json");
2696        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2697        let _ = std::fs::remove_dir_all(root);
2698    }
2699
2700    #[test]
2701    fn compiles_elements_shape_and_connect() {
2702        let root = project(&[(
2703            "content/pipe.yblock",
2704            r##"{
2705  "block": { "render_layer": "cutout" },
2706  "model": {
2707    "textures": { "0": "minecraft:block/stone" },
2708    "elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
2709                    "rotation": { "axis": "y", "angle": 22.5 },
2710                    "faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
2711  },
2712  "states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
2713     "elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
2714}"##,
2715        )]);
2716        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2717        let m = output(&c, "assets/mymod/models/block/pipe.json");
2718        assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
2719        assert_eq!(m["render_type"], json!("minecraft:cutout"));
2720        assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
2721        let bs = output(&c, "assets/mymod/blockstates/pipe.json");
2722        assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
2723        assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
2724        let src = c.source.unwrap();
2725        assert!(
2726            src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
2727            "{src}"
2728        );
2729        assert!(src.contains(".connects_to_neighbors()"), "{src}");
2730        assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
2731        let _ = std::fs::remove_dir_all(root);
2732    }
2733
2734    #[test]
2735    fn reports_errors_with_lines() {
2736        let root = project(&[(
2737            "content/bad.yblock",
2738            "{\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}",
2739        )]);
2740        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2741        assert!(
2742            e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
2743            "{e}"
2744        );
2745        assert!(
2746            e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
2747            "{e}"
2748        );
2749        assert!(
2750            e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
2751            "{e}"
2752        );
2753        assert!(
2754            e.contains("content/bad.yblock:10: unknown key \"bogus\""),
2755            "{e}"
2756        );
2757        let _ = std::fs::remove_dir_all(root);
2758    }
2759
2760    #[test]
2761    fn compiles_recipes() {
2762        let root = project(&[
2763            ("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
2764            (
2765                "content/recipes/ruby_block.yrecipe",
2766                r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "mymod:ruby" }, "result": "minecraft:emerald_block" }"##,
2767            ),
2768            (
2769                "content/recipes/ruby.yrecipe",
2770                r##"{ "type": "shapeless", "ingredients": ["minecraft:emerald", "#c:dusts"], "result": "mymod:ruby", "count": 2 }"##,
2771            ),
2772            (
2773                "content/recipes/smelt_ruby.yrecipe",
2774                r##"{ "type": "smelting", "ingredient": "#c:ores", "result": "mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
2775            ),
2776            (
2777                "content/recipes/cut.yrecipe",
2778                r##"{ "type": "stonecutting", "ingredient": ["minecraft:stone", "minecraft:andesite"], "result": "mymod:ruby", "group": "rubies" }"##,
2779            ),
2780        ]);
2781        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2782        assert_eq!(c.recipes, 4);
2783        let src = c.source.clone().unwrap();
2784        for want in [
2785            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"#,
2786            r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
2787            r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
2788            r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
2789            r##"group: Some("rubies".into())"##,
2790        ] {
2791            assert!(src.contains(want), "missing {want}\n{src}");
2792        }
2793        let _ = std::fs::remove_dir_all(root);
2794    }
2795
2796    #[test]
2797    fn recipe_mistakes() {
2798        let bad = |recipe: &str| {
2799            let root = project(&[("content/r.yrecipe", recipe)]);
2800            let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2801            let _ = std::fs::remove_dir_all(root);
2802            e
2803        };
2804        let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "minecraft:stick" }, "result": "minecraft:stick" }"#);
2805        assert!(e.contains("pattern uses 'B'"), "{e}");
2806        let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "minecraft:stick" }"#);
2807        assert!(e.contains("write `minecraft:stick`"), "{e}");
2808        let e = bad(r##"{ "type": "smelting", "ingredient": "minecraft:sand", "result": "#c:glass" }"##);
2809        assert!(e.contains("must be an item, not a tag"), "{e}");
2810        let e = bad(r#"{ "type": "shapeless", "ingredients": ["minecraft:sand"], "result": "mymod:nothing" }"#);
2811        assert!(e.contains("no block or item mymod:nothing"), "{e}");
2812        let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["minecraft:sand"], "result": "minecraft:glass" }"#);
2813        assert!(e.contains("pattern is for shaped recipes"), "{e}");
2814    }
2815
2816    #[test]
2817    fn block_inventory_names_a_screen() {
2818        let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
2819        let root = project(&[
2820            ("content/bench.yblock", block),
2821            ("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
2822        ]);
2823        let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
2824        assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
2825        let _ = std::fs::remove_dir_all(root);
2826
2827        let root = project(&[("content/bench.yblock", block)]);
2828        let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
2829        assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
2830        let _ = std::fs::remove_dir_all(root);
2831    }
2832
2833    #[test]
2834    fn compiles_a_book() {
2835        let book = r#"{
2836            "id": "guide",
2837            "name": "@Book.title",
2838            "categories": [{ "id": "basics", "name": "Basics", "icon": "@item:minecraft:book" }],
2839            "entries": [{
2840                "id": "ruby", "name": "@item:mymod:ruby", "category": "@category:basics",
2841                "pages": [
2842                    { "type": "text", "text": "@@not a ref, {link=@entry:ruby|see}" },
2843                    { "type": "spotlight", "item": "@item:mymod:ruby" },
2844                    { "type": "entity", "entity": "@entity:minecraft:allay" }
2845                ]
2846            }]
2847        }"#;
2848        let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
2849        let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
2850        let mut refs = yres::Refs::default();
2851        refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
2852        let out = compile(&root, "mymod", &refs).unwrap();
2853        assert_eq!(out.books, 1);
2854        let src = out.source.unwrap();
2855        assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
2856        assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
2857        assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
2858        assert!(src.contains(r#"\"item\":\"mymod:ruby\""#) && src.contains(r#"\"category\":\"basics\""#), "references become ids: {src}");
2859
2860        // Bare ids, missing content and version-only ids are errors, each on its line.
2861        let bad = r#"{ "id": "x",
2862            "categories": [{ "id": "c", "name": "C" }],
2863            "entries": [{ "id": "e", "name": "E", "category": "c",
2864                "pages": [
2865                    { "type": "spotlight", "item": "minecraft:stick" },
2866                    { "type": "spotlight", "item": "@item:minecraft:short_grass" },
2867                    { "type": "recipe", "recipe": "@recipe:mymod:nope" },
2868                    { "type": "entity", "entity": "@entity:create:train" }
2869                ] }] }"#;
2870        let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2871        assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
2872        assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
2873        assert!(e.contains("not in 1.20.1"), "{e}");
2874        assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
2875        assert!(e.contains("[external.create]"), "{e}");
2876
2877        let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
2878        let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
2879        assert!(e.contains("not a book"), "{e}");
2880    }
2881
2882    #[test]
2883    fn compiles_a_recipe_of_the_mods_own_kind() {
2884        let r = r##"{ "type": "mymod:infusion", "slots": { "core": ["minecraft:diamond"], "ring": [["minecraft:coal", "minecraft:charcoal"], "#c:dusts"] },
2885                     "values": { "time": 200 }, "result": "minecraft:emerald", "count": 2 }"##;
2886        let c = compile(&project(&[("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
2887        let src = c.source.unwrap();
2888        assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
2889        assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
2890
2891        let bad = r#"{ "type": "mymod:infusion", "ingredient": "minecraft:stone", "result": "minecraft:emerald" }"#;
2892        let e = compile(&project(&[("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
2893        assert!(e.contains("takes \"slots\"") && e.contains("needs \"slots\""), "{e}");
2894        // Another mod's kind isn't the mod's own: not a Yog recipe type.
2895        let other = r#"{ "type": "create:mixing", "result": "minecraft:emerald" }"#;
2896        assert!(compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).is_err());
2897    }
2898
2899    #[test]
2900    fn compiles_an_item_with_resource_refs() {
2901        let root = project(&[(
2902            "content/items/ruby.yitem",
2903            r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
2904                 "item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
2905                 "model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
2906        )]);
2907        let mut refs = yres::Refs::default();
2908        refs.texts.insert(
2909            "Items.ruby".into(),
2910            ("item.mymod.ruby".into(), "Ruby".into()),
2911        );
2912        refs.texts.insert(
2913            "Strings.ruby_tip".into(),
2914            ("mymod.ruby_tip".into(), "Shiny".into()),
2915        );
2916        refs.images
2917            .insert("Textures.ruby".into(), "mymod:item/ruby".into());
2918        let c = compile(&root, "mymod", &refs).unwrap();
2919        assert_eq!(
2920            output(&c, "assets/mymod/models/item/ruby.json"),
2921            json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
2922        );
2923        let src = c.source.clone().unwrap();
2924        assert!(src.contains(".max_stack(16)"), "{src}");
2925        assert!(
2926            src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
2927            "{src}"
2928        );
2929        // The .yres lang file gets the tooltip under the key the game reads.
2930        let mut outputs = vec![(
2931            "assets/mymod/lang/ru_ru.json".to_string(),
2932            r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
2933                .as_bytes()
2934                .to_vec(),
2935        )];
2936        merge_outputs(&root, &mut outputs, c).unwrap();
2937        let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
2938        assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
2939        let _ = std::fs::remove_dir_all(root);
2940    }
2941
2942    #[test]
2943    fn namespace_needed_for_odd_mod_ids() {
2944        let root = project(&[(
2945            "content/x.yitem",
2946            r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
2947        )]);
2948        let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
2949        assert!(e.contains("give the namespace"), "{e}");
2950        let _ = std::fs::remove_dir_all(root);
2951    }
2952}