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//! - builds the mod's textures (`.ytexture`, see `textures`);
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 block_item;
23mod body;
24mod book;
25mod datapack;
26mod defs;
27mod import;
28pub mod json;
29mod looks;
30pub(crate) mod material;
31mod model;
32mod outputs;
33mod recipe;
34mod refs;
35mod source;
36mod table;
37mod textures;
38
39pub use import::import_command;
40pub use source::write_source;
41pub use outputs::merge_outputs;
42
43use yog_ids::Format;
44use crate::yres;
45use yog_data::Path as At;
46use json::num_value;
47use serde_json::{Map, Value, json};
48use std::collections::{BTreeMap, BTreeSet, HashMap};
49use std::path::{Path, PathBuf};
50use yog_data::Schema;
51use crate::content::block_item::compile_file;
52use crate::content::block_item::inventory_screens;
53use crate::content::body::{BodyCx, compile_body};
54use crate::content::body::compile_entity;
55use crate::content::body::compile_font;
56use crate::content::book::compile_book;
57use crate::content::book::compile_layout;
58use crate::content::book::recipe_layouts;
59use crate::content::defs::definition_path;
60use crate::content::defs::dimension_path;
61use crate::content::defs::loot_path;
62use crate::content::defs::read_defs;
63use crate::content::defs::tool_level_tags;
64use crate::content::outputs::version_overlays;
65use crate::content::recipe::compile_recipe;
66use crate::content::source::float;
67use crate::content::source::mentions_content_macro;
68use crate::content::source::registrations;
69use crate::content::source::{Content, rust_source};
70
71/// Where content files live, under the mod root.
72pub const CONTENT_DIR: &str = yog_ids::project::CONTENT;
73/// The generated Rust code, under the mod root.
74pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
75
76/// A generated file: path inside the `.yog` and its bytes.
77pub type Output = (String, Vec<u8>);
78
79/// The formats a content file under `content/` is written in.
80const CONTENT: [Format; 9] = [
81    Format::Block,
82    Format::Item,
83    Format::Recipe,
84    Format::Book,
85    Format::Def,
86    Format::Model,
87    Format::Anim,
88    Format::Entity,
89    Format::Font,
90];
91
92/// The content format of the file at `path`.
93fn content_format(path: &Path) -> Option<Format> {
94    Format::of_path(path).filter(|f| CONTENT.contains(f))
95}
96
97/// Everything `yog build` makes of a mod's content files.
98#[derive(Debug, Default)]
99pub struct Compiled {
100    /// Models, blockstates, item definitions, loot tables, textures.
101    pub outputs: Vec<Output>,
102    /// Lang entries: `(namespace, locale)` → key → text, merged into the
103    /// lang files by [`merge_outputs`].
104    lang: BTreeMap<(String, String), BTreeMap<String, String>>,
105    /// `.yres` text keys to copy under another key: (from, to).
106    lang_aliases: Vec<(String, String)>,
107    /// Tag path (`data/minecraft/tags/block/...json`) → its entries:
108    /// id (`#ns:tag` for a tag) → required (`false`: kept only if present).
109    tags: BTreeMap<String, BTreeMap<String, bool>>,
110    /// The generated Rust code; `None` when the mod has no content files.
111    pub source: Option<Source>,
112    /// Data the code includes (`include_bytes!`), by path under
113    /// `.yog-build/generated/`: the books, as Yog Data.
114    pub data_files: Vec<(String, Vec<u8>)>,
115    pub warnings: Vec<String>,
116    /// [`yog_registry::REGISTRATIONS_FILE`]: what the mod registers at the
117    /// game's start — packaged by dev builds only, for hot reload.
118    pub registrations: String,
119    /// The pictures (`.yres` images' files) an atlas took in: left out of the build.
120    pub packed: BTreeSet<String>,
121    pub blocks: usize,
122    pub items: usize,
123    pub recipes: usize,
124    pub books: usize,
125}
126
127// ── Vocabulary ───────────────────────────────────────────────────────────────
128
129pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
130pub const DISPLAY_SLOTS: [&str; 8] = [
131    "thirdperson_righthand",
132    "thirdperson_lefthand",
133    "firstperson_righthand",
134    "firstperson_lefthand",
135    "gui",
136    "head",
137    "ground",
138    "fixed",
139];
140pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
141
142/// A model template — `"parent": "cube_all"` — and the texture slots it takes.
143pub struct Template {
144    pub name: &'static str,
145    /// The vanilla model it compiles to.
146    pub parent: &'static str,
147    /// Its texture slots; for item sprites, `layer0` (more layers allowed).
148    pub slots: &'static [&'static str],
149    /// An item sprite template (`generated`, `handheld`) — item models only.
150    pub sprite: bool,
151    /// The parent model defines `particle` itself.
152    pub has_particle: bool,
153}
154
155pub const TEMPLATES: &[Template] = &[
156    Template {
157        name: "cube_all",
158        parent: "minecraft:block/cube_all",
159        slots: &["all"],
160        sprite: false,
161        has_particle: true,
162    },
163    Template {
164        name: "cube_column",
165        parent: "minecraft:block/cube_column",
166        slots: &["end", "side"],
167        sprite: false,
168        has_particle: true,
169    },
170    Template {
171        name: "cube_bottom_top",
172        parent: "minecraft:block/cube_bottom_top",
173        slots: &["top", "bottom", "side"],
174        sprite: false,
175        has_particle: true,
176    },
177    Template {
178        name: "cube",
179        parent: "minecraft:block/cube",
180        slots: &["down", "up", "north", "south", "west", "east"],
181        sprite: false,
182        has_particle: false,
183    },
184    Template {
185        name: "orientable",
186        parent: "minecraft:block/orientable",
187        slots: &["front", "side", "top"],
188        sprite: false,
189        has_particle: true,
190    },
191    Template {
192        name: "cross",
193        parent: "minecraft:block/cross",
194        slots: &["cross"],
195        sprite: false,
196        has_particle: true,
197    },
198    Template {
199        name: "generated",
200        parent: "minecraft:item/generated",
201        slots: &["layer0"],
202        sprite: true,
203        has_particle: true,
204    },
205    Template {
206        name: "handheld",
207        parent: "minecraft:item/handheld",
208        slots: &["layer0"],
209        sprite: true,
210        has_particle: true,
211    },
212];
213
214pub fn template(name: &str) -> Option<&'static Template> {
215    TEMPLATES.iter().find(|t| t.name == name)
216}
217
218/// `[a-z0-9_.-]+` — a Minecraft namespace.
219pub fn valid_namespace(s: &str) -> bool {
220    !s.is_empty()
221        && s.bytes().all(|b| {
222            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
223        })
224}
225
226/// `[a-z0-9_./-]+` — a Minecraft resource path.
227pub fn valid_path(s: &str) -> bool {
228    !s.is_empty()
229        && !s.starts_with('/')
230        && !s.ends_with('/')
231        && s.bytes().all(|b| {
232            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
233        })
234}
235
236/// `ns:path` (or `path`, in `minecraft`) as a valid resource id.
237pub fn valid_id(s: &str) -> bool {
238    match s.split_once(':') {
239        Some((ns, path)) => valid_namespace(ns) && valid_path(path),
240        None => valid_path(s),
241    }
242}
243
244// ── Finding ──────────────────────────────────────────────────────────────────
245
246/// Every content file (`CONTENT`) under `content/` of the mod at `root`, sorted.
247pub fn find(root: &Path) -> Vec<PathBuf> {
248    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
249        let Ok(entries) = std::fs::read_dir(dir) else {
250            return;
251        };
252        for entry in entries.flatten() {
253            let path = entry.path();
254            if path.is_dir() {
255                walk(&path, out);
256            } else if content_format(&path).is_some() {
257                out.push(path);
258            }
259        }
260    }
261    let mut out = Vec::new();
262    walk(&root.join(CONTENT_DIR), &mut out);
263    out.sort();
264    out
265}
266
267fn display(root: &Path, p: &Path) -> String {
268    p.strip_prefix(root)
269        .unwrap_or(p)
270        .to_string_lossy()
271        .replace('\\', "/")
272}
273
274// ── Compiling ────────────────────────────────────────────────────────────────
275
276#[derive(Debug, Clone, Default)]
277struct BlockProps {
278    hardness: Option<f64>,
279    resistance: Option<f64>,
280    sound: Option<String>,
281    light: u8,
282    /// The tool tags it joins (`minecraft:mineable/pickaxe`, a mod's own).
283    tools: Vec<String>,
284    /// The tool level it needs (`minecraft:iron`, a mod's `.ydef` level) and
285    /// the line that says so.
286    tool_level: Option<(String, usize)>,
287    requires_tool: bool,
288    no_collision: bool,
289    slipperiness: Option<f64>,
290    /// `None` = full cube.
291    shape: Option<[f64; 6]>,
292    connect_groups: Vec<String>,
293    connects: bool,
294    /// `""`, `"horizontal"` or `"all"`.
295    facing: String,
296    /// `"toward"` or `"away"`.
297    placement: String,
298    attach: bool,
299    waterlogged: bool,
300    /// Its own properties: name, values.
301    properties: Vec<(String, Vec<String>)>,
302    /// Yes/no properties: kept on by redstone; read by a comparator.
303    powered: Option<String>,
304    comparator: Option<String>,
305    /// Hoppers and droppers can put items into it.
306    accepts_items: bool,
307    /// The inventory screen it opens (a `.yui` `<Screen>` with `<Slot>`s).
308    inventory: Option<String>,
309    /// Gives off redstone power, set by the mod per position.
310    redstone: bool,
311    /// Doesn't hide the faces behind it or stop light.
312    no_occlusion: bool,
313    /// One of the game's kinds of block (`stairs`…); `""`: Yog's own.
314    kind: String,
315    /// What a kind is made from, its set, a button's press and who sets it off.
316    base: Option<String>,
317    set: Option<String>,
318    press_ticks: Option<u32>,
319    mobs_only: bool,
320}
321
322#[derive(Debug, Clone, Default)]
323struct ItemProps {
324    stack_size: Option<u8>,
325    durability: u32,
326    fire_resistant: bool,
327    fuel_ticks: u32,
328    /// (nutrition, saturation, always_edible)
329    food: Option<(u32, f64, bool)>,
330    /// (kind, material, attack damage, attack speed)
331    tool: Option<(String, String, f32, f32)>,
332    /// (slot, material)
333    armor: Option<(String, String)>,
334    /// Worn in this slot without being armor.
335    wearable: Option<String>,
336    /// The hanging entity it places, and its size in blocks.
337    hangs: Option<(String, Option<[u32; 2]>)>,
338}
339
340/// One checked content file — what the Rust code needs.
341#[derive(Debug, Clone)]
342struct Entry {
343    kind: Format,
344    id: String,
345    file: String,
346    auto: bool,
347    /// Mods it needs (`requires`): registered only when they're all loaded.
348    requires: Vec<String>,
349    block: BlockProps,
350    item: ItemProps,
351    potion: Option<yog_registry::PotionDef>,
352}
353
354/// Per-file checking state.
355struct Ctx<'a> {
356    root: &'a Path,
357    refs: &'a yres::Refs,
358    file: String,
359    ns: String,
360    errors: &'a mut Vec<String>,
361    warnings: &'a mut Vec<String>,
362    /// The mod's own textures' ids (`.ytexture` files).
363    own_textures: &'a BTreeSet<String>,
364    /// `block.inventory` screen ids named so far: (file, line, id) —
365    /// checked against the mod's screens once every file is read.
366    inventories: &'a mut Vec<(String, usize, String)>,
367    /// References a file makes (a block's drops): (file, line, path, ref) —
368    /// checked once every file is read.
369    deferred: &'a mut Vec<(String, usize, String, yog_data::Ref)>,
370}
371
372impl Ctx<'_> {
373    fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
374        self.errors.push(format!("{}:{line}: {msg}", self.file));
375    }
376
377    fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
378        self.warnings.push(format!("{}:{line}: {msg}", self.file));
379    }
380
381    /// A problem with the value at `path`.
382    fn err_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
383        let line = loc(path);
384        self.err(line, format!("{path}: {msg}"));
385    }
386
387    fn warn_at(&mut self, loc: Loc, path: &At, msg: impl std::fmt::Display) {
388        let line = loc(path);
389        self.warn(line, format!("{path}: {msg}"));
390    }
391
392    /// Reports what reading a file through its schema found.
393    fn schema_errors(&mut self, errors: yog_data::Errors) {
394        for e in errors.0 {
395            let l = e.at.map_or(1, |a| a.line as usize);
396            if e.path.is_root() {
397                self.err(l, e.message)
398            } else {
399                self.err(l, format!("{}: {}", e.path, e.message))
400            }
401        }
402    }
403
404    /// A texture → the model's form: `#slot`, or a texture id — the mod's
405    /// own must be one of its `.ytexture`s.
406    fn texture(&mut self, loc: Loc, path: &At, t: &yog_formats::content::Texture) -> Option<String> {
407        use yog_formats::content::{SLOT_MARK, Texture};
408        match t {
409            Texture::Slot(slot) => Some(format!("{SLOT_MARK}{slot}")),
410            Texture::Ref(id) => {
411                let (ns, tex) = yog_ids::split(id);
412                if ns == self.ns && !self.own_textures.contains(id) {
413                    self.err_at(loc, path, format!("`{}{id}`: no {}/{tex}.{}", yog_ids::reference::TEXTURE, yres::RESOURCES_DIR, Format::Texture.ext()));
414                    return None;
415                }
416                Some(id.clone())
417            }
418        }
419    }
420}
421
422/// Where a path of the file being read is: its line.
423type Loc<'a> = &'a dyn Fn(&At) -> usize;
424
425/// A checked model: Minecraft's JSON, its bounding box.
426struct Model {
427    json: Value,
428    /// Elements' bounding box in pixels, when it has elements.
429    bounds: Option<[f64; 6]>,
430}
431
432fn pretty(v: &Value) -> Vec<u8> {
433    let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
434    s.push('\n');
435    s.into_bytes()
436}
437
438/// What a recipe takes.
439#[derive(Debug, Clone)]
440enum RecipeKind {
441    /// A crafting grid pattern: rows of key characters (space = empty);
442    /// each key any of several items.
443    Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
444    /// Crafting ingredients in any arrangement.
445    Shapeless(Vec<Vec<String>>),
446    Cooking { method: yog_registry::Cooking, input: Vec<String>, experience: f64, cook_time: u32 },
447    Stonecutting(Vec<String>),
448    Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
449    /// The mod's own kind of recipe (`mymod:infusion`), for its machines
450    /// and books — not the game's: named slots, each a list of ingredients
451    /// (an ingredient any of several items), and values (`time`, `energy`…).
452    Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
453}
454
455/// One checked `.ybook`.
456struct BookFile {
457    id: String,
458    /// The book as the runtime reads it (`yog_formats::book::Book`'s schema), as
459    /// binary Yog Data: the mod hands these bytes over as they are.
460    data: Vec<u8>,
461}
462
463/// One checked `.yentity`.
464struct EntityFile {
465    id: String,
466    file: String,
467    /// Its model and clips, ids with their namespace.
468    model: Option<String>,
469    animations: Option<String>,
470    /// `yog_registry::EntityDef`, as binary Yog Data.
471    data: Vec<u8>,
472}
473
474impl EntityFile {
475    fn path(&self) -> String {
476        format!("entities/{}.ydata", self.id.replace([':', '/'], "__"))
477    }
478}
479
480/// One checked `.ymodel` or `.yanim`.
481struct BodyFile {
482    model: bool,
483    /// `<mod>:<path under content/ without the extension>`.
484    id: String,
485    /// As `yog_body` reads it: binary Yog Data.
486    data: Vec<u8>,
487}
488
489/// One checked `.yfont`.
490struct FontFile {
491    /// `<mod>:<path under content/ without the extension>`.
492    id: String,
493    /// The built `.yfont`, as binary Yog Data.
494    data: Vec<u8>,
495}
496
497impl FontFile {
498    fn path(&self) -> String {
499        format!("fonts/{}.yfont", self.id.replace([':', '/'], "__"))
500    }
501}
502
503impl BodyFile {
504    /// Its file under `.yog-build/generated/`.
505    fn path(&self) -> String {
506        let dir = if self.model { "models" } else { "anims" };
507        format!("body/{dir}/{}.ydata", self.id.replace([':', '/'], "__"))
508    }
509}
510
511impl BookFile {
512    /// Its file under `.yog-build/generated/`.
513    fn path(&self) -> String {
514        format!("books/{}.ydata", self.id.replace(':', "__"))
515    }
516}
517
518/// The mod's lang: (namespace, locale) → key → text.
519type Lang = BTreeMap<(String, String), BTreeMap<String, String>>;
520
521/// A deferred check of a tag of content `<kinds>`: `tag:item`, `tag:block|item`.
522const TAG_REF: &str = "tag:";
523
524/// What an ore makes: its files, and the biome feature (feature, biomes, step) for Fabric's host.
525type OreOut = (Vec<Output>, (String, String, String));
526
527/// One checked `.yrecipe`.
528#[derive(Debug, Clone)]
529struct Recipe {
530    id: String,
531    /// Its `type`: `shaped`, `smelting`, …, or the mod's own kind.
532    ty: String,
533    kind: RecipeKind,
534    result: String,
535    count: u32,
536    group: Option<String>,
537    category: Option<String>,
538    /// Mods it needs (`requires`): registered only when they're all loaded.
539    requires: Vec<String>,
540}
541
542impl Recipe {
543    /// The `registry.add_recipe(...)` call registering it.
544    fn registration(&self) -> String {
545        let list = |items: &[String]| {
546            let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
547            format!("vec![{}]", quoted.join(", "))
548        };
549        let kind = match &self.kind {
550            RecipeKind::Shaped { pattern, key } => {
551                let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
552                let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
553                format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
554            }
555            RecipeKind::Shapeless(ingredients) => {
556                let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
557                format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
558            }
559            RecipeKind::Cooking { method, input, experience, cook_time } => format!(
560                "Cooking {{ method: ::yog_api::Cooking::{method:?}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
561                list(input),
562                float(*experience)
563            ),
564            RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
565            RecipeKind::Custom { kind, slots, values } => {
566                // Yog's recipe JSON as is: the host keeps it out of the game's data.
567                let mut o = Map::new();
568                o.insert("type".into(), kind.clone().into());
569                o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
570                if !values.is_empty() {
571                    o.insert("values".into(), Value::Object(values.clone()));
572                }
573                o.insert("result".into(), self.result.clone().into());
574                if self.count != 1 {
575                    o.insert("count".into(), self.count.into());
576                }
577                if let Some(g) = &self.group {
578                    o.insert("group".into(), g.clone().into());
579                }
580                return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
581            }
582            RecipeKind::Smithing { template, base, addition } => format!(
583                "Smithing {{ template: {}, base: {}, addition: {} }}",
584                list(template),
585                list(base),
586                list(addition)
587            ),
588        };
589        let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
590        format!(
591            "registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
592            self.id,
593            self.result,
594            self.count,
595            opt(&self.group),
596            opt(&self.category)
597        )
598    }
599}
600
601/// What the `.ydef`s define: every ladder, its levels bottom to top (the
602/// game's tool ladder and the mod's), and the mod's sound groups.
603struct Defs {
604    order: BTreeMap<String, Vec<String>>,
605    sound_groups: Vec<yog_formats::def::SoundGroup>,
606    tool_materials: Vec<yog_formats::def::ToolMaterial>,
607    armor_materials: Vec<yog_formats::def::ArmorMaterial>,
608    /// Placed features the mod adds to biomes (its ores), for the hosts that add them in code.
609    biome_features: Vec<(String, String, String)>,
610    /// Its dimensions' ids (their data in `generated/dimensions/`).
611    dimensions: Vec<String>,
612    /// Its tables (`kind: "table"`), each an enum in its code.
613    tables: Vec<table::Table>,
614    /// Its loot modifiers' ids (their data in `generated/loot/`).
615    loot_modifiers: Vec<String>,
616    /// Its attributes, effects and potions: (kind, id).
617    definitions: Vec<(&'static str, String)>,
618    /// Its advancements' ids (their data in the datapack).
619    advancements: Vec<String>,
620}
621
622impl Defs {
623    /// A level's ladder and place on it.
624    fn level(&self, id: &str) -> Option<(&str, usize)> {
625        self.order.iter().find_map(|(l, levels)| levels.iter().position(|x| x == id).map(|i| (l.as_str(), i)))
626    }
627}
628
629/// Compiles the mod's content files (see the module docs). Any error fails
630/// the whole thing, listing every problem.
631pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
632    let mut out = Compiled::default();
633    let files = find(root);
634    if files.is_empty() {
635        return Ok(out);
636    }
637    let mod_ns = valid_namespace(mod_id).then_some(mod_id);
638    let mut errors: Vec<String> = Vec::new();
639    let mut warnings: Vec<String> = Vec::new();
640    let own_textures = textures::compile(root, mod_id, refs, &mut errors);
641    let mut generated: BTreeMap<String, String> = BTreeMap::new();
642    let mut entries: Vec<Entry> = Vec::new();
643    let mut ids: HashMap<String, String> = HashMap::new();
644    let mut recipes: Vec<Recipe> = Vec::new();
645    let mut recipe_ids: HashMap<String, String> = HashMap::new();
646    let mut books: Vec<BookFile> = Vec::new();
647    let mut book_ids: HashMap<String, String> = HashMap::new();
648    let mut bodies: Vec<BodyFile> = Vec::new();
649    let mut fonts: Vec<FontFile> = Vec::new();
650    let mut font_ids: HashMap<String, String> = HashMap::new();
651    let mut entity_files: Vec<EntityFile> = Vec::new();
652    let mut body_ids: HashMap<(bool, String), String> = HashMap::new();
653    let mut inventories: Vec<(String, usize, String)> = Vec::new();
654    let mut pending_books: Vec<(String, String)> = Vec::new();
655    let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
656    let mut pending_layouts: Vec<(String, String)> = Vec::new();
657    let mut pending_defs: Vec<(String, String)> = Vec::new();
658    let mut deferred: Vec<(String, usize, String, yog_data::Ref)> = Vec::new();
659    for path in &files {
660        let shown = display(root, path);
661        let Some(kind) = content_format(path) else { continue };
662        let text = match std::fs::read_to_string(path) {
663            Ok(t) => t,
664            Err(e) => {
665                errors.push(format!("{shown}: {e}"));
666                continue;
667            }
668        };
669        if matches!(kind, Format::Model | Format::Anim) {
670            if let Some(b) = compile_body(&BodyCx { root, ns: mod_ns.unwrap_or(yog_ids::ns::MINECRAFT), own_textures: &own_textures.ids }, &shown, &text, kind, path, &mut errors) {
671                if let Some(other) = body_ids.insert((kind == Format::Model, b.id.clone()), shown.clone()) {
672                    errors.push(format!("{shown}: {} {} is also defined by {other}", kind.noun(), b.id));
673                }
674                bodies.push(b);
675            }
676            continue;
677        }
678        if kind == Format::Font {
679            if let Some(font) = compile_font(&shown, &text, root, path, mod_ns.unwrap_or("minecraft"), &mut errors) {
680                if let Some(other) = font_ids.insert(font.id.clone(), shown.clone()) {
681                    errors.push(format!("{shown}: font {} is also defined by {other}", font.id));
682                }
683                fonts.push(font);
684            }
685            continue;
686        }
687        if kind == Format::Book {
688            // Checked once every block, item and recipe is known.
689            pending_books.push((shown.clone(), text));
690            continue;
691        }
692        if kind == Format::Recipe {
693            // Checked once every block and item is known.
694            pending_recipes.push((shown.clone(), text, path.clone()));
695            continue;
696        }
697        if kind == Format::Def {
698            // Kinds of recipe are checked with the recipes, ores and dimensions with what they name.
699            let of = yog_data::text::parse_value(&text).ok().and_then(|v| v.get("kind").and_then(|k| k.as_str()).map(str::to_string));
700            if matches!(of.as_deref(), Some(yog_ids::kind::RECIPE_KIND | yog_ids::kind::ORE | yog_ids::kind::DIMENSION | yog_ids::kind::TABLE | yog_ids::kind::TAG | yog_ids::kind::DAMAGE_TYPE | yog_ids::kind::TREE | yog_ids::kind::LOOT_MODIFIER | yog_ids::kind::LOOT_TABLE | yog_ids::kind::ATTRIBUTE | yog_ids::kind::EFFECT | yog_ids::kind::ADVANCEMENT)) {
701                pending_layouts.push((shown.clone(), text));
702            } else {
703                pending_defs.push((shown.clone(), text));
704            }
705            continue;
706        }
707        let mut cx = Ctx {
708            root,
709            refs,
710            file: shown.clone(),
711            ns: mod_ns.unwrap_or("minecraft").to_string(),
712            errors: &mut errors,
713            warnings: &mut warnings,
714            own_textures: &own_textures.ids,
715            inventories: &mut inventories,
716            deferred: &mut deferred,
717        };
718        if kind == Format::Entity {
719            if let Some(e) = compile_entity(&mut cx, &text, path, mod_ns, &mut out) {
720                if let Some(other) = ids.insert(e.id.clone(), shown.clone()) {
721                    errors.push(format!("{shown}: id {} is also defined by {other}", e.id));
722                }
723                entity_files.push(e);
724            }
725            continue;
726        }
727        if let Some(entry) = compile_file(&mut cx, &text, kind, path, mod_ns, &mut out, &mut generated)
728        {
729            if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
730                errors.push(format!(
731                    "{shown}: id {} is also defined by {other}",
732                    entry.id
733                ));
734            }
735            entries.push(entry);
736        }
737    }
738    let mut potion_defs: BTreeMap<String, yog_registry::PotionDef> = entries
739        .iter()
740        .filter_map(|e| e.potion.clone().map(|p| (e.id.clone(), p)))
741        .collect();
742    entries.retain(|e| e.potion.is_none());
743    // Books name the mod's content, vanilla's and other mods' declared ids.
744    let manifest = crate::manifest::YogToml::read(&root.join(yog_ids::file::MANIFEST)).ok();
745    let externals = manifest.as_ref().map(|t| t.externals.clone()).unwrap_or_default();
746    let datapack = manifest.as_ref().and_then(|t| t.datapack.clone());
747    let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals, datapack.as_deref());
748    for e in &entries {
749        known.add(if e.kind == Format::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
750    }
751    for id in potion_defs.keys() {
752        known.add(refs::RefKind::Item, id.clone());
753    }
754    for e in &entity_files {
755        known.add(refs::RefKind::Entity, e.id.clone());
756        // A model or clips of this mod must be among its `.ymodel`/`.yanim`.
757        for (wanted, model, what) in [(&e.model, true, "model"), (&e.animations, false, "animations")] {
758            if let Some(id) = wanted
759                && mod_ns.is_some_and(|ns| id.starts_with(&format!("{ns}:")))
760                && !bodies.iter().any(|b| b.model == model && &b.id == id)
761            {
762                errors.push(format!("{}: {what} `{id}`: no such file in content/ (`.{}`)", e.file, if model { Format::Model } else { Format::Anim }.ext()));
763            }
764        }
765    }
766    // Kinds of recipe (`.ydef`): what they name checked; a kind declared
767    // twice is a mistake.
768    let mut recipe_defs: HashMap<String, yog_formats::def::RecipeKindDef> = HashMap::new();
769    let mut ores: Vec<OreOut> = Vec::new();
770    let mut dimensions: Vec<(String, Vec<u8>)> = Vec::new();
771    let mut tables: Vec<table::Table> = Vec::new();
772    // Loot modifiers: (id, their data).
773    let mut loot: Vec<(String, Vec<u8>)> = Vec::new();
774    // Attributes, effects, potions: (kind, id, their data).
775    let mut definitions: Vec<(&'static str, String, Vec<u8>)> = Vec::new();
776    let mut brews: Vec<(String, yog_registry::Brew)> = Vec::new();
777    let mut advancements: Vec<String> = Vec::new();
778    for (shown, text) in &pending_layouts {
779        let mut cx = Ctx {
780            root,
781            refs,
782            file: shown.clone(),
783            ns: mod_ns.unwrap_or("minecraft").to_string(),
784            errors: &mut errors,
785            warnings: &mut warnings,
786            own_textures: &own_textures.ids,
787            inventories: &mut inventories,
788            deferred: &mut deferred,
789        };
790        let Some(k) = compile_layout(&mut cx, text, mod_ns, &known, &mut ores, &mut dimensions, &mut tables, &mut loot, &mut definitions, &mut advancements, &mut out) else { continue };
791        if recipe_defs.contains_key(&k.id) {
792            errors.push(format!("{shown}: recipe kind {} is declared twice", k.id));
793        }
794        recipe_defs.insert(k.id.clone(), k);
795    }
796    // How kinds of recipe are shown: `.yui` `recipe_layout` components.
797    let layouts = recipe_layouts(root, mod_ns.unwrap_or("minecraft"), &recipe_defs, &known, &mut errors);
798    for (shown, text, path) in &pending_recipes {
799        let mut cx = Ctx {
800            root,
801            refs,
802            file: shown.clone(),
803            ns: mod_ns.unwrap_or("minecraft").to_string(),
804            errors: &mut errors,
805            warnings: &mut warnings,
806            own_textures: &own_textures.ids,
807            inventories: &mut inventories,
808            deferred: &mut deferred,
809        };
810        if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known, &recipe_defs, &mut brews) {
811            if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
812                errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
813            }
814            recipes.push(r);
815        }
816    }
817    for r in &recipes {
818        known.add(refs::RefKind::Recipe, r.id.clone());
819    }
820    for (to, brew) in brews {
821        if !potion_defs.contains_key(&brew.from) && !refs::is_vanilla_potion(&brew.from) {
822            errors.push(format!("brewing recipe input `{}`: no potion `.yitem` or Yog default potion alias defines it", brew.from));
823            continue;
824        }
825        if let Some(potion) = potion_defs.get_mut(&to) {
826            potion.brewed.push(brew);
827        } else if refs::is_vanilla_potion(&to) {
828            potion_defs
829                .entry(to.clone())
830                .or_insert_with(|| yog_registry::PotionDef { id: to, name: None, effects: Vec::new(), brewed: Vec::new() })
831                .brewed
832                .push(brew);
833        } else {
834            errors.push(format!("brewing recipe result `{to}`: no potion `.yitem` or Yog default potion alias defines it"));
835        }
836    }
837    // Definitions: the ladders and their levels, sound groups.
838    let mut defs = read_defs(&pending_defs, mod_ns, &mut errors);
839    definitions.extend(potion_defs.into_iter().map(|(id, potion)| (yog_ids::kind::POTION, id, yog_data::binary::encode(&potion.write()))));
840    for (files, feature) in ores {
841        out.outputs.extend(files);
842        defs.biome_features.push(feature);
843    }
844    // What blocks and items name (drops, levels): known now.
845    for (file, line, path, r) in &deferred {
846        let found = match r.kind.as_str() {
847            yog_ids::kind::LEVEL => defs.level(&r.id).map(|_| ()).ok_or_else(|| format!("no level {} — the game's tool levels or a .ydef's", r.id)),
848            yog_ids::kind::LADDER => defs.order.contains_key(&r.id).then_some(()).ok_or_else(|| format!("no ladder {}", r.id)),
849            yog_ids::kind::TOOL_MATERIAL => (defs.tool_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ToolMaterial::built_in(&r.id).is_some())
850                .then_some(())
851                .ok_or_else(|| format!("no tool material {} — the game's or a .ydef's", r.id)),
852            yog_ids::kind::ARMOR_MATERIAL => (defs.armor_materials.iter().any(|m| m.id == r.id) || yog_formats::def::ArmorMaterial::built_in(&r.id).is_some())
853                .then_some(())
854                .ok_or_else(|| format!("no armor material {} — the game's or a .ydef's", r.id)),
855            yog_ids::kind::SOUND_GROUP => (defs.sound_groups.iter().any(|g| g.id == r.id) || yog_formats::def::SoundGroup::built_in(&r.id).is_some())
856                .then_some(())
857                .ok_or_else(|| format!("no sound group {} — the game's or a .ydef's", r.id)),
858            kind if kind.starts_with(TAG_REF) => {
859                // `tag:block|item`: a tag of either.
860                let mut found = kind[TAG_REF.len()..].split('|').map(|k| known.check_tag(k, &r.id));
861                let first = found.next().unwrap_or(Ok(()));
862                if first.is_ok() || found.any(|f| f.is_ok()) { Ok(()) } else { first }
863            }
864            kind => match refs::RefKind::from_word(kind) {
865                Some(k) => known.check(k, &r.id),
866                None => Ok(()),
867            },
868        };
869        if let Err(e) = found {
870            errors.push(format!("{file}:{line}: {path}: {e}"));
871        }
872    }
873    for e in &entries {
874        if let Some((level, line)) = &e.block.tool_level {
875            tool_level_tags(&e.id, level, &defs, &mut out, &mut |m| errors.push(format!("{}:{line}: block.tool_level: {m}", e.file)), &mut |m| warnings.push(format!("{}:{line}: block.tool_level: {m}", e.file)));
876        }
877    }
878    let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
879    // The mod's own kinds of book page (`.yui` components with `book_page`).
880    let page_types = crate::book_page::find(root, mod_ns.unwrap_or("minecraft"), &mut errors);
881    for (shown, text) in &pending_books {
882        let mut cx = Ctx {
883            root,
884            refs,
885            file: shown.clone(),
886            ns: mod_ns.unwrap_or("minecraft").to_string(),
887            errors: &mut errors,
888            warnings: &mut warnings,
889            own_textures: &own_textures.ids,
890            inventories: &mut inventories,
891            deferred: &mut deferred,
892        };
893        if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds, &page_types) {
894            if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
895                errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
896            }
897            books.push(b);
898        }
899    }
900    // What recipes name from this mod must be its blocks and items (vanilla
901    // and other mods' ids can't be checked here).
902    if let Some(ns) = mod_ns {
903        let screens = inventory_screens(&root.join("src"));
904        for (file, line, id) in &inventories {
905            if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
906                errors.push(format!(
907                    "{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
908                ));
909            }
910        }
911    }
912    out.outputs.extend(own_textures.outputs);
913    out.packed = own_textures.packed;
914    // Armor's worn look where 1.21.4+ looks for it.
915    for m in &defs.armor_materials {
916        looks::equipment(root, &mut out, &m.texture, &mut warnings);
917    }
918    out.warnings = warnings;
919    if !errors.is_empty() {
920        return Err(format!(
921            "invalid blocks/items in content/:\n  {}",
922            errors.join("\n  ")
923        ));
924    }
925    out.blocks = entries.iter().filter(|e| e.kind == Format::Block).count();
926    out.items = entries.len() - out.blocks;
927    out.recipes = recipes.len();
928    out.books = books.len();
929    if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() || !bodies.is_empty() || !entity_files.is_empty() || !fonts.is_empty() || !defs.sound_groups.is_empty() || !defs.tool_materials.is_empty() || !defs.armor_materials.is_empty() || !defs.biome_features.is_empty() || !dimensions.is_empty() || !loot.is_empty() || !definitions.is_empty())
930        && !mentions_content_macro(&root.join("src"))
931    {
932        out.warnings.push(format!(
933            "{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
934            CONTENT_DIR
935        ));
936    }
937    out.data_files = books.iter().map(|b| (b.path(), b.data.clone())).collect();
938    out.data_files.extend(bodies.iter().map(|b| (b.path(), b.data.clone())));
939    out.data_files.extend(fonts.iter().map(|f| (f.path(), f.data.clone())));
940    out.data_files.extend(entity_files.iter().map(|e| (e.path(), e.data.clone())));
941    out.data_files.extend(dimensions.iter().map(|(id, data)| (dimension_path(id), data.clone())));
942    out.data_files.extend(loot.iter().map(|(id, data)| (loot_path(id), data.clone())));
943    out.data_files.extend(definitions.iter().map(|(kind, id, data)| (definition_path(kind, id), data.clone())));
944    defs.definitions = definitions.iter().map(|(kind, id, _)| (*kind, id.clone())).collect();
945    defs.loot_modifiers = loot.into_iter().map(|(id, _)| id).collect();
946    defs.advancements = advancements;
947    // A tool level above netherite gets a tag of what its tools can't mine.
948    for m in &defs.tool_materials {
949        if yog_ids::split(&m.incorrect).0 != yog_ids::ns::MINECRAFT {
950            out.tags.entry(datapack::overlay(&yog_registry::ToolLadder::game().incorrect_when, &datapack::tag_file(yog_ids::kind::BLOCK, &m.incorrect))).or_default();
951        }
952    }
953    defs.dimensions = dimensions.iter().map(|(id, _)| id.clone()).collect();
954    defs.tables = tables;
955    out.source = Some(rust_source(&Content {
956        entries: &entries,
957        recipes: &recipes,
958        books: &books,
959        bodies: &bodies,
960        entities: &entity_files,
961        fonts: &fonts,
962        defs: &defs,
963        textures: &own_textures.ids,
964    }));
965    version_overlays(&mut out, &known.datapack);
966    out.registrations = registrations(&entries, &entity_files, &dimensions, &defs);
967    Ok(out)
968}
969
970/// The generated files' header.
971const GENERATED_HEADER: &str = "// <auto-generated>\n//     Generated by `yog build` from content/** — don't edit, it's rewritten\n//     on every build. Included by `yog_api::content!()`.\n// </auto-generated>\n";
972
973/// Where the content's code goes besides the root file, under
974/// `.yog-build/generated/`: one file a part (`items.g.rs`, `register.g.rs`, …).
975pub const GENERATED_PARTS_DIR: &str = "content";
976
977/// The content's code: the root file ([`GENERATED_FILE`]), which only
978/// includes the parts, and the parts — (name, code) each.
979#[derive(Debug, Clone, Default)]
980pub struct Source {
981    pub root: String,
982    pub parts: Vec<(String, String)>,
983}
984
985impl Source {
986    /// Part `name`'s code (`"items"`).
987    #[cfg(test)]
988    pub fn part(&self, name: &str) -> &str {
989        self.parts.iter().find(|(n, _)| n == name).map_or("", |(_, c)| c.as_str())
990    }
991
992    /// Every part's code, for tests that look for a line wherever it is.
993    #[cfg(test)]
994    pub fn all(&self) -> String {
995        self.parts.iter().map(|(_, c)| c.as_str()).collect()
996    }
997}
998
999/// Where the mod's tags are listed for tools (`#ns:path` → its entries, by
1000/// kind) — the IDE shows a tag's items from here, as the build makes them.
1001pub const TAGS_FILE: &str = ".yog-build/generated/tags.json";
1002
1003/// Outputs in order, keyed by path: [`OutputSet::put`] replaces a path's
1004/// output (moving it to the end) without rescanning the list.
1005#[derive(Default)]
1006pub struct OutputSet {
1007    items: Vec<Option<Output>>,
1008    at: HashMap<String, usize>,
1009}
1010
1011impl OutputSet {
1012    pub fn get(&self, path: &str) -> Option<&[u8]> {
1013        let i = *self.at.get(path)?;
1014        self.items[i].as_ref().map(|(_, b)| b.as_slice())
1015    }
1016
1017    pub fn contains(&self, path: &str) -> bool {
1018        self.at.contains_key(path)
1019    }
1020
1021    pub fn paths(&self) -> impl Iterator<Item = &String> {
1022        self.at.keys()
1023    }
1024
1025    /// Sets `path`'s output, dropping the one it had.
1026    pub fn put(&mut self, path: String, bytes: Vec<u8>) {
1027        if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
1028            self.items[i] = None;
1029        }
1030        self.items.push(Some((path, bytes)));
1031    }
1032
1033    pub fn into_vec(self) -> Vec<Output> {
1034        self.items.into_iter().flatten().collect()
1035    }
1036}
1037
1038impl From<Vec<Output>> for OutputSet {
1039    fn from(outputs: Vec<Output>) -> Self {
1040        let mut set = OutputSet::default();
1041        for (path, bytes) in outputs {
1042            set.put(path, bytes);
1043        }
1044        set
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests;