Skip to main content

yog_cli/
content.rs

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