Skip to main content

yog_cli/
content.rs

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