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