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