mod import;
pub mod json;
mod recipe;
mod refs;
pub use import::import_command;
use crate::yres;
use json::{J, V, num_value};
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub const CONTENT_DIR: &str = "content";
pub const GENERATED_FILE: &str = ".yog-build/generated/yog-content.g.rs";
pub type Output = (String, Vec<u8>);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Block,
Item,
Recipe,
Book,
Layout,
}
impl Kind {
pub fn of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"yblock" => Some(Kind::Block),
"yitem" => Some(Kind::Item),
"yrecipe" => Some(Kind::Recipe),
"ybook" => Some(Kind::Book),
"ylayout" => Some(Kind::Layout),
_ => None,
}
}
fn word(self) -> &'static str {
match self {
Kind::Block => "block",
Kind::Item => "item",
Kind::Recipe => "recipe",
Kind::Book => "book",
Kind::Layout => "layout",
}
}
}
#[derive(Debug, Default)]
pub struct Compiled {
pub outputs: Vec<Output>,
lang: BTreeMap<(String, String), BTreeMap<String, String>>,
lang_aliases: Vec<(String, String)>,
tags: BTreeMap<String, BTreeSet<String>>,
pub source: Option<String>,
pub warnings: Vec<String>,
pub blocks: usize,
pub items: usize,
pub recipes: usize,
pub books: usize,
}
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
pub const DISPLAY_SLOTS: [&str; 8] = [
"thirdperson_righthand",
"thirdperson_lefthand",
"firstperson_righthand",
"firstperson_lefthand",
"gui",
"head",
"ground",
"fixed",
];
pub const SOUNDS: [&str; 10] = [
"stone",
"wood",
"grass",
"gravel",
"sand",
"snow",
"metal",
"glass",
"wool",
"nether_brick",
];
pub const TOOLS: [&str; 4] = ["pickaxe", "axe", "shovel", "hoe"];
pub const TOOL_LEVELS: [&str; 3] = ["stone", "iron", "diamond"];
pub const RENDER_LAYERS: [&str; 4] = ["solid", "cutout", "cutout_mipped", "translucent"];
pub const RECIPE_TYPES: [&str; 8] =
["shaped", "shapeless", "smelting", "blasting", "smoking", "campfire_cooking", "stonecutting", "smithing"];
pub struct Template {
pub name: &'static str,
pub parent: &'static str,
pub slots: &'static [&'static str],
pub sprite: bool,
pub has_particle: bool,
}
pub const TEMPLATES: &[Template] = &[
Template {
name: "cube_all",
parent: "minecraft:block/cube_all",
slots: &["all"],
sprite: false,
has_particle: true,
},
Template {
name: "cube_column",
parent: "minecraft:block/cube_column",
slots: &["end", "side"],
sprite: false,
has_particle: true,
},
Template {
name: "cube_bottom_top",
parent: "minecraft:block/cube_bottom_top",
slots: &["top", "bottom", "side"],
sprite: false,
has_particle: true,
},
Template {
name: "cube",
parent: "minecraft:block/cube",
slots: &["down", "up", "north", "south", "west", "east"],
sprite: false,
has_particle: false,
},
Template {
name: "orientable",
parent: "minecraft:block/orientable",
slots: &["front", "side", "top"],
sprite: false,
has_particle: true,
},
Template {
name: "cross",
parent: "minecraft:block/cross",
slots: &["cross"],
sprite: false,
has_particle: true,
},
Template {
name: "generated",
parent: "minecraft:item/generated",
slots: &["layer0"],
sprite: true,
has_particle: true,
},
Template {
name: "handheld",
parent: "minecraft:item/handheld",
slots: &["layer0"],
sprite: true,
has_particle: true,
},
];
pub fn template(name: &str) -> Option<&'static Template> {
TEMPLATES.iter().find(|t| t.name == name)
}
pub fn valid_namespace(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
})
}
pub fn valid_path(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('/')
&& !s.ends_with('/')
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-' | b'/')
})
}
pub fn valid_id(s: &str) -> bool {
match s.split_once(':') {
Some((ns, path)) => valid_namespace(ns) && valid_path(path),
None => valid_path(s),
}
}
pub fn find(root: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if Kind::of(&path).is_some() {
out.push(path);
}
}
}
let mut out = Vec::new();
walk(&root.join(CONTENT_DIR), &mut out);
out.sort();
out
}
fn display(root: &Path, p: &Path) -> String {
p.strip_prefix(root)
.unwrap_or(p)
.to_string_lossy()
.replace('\\', "/")
}
#[derive(Debug, Clone)]
enum Text {
Key(String),
Literal(String),
}
#[derive(Debug, Clone, Default)]
struct BlockProps {
hardness: Option<f64>,
resistance: Option<f64>,
sound: Option<String>,
light: u8,
tool: Option<String>,
tool_level: Option<String>,
requires_tool: bool,
no_collision: bool,
slipperiness: Option<f64>,
shape: Option<[f64; 6]>,
connect_groups: Vec<String>,
connects: bool,
inventory: Option<String>,
redstone: bool,
}
#[derive(Debug, Clone, Default)]
struct ItemProps {
stack_size: Option<u8>,
durability: u32,
fire_resistant: bool,
fuel_ticks: u32,
food: Option<(u32, f64, bool)>,
}
#[derive(Debug, Clone)]
struct Entry {
kind: Kind,
id: String,
file: String,
auto: bool,
block: BlockProps,
item: ItemProps,
}
struct Ctx<'a> {
root: &'a Path,
refs: &'a yres::Refs,
file: String,
ns: String,
errors: &'a mut Vec<String>,
warnings: &'a mut Vec<String>,
textures: &'a mut BTreeMap<String, PathBuf>,
inventories: &'a mut Vec<(String, usize, String)>,
}
impl Ctx<'_> {
fn err(&mut self, line: usize, msg: impl std::fmt::Display) {
self.errors.push(format!("{}:{line}: {msg}", self.file));
}
fn warn(&mut self, line: usize, msg: impl std::fmt::Display) {
self.warnings.push(format!("{}:{line}: {msg}", self.file));
}
fn unknown_keys(&mut self, obj: &J, what: &str, known: &[&str]) {
for (k, v) in obj.members() {
if !known.contains(&k.as_str()) && !is_metadata(k) {
self.err(
v.line,
format!(
"unknown key {k:?} in {what} (expected one of: {})",
known.join(", ")
),
);
}
}
}
fn number(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<f64> {
match j.num() {
Some(n) if (min..=max).contains(&n) => Some(n),
Some(n) => {
self.err(j.line, format!("{what} is {n}, must be {min}..{max}"));
None
}
None => {
self.err(j.line, format!("{what} must be a number, not {}", j.kind()));
None
}
}
}
fn boolean(&mut self, j: &J, what: &str) -> Option<bool> {
let b = j.bool();
if b.is_none() {
self.err(j.line, format!("{what} must be true or false"));
}
b
}
fn string<'j>(&mut self, j: &'j J, what: &str) -> Option<&'j str> {
let s = j.str();
if s.is_none() {
self.err(j.line, format!("{what} must be a string, not {}", j.kind()));
}
s
}
fn one_of(&mut self, j: &J, what: &str, allowed: &[&str]) -> Option<String> {
let s = self.string(j, what)?;
if allowed.contains(&s) {
Some(s.to_string())
} else {
self.err(
j.line,
format!("{what} {s:?} isn't one of: {}", allowed.join(", ")),
);
None
}
}
fn vec<const N: usize>(&mut self, j: &J, what: &str, min: f64, max: f64) -> Option<[f64; N]> {
let items = j.items();
if !j.is_arr() || items.len() != N {
self.err(j.line, format!("{what} must be an array of {N} numbers"));
return None;
}
let mut out = [0.0; N];
for (i, item) in items.iter().enumerate() {
out[i] = self.number(item, what, min, max)?;
}
Some(out)
}
fn text(&mut self, j: &J, what: &str) -> Option<Text> {
let s = self.string(j, what)?;
match s.strip_prefix('@') {
Some(rest) if rest.starts_with('@') => Some(Text::Literal(rest.to_string())),
Some(rest) => match self.refs.texts.get(rest) {
Some((key, _)) => Some(Text::Key(key.clone())),
None => {
self.err(
j.line,
format!("{what} `{s}`: no text `{rest}` in resources/*.yres"),
);
None
}
},
None => Some(Text::Literal(s.to_string())),
}
}
fn texture(&mut self, j: &J, what: &str) -> Option<String> {
let s = self.string(j, what)?.trim().to_string();
if let Some(slot) = s.strip_prefix('#') {
if slot.is_empty() {
self.err(j.line, format!("{what}: `#` needs a slot name"));
return None;
}
return Some(s);
}
if let Some(rest) = s.strip_prefix('@') {
return match self.refs.images.get(rest) {
Some(id) => Some(id.clone()),
None => {
self.err(
j.line,
format!("{what} `{s}`: no image `{rest}` in resources/*.yres"),
);
None
}
};
}
if let Some(stem) = s.strip_suffix(".png") {
let rel = stem.trim_start_matches("./").trim_start_matches('/');
let rel = rel.strip_prefix("resources/").unwrap_or(rel);
if rel.split('/').any(|p| p == "..") || !valid_path(rel) {
self.err(
j.line,
format!("{what} `{s}`: a path inside resources/ — lower case a-z, 0-9, `_`, `-`, `.`, `/`"),
);
return None;
}
let file = self
.root
.join(yres::RESOURCES_DIR)
.join(format!("{rel}.png"));
if !file.is_file() {
self.err(j.line, format!("{what}: resources/{rel}.png doesn't exist"));
return None;
}
let target = format!("assets/{}/textures/{rel}.png", self.ns);
if let Some(other) = self.textures.insert(target.clone(), file.clone())
&& other != file
{
self.err(
j.line,
format!(
"{what}: {target} also comes from {}",
display(self.root, &other)
),
);
}
let meta = self
.root
.join(yres::RESOURCES_DIR)
.join(format!("{rel}.png.mcmeta"));
if meta.is_file() {
self.textures.insert(format!("{target}.mcmeta"), meta);
}
return Some(format!("{}:{rel}", self.ns));
}
if !valid_id(&s) {
self.err(
j.line,
format!("{what} `{s}` isn't a texture — `#slot`, `@Set.image`, `path/in/resources.png` or a texture id like `minecraft:block/stone`"),
);
return None;
}
let id = if s.contains(':') {
s
} else {
format!("minecraft:{s}")
};
let (ns, path) = id.split_once(':').unwrap_or_default();
if ns == self.ns {
let file = self
.root
.join("assets")
.join(ns)
.join("textures")
.join(format!("{path}.png"));
let bundled = self
.textures
.contains_key(&format!("assets/{ns}/textures/{path}.png"))
|| self.refs.images.values().any(|v| *v == id);
if !file.is_file() && !bundled {
self.warn(
j.line,
format!("{what}: texture {id} isn't in assets/{ns}/textures/ or resources/"),
);
}
}
Some(id)
}
}
struct Model {
json: Value,
bounds: Option<[f64; 6]>,
}
fn model(cx: &mut Ctx, j: &J, what: &str, item: bool) -> Option<Model> {
if !j.is_obj() {
cx.err(j.line, format!("{what} must be an object"));
return None;
}
cx.unknown_keys(
j,
what,
&[
"parent",
"textures",
"elements",
"display",
"ambient_occlusion",
"gui_light",
],
);
let mut out = Map::new();
let mut tpl: Option<&Template> = None;
let mut raw_parent = false;
if let Some(p) = j.get("parent") {
let name = cx.string(p, &format!("{what}.parent"))?;
if let Some(t) = template(name) {
if t.sprite && !item {
cx.err(p.line, format!("{what}.parent {name:?} is an item sprite — block models use cube_all, cube_column, ..."));
}
out.insert("parent".into(), Value::String(t.parent.into()));
tpl = Some(t);
} else if name.contains(':') && valid_id(name) {
out.insert("parent".into(), Value::String(name.into()));
raw_parent = true;
} else {
let names: Vec<&str> = TEMPLATES
.iter()
.filter(|t| item || !t.sprite)
.map(|t| t.name)
.collect();
cx.err(
p.line,
format!("{what}.parent {name:?} isn't a template ({}) or a model id like `minecraft:block/stairs`", names.join(", ")),
);
return None;
}
}
let mut slots: Vec<String> = Vec::new();
let mut textures = Map::new();
if let Some(t) = j.get("textures") {
if !t.is_obj() {
cx.err(
t.line,
format!("{what}.textures must be an object of slot → texture"),
);
}
for (slot, v) in t.members() {
if slot.is_empty() || slot.starts_with('#') {
cx.err(
v.line,
format!("{what}.textures: slot names are plain words (`all`, `side`, `0`)"),
);
continue;
}
if let Some(tex) = cx.texture(v, &format!("{what}.textures.{slot}")) {
textures.insert(slot.clone(), Value::String(tex));
slots.push(slot.clone());
}
}
}
let defined = |slot: &str, textures: &Map<String, Value>| textures.contains_key(slot);
if let Some(t) = tpl {
if t.sprite {
if !defined("layer0", &textures) {
cx.err(
j.line,
format!("{what}: parent {:?} needs textures.layer0", t.name),
);
}
for slot in &slots {
let n = slot
.strip_prefix("layer")
.and_then(|n| n.parse::<usize>().ok());
match n {
Some(n) if n == 0 || defined(&format!("layer{}", n - 1), &textures) => {}
Some(_) => cx.err(j.line, format!("{what}: textures.{slot} without the layer before it")),
None if slot == "particle" => {}
None => cx.warn(j.line, format!("{what}: textures.{slot} isn't used by an item sprite (layer0, layer1, ...)")),
}
}
} else {
for slot in t.slots {
if !defined(slot, &textures) {
cx.err(
j.line,
format!("{what}: parent {:?} needs textures.{slot}", t.name),
);
}
}
}
}
let mut bounds: Option<[f64; 6]> = None;
if let Some(e) = j.get("elements") {
if let Some(t) = tpl {
cx.err(
e.line,
format!(
"{what}: elements replace the shape of parent {:?} — remove one of them",
t.name
),
);
}
if !e.is_arr() {
cx.err(e.line, format!("{what}.elements must be an array"));
}
let mut elements = Vec::new();
for (i, el) in e.items().iter().enumerate() {
if let Some((json, from, to)) = element(
cx,
el,
&format!("{what}.elements[{i}]"),
&textures,
raw_parent,
) {
let b = bounds.get_or_insert([16.0, 16.0, 16.0, 0.0, 0.0, 0.0]);
for a in 0..3 {
b[a] = b[a].min(from[a].min(to[a]));
b[a + 3] = b[a + 3].max(from[a].max(to[a]));
}
elements.push(json);
}
}
out.insert("elements".into(), Value::Array(elements));
} else if tpl.is_none() && !raw_parent {
cx.err(j.line, format!("{what}: no parent and no elements — give a template (\"parent\": \"cube_all\") or elements"));
}
if !textures.contains_key("particle")
&& !tpl.is_some_and(|t| t.has_particle)
&& (!raw_parent || j.get("elements").is_some())
&& let Some(first) = slots.first()
{
let v = textures[first].clone();
textures.insert("particle".into(), v);
}
if !textures.is_empty() {
out.insert("textures".into(), Value::Object(textures));
}
if let Some(d) = j.get("display") {
if !d.is_obj() {
cx.err(d.line, format!("{what}.display must be an object"));
}
let mut display = Map::new();
for (slot, t) in d.members() {
let w = format!("{what}.display.{slot}");
if !DISPLAY_SLOTS.contains(&slot.as_str()) {
cx.err(
t.line,
format!(
"{w}: unknown display position (expected one of: {})",
DISPLAY_SLOTS.join(", ")
),
);
continue;
}
cx.unknown_keys(t, &w, &["rotation", "translation", "scale"]);
let mut m = Map::new();
for (key, lim) in [("rotation", 360.0), ("translation", 80.0), ("scale", 4.0)] {
if let Some(v) = t.get(key)
&& let Some(v3) = cx.vec::<3>(v, &format!("{w}.{key}"), -lim, lim)
{
m.insert(
key.into(),
Value::Array(v3.iter().map(|n| num_value(*n)).collect()),
);
}
}
display.insert(slot.clone(), Value::Object(m));
}
out.insert("display".into(), Value::Object(display));
}
if let Some(ao) = j.get("ambient_occlusion")
&& let Some(b) = cx.boolean(ao, &format!("{what}.ambient_occlusion"))
{
out.insert("ambientocclusion".into(), Value::Bool(b));
}
if let Some(gl) = j.get("gui_light")
&& let Some(s) = cx.one_of(gl, &format!("{what}.gui_light"), &["front", "side"])
{
out.insert("gui_light".into(), Value::String(s));
}
Some(Model {
json: Value::Object(out),
bounds,
})
}
fn element(
cx: &mut Ctx,
j: &J,
what: &str,
textures: &Map<String, Value>,
raw_parent: bool,
) -> Option<(Value, [f64; 3], [f64; 3])> {
if !j.is_obj() {
cx.err(j.line, format!("{what} must be an object"));
return None;
}
cx.unknown_keys(
j,
what,
&["name", "from", "to", "rotation", "shade", "faces"],
);
if let Some(n) = j.get("name") {
cx.string(n, &format!("{what}.name"));
}
let (Some(from_j), Some(to_j)) = (j.get("from"), j.get("to")) else {
cx.err(j.line, format!("{what} needs `from` and `to`"));
return None;
};
let from = cx.vec::<3>(from_j, &format!("{what}.from"), -16.0, 32.0)?;
let to = cx.vec::<3>(to_j, &format!("{what}.to"), -16.0, 32.0)?;
let mut out = Map::new();
out.insert(
"from".into(),
Value::Array(from.iter().map(|n| num_value(*n)).collect()),
);
out.insert(
"to".into(),
Value::Array(to.iter().map(|n| num_value(*n)).collect()),
);
if let Some(r) = j.get("rotation") {
let w = format!("{what}.rotation");
cx.unknown_keys(r, &w, &["origin", "axis", "angle", "rescale"]);
let axis = r
.get("axis")
.and_then(|a| cx.one_of(a, &format!("{w}.axis"), &["x", "y", "z"]));
let angle = r
.get("angle")
.and_then(|a| cx.number(a, &format!("{w}.angle"), -45.0, 45.0));
if let Some(a) = angle
&& (a / 22.5).fract() != 0.0
{
cx.err(r.line, format!("{w}.angle is {a} — Minecraft rotates elements by -45, -22.5, 0, 22.5 or 45 degrees"));
}
let origin = match r.get("origin") {
Some(o) => cx.vec::<3>(o, &format!("{w}.origin"), -16.0, 32.0),
None => Some([8.0, 8.0, 8.0]),
};
if axis.is_none() || angle.is_none() {
if r.get("axis").is_none() || r.get("angle").is_none() {
cx.err(r.line, format!("{w} needs `axis` and `angle`"));
}
} else if let (Some(axis), Some(angle), Some(origin)) = (axis, angle, origin) {
let mut rot = Map::new();
rot.insert(
"origin".into(),
Value::Array(origin.iter().map(|n| num_value(*n)).collect()),
);
rot.insert("axis".into(), Value::String(axis));
rot.insert("angle".into(), num_value(angle));
if let Some(rs) = r.get("rescale")
&& let Some(b) = cx.boolean(rs, &format!("{w}.rescale"))
&& b
{
rot.insert("rescale".into(), Value::Bool(true));
}
out.insert("rotation".into(), Value::Object(rot));
}
}
if let Some(s) = j.get("shade")
&& let Some(b) = cx.boolean(s, &format!("{what}.shade"))
&& !b
{
out.insert("shade".into(), Value::Bool(false));
}
let mut faces = Map::new();
match j.get("faces") {
None => cx.warn(j.line, format!("{what} has no faces — it's invisible")),
Some(f) => {
if !f.is_obj() {
cx.err(
f.line,
format!("{what}.faces must be an object of direction → face"),
);
}
for (dir, face) in f.members() {
let w = format!("{what}.faces.{dir}");
if !DIRECTIONS.contains(&dir.as_str()) {
cx.err(
face.line,
format!(
"{w}: unknown face (expected one of: {})",
DIRECTIONS.join(", ")
),
);
continue;
}
cx.unknown_keys(face, &w, &["texture", "uv", "rotation", "cullface", "tint"]);
let mut fo = Map::new();
match face.get("texture") {
None => cx.err(face.line, format!("{w} needs a texture (`#slot`)")),
Some(t) => {
if let Some(s) = cx.string(t, &format!("{w}.texture")) {
let slot = s.strip_prefix('#').unwrap_or(s);
if !textures.contains_key(slot) && !raw_parent {
cx.err(
t.line,
format!("{w}.texture `#{slot}`: no such slot in textures"),
);
}
fo.insert("texture".into(), Value::String(format!("#{slot}")));
}
}
}
if let Some(uv) = face.get("uv")
&& let Some(uv) = cx.vec::<4>(uv, &format!("{w}.uv"), 0.0, 16.0)
{
fo.insert(
"uv".into(),
Value::Array(uv.iter().map(|n| num_value(*n)).collect()),
);
}
if let Some(r) = face.get("rotation") {
match r.num() {
Some(n) if [0.0, 90.0, 180.0, 270.0].contains(&n) => {
if n != 0.0 {
fo.insert("rotation".into(), num_value(n));
}
}
_ => cx.err(r.line, format!("{w}.rotation must be 0, 90, 180 or 270")),
}
}
if let Some(c) = face.get("cullface")
&& let Some(c) = cx.one_of(c, &format!("{w}.cullface"), &DIRECTIONS)
{
fo.insert("cullface".into(), Value::String(c));
}
if let Some(t) = face.get("tint")
&& let Some(n) = cx.number(t, &format!("{w}.tint"), -1.0, 255.0)
&& n >= 0.0
{
fo.insert("tintindex".into(), num_value(n.floor()));
}
faces.insert(dir.clone(), Value::Object(fo));
}
}
}
out.insert("faces".into(), Value::Object(faces));
Some((Value::Object(out), from, to))
}
fn entry_id(cx: &mut Ctx, j: &J, path: &Path, mod_ns: Option<&str>) -> Option<(String, String)> {
let (raw, line) = match j.get("id") {
Some(v) => (cx.string(v, "id")?.to_string(), v.line),
None => (
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
1,
),
};
let (ns, p) = match raw.split_once(':') {
Some((ns, p)) => (ns.to_string(), p.to_string()),
None => match mod_ns {
Some(ns) => (ns.to_string(), raw.clone()),
None => {
cx.err(
line,
"the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"",
);
return None;
}
},
};
if !valid_namespace(&ns) || !valid_path(&p) {
cx.err(
line,
format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"),
);
return None;
}
Some((ns, p))
}
fn variant(model: &str, x: f64, y: f64, uvlock: bool, weight: Option<f64>) -> Value {
let mut v = Map::new();
v.insert("model".into(), Value::String(model.into()));
if x != 0.0 {
v.insert("x".into(), num_value(x));
}
if y != 0.0 {
v.insert("y".into(), num_value(y));
}
if uvlock {
v.insert("uvlock".into(), Value::Bool(true));
}
if let Some(w) = weight
&& w != 1.0
{
v.insert("weight".into(), num_value(w));
}
Value::Object(v)
}
fn pretty(v: &Value) -> Vec<u8> {
let mut s = serde_json::to_string_pretty(v).unwrap_or_default();
s.push('\n');
s.into_bytes()
}
#[allow(clippy::too_many_arguments)]
fn compile_file(
cx: &mut Ctx,
j: &J,
kind: Kind,
path: &Path,
mod_ns: Option<&str>,
out: &mut Compiled,
generated: &mut BTreeMap<String, String>,
) -> Option<Entry> {
if !j.is_obj() {
cx.err(
j.line,
format!("a .y{} file is one JSON object", kind.word()),
);
return None;
}
let common = [
"$schema", "format", "id", "name", "tooltip", "register", "model",
];
let specific: &[&str] = match kind {
Kind::Block => &["block", "item_model", "states"],
Kind::Item => &["item"],
Kind::Recipe | Kind::Book | Kind::Layout => unreachable!("only blocks and items get here"),
};
let known: Vec<&str> = common.iter().chain(specific).copied().collect();
cx.unknown_keys(j, &format!("a .y{}", kind.word()), &known);
if let Some(f) = j.get("format")
&& f.num() != Some(1.0)
{
cx.err(f.line, "format must be 1 (this yog understands format 1)");
}
let (ns, p) = entry_id(cx, j, path, mod_ns)?;
cx.ns = ns.clone();
let id = format!("{ns}:{p}");
let auto = match j.get("register") {
None => true,
Some(r) => cx
.one_of(r, "register", &["auto", "manual"])
.is_none_or(|s| s == "auto"),
};
let key = format!("{}.{ns}.{}", kind.word(), p.replace('/', "."));
for (field, target) in [("name", key.clone()), ("tooltip", format!("{key}.desc"))] {
let Some(v) = j.get(field) else { continue };
match cx.text(v, field)? {
Text::Key(from) if from == target => {}
Text::Key(from) => out.lang_aliases.push((from, target)),
Text::Literal(s) => {
out.lang
.entry((ns.clone(), yres::NEUTRAL_LOCALE.to_string()))
.or_default()
.insert(target, s);
}
}
}
let mut emit = |cx: &mut Ctx, rel: String, value: &Value| {
let full = format!("assets/{ns}/{rel}");
if cx.root.join(&full).is_file() {
cx.err(1, format!("{full} is written by this file — delete the hand-written one (or bring it in with `yog import model`)"));
}
if let Some(other) = generated.insert(full.clone(), cx.file.clone()) {
cx.err(1, format!("{full} is also written by {other}"));
}
out.outputs.push((full, pretty(value)));
};
let mut entry = Entry {
kind,
id: id.clone(),
file: cx.file.clone(),
auto,
block: BlockProps::default(),
item: ItemProps::default(),
};
match kind {
Kind::Recipe | Kind::Book | Kind::Layout => unreachable!("only blocks and items get here"),
Kind::Item => {
if let Some(it) = j.get("item") {
cx.unknown_keys(
it,
"item",
&[
"stack_size",
"durability",
"fire_resistant",
"fuel_ticks",
"food",
],
);
let props = &mut entry.item;
if let Some(v) = it.get("stack_size") {
props.stack_size = cx.number(v, "item.stack_size", 1.0, 99.0).map(|n| n as u8);
}
if let Some(v) = it.get("durability") {
props.durability =
cx.number(v, "item.durability", 0.0, 1e9).unwrap_or(0.0) as u32;
}
if let Some(v) = it.get("fire_resistant") {
props.fire_resistant = cx.boolean(v, "item.fire_resistant").unwrap_or(false);
}
if let Some(v) = it.get("fuel_ticks") {
props.fuel_ticks =
cx.number(v, "item.fuel_ticks", 0.0, 1e9).unwrap_or(0.0) as u32;
}
if let Some(f) = it.get("food") {
cx.unknown_keys(
f,
"item.food",
&["nutrition", "saturation", "always_edible"],
);
let n = f
.get("nutrition")
.and_then(|v| cx.number(v, "item.food.nutrition", 0.0, 1000.0));
let s = f
.get("saturation")
.and_then(|v| cx.number(v, "item.food.saturation", 0.0, 100.0));
let a = f
.get("always_edible")
.and_then(|v| cx.boolean(v, "item.food.always_edible"));
if n.is_none() {
cx.err(f.line, "item.food needs nutrition");
}
props.food = n.map(|n| (n as u32, s.unwrap_or(0.0), a.unwrap_or(false)));
}
if props.durability > 0 && props.stack_size.is_some_and(|s| s > 1) {
cx.warn(
it.line,
"item.stack_size is ignored: an item with durability stacks to 1",
);
}
}
let Some(mj) = j.get("model") else {
cx.err(j.line, "an item needs a model — e.g. { \"parent\": \"generated\", \"textures\": { \"layer0\": \"@Textures.ruby\" } }");
return Some(entry);
};
let m = model(cx, mj, "model", true)?;
emit(cx, format!("models/item/{p}.json"), &m.json);
emit(
cx,
format!("items/{p}.json"),
&json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
);
}
Kind::Block => {
let mut render_layer: Option<String> = None;
let mut drops = Value::Null;
let mut shape_auto = true;
if let Some(b) = j.get("block") {
cx.unknown_keys(
b,
"block",
&[
"hardness",
"resistance",
"sound",
"light",
"tool",
"tool_level",
"requires_tool",
"collision",
"slipperiness",
"shape",
"render_layer",
"drops",
"connect_groups",
"inventory",
"redstone",
],
);
let props = &mut entry.block;
if let Some(v) = b.get("hardness") {
props.hardness = cx.number(v, "block.hardness", -1.0, 1e6);
}
if let Some(v) = b.get("resistance") {
props.resistance = cx.number(v, "block.resistance", 0.0, 1e7);
}
if let Some(v) = b.get("sound") {
props.sound = cx.one_of(v, "block.sound", &SOUNDS);
}
if let Some(v) = b.get("light") {
props.light = cx.number(v, "block.light", 0.0, 15.0).unwrap_or(0.0) as u8;
}
if let Some(v) = b.get("tool") {
props.tool = cx.one_of(v, "block.tool", &TOOLS);
}
if let Some(v) = b.get("tool_level") {
props.tool_level = cx.one_of(v, "block.tool_level", &TOOL_LEVELS);
}
if let Some(v) = b.get("requires_tool") {
props.requires_tool = cx.boolean(v, "block.requires_tool").unwrap_or(false);
}
if let Some(v) = b.get("collision") {
props.no_collision = !cx.boolean(v, "block.collision").unwrap_or(true);
}
if let Some(v) = b.get("slipperiness") {
props.slipperiness = cx.number(v, "block.slipperiness", 0.0, 1.0);
}
if let Some(v) = b.get("shape") {
match &v.v {
V::Str(s) if s == "auto" => {}
V::Str(s) if s == "full" => shape_auto = false,
V::Arr(_) => {
shape_auto = false;
props.shape = cx.vec::<6>(v, "block.shape", 0.0, 16.0);
}
_ => cx.err(v.line, "block.shape must be \"auto\" (the model's bounds), \"full\" or [x1, y1, z1, x2, y2, z2] in pixels"),
}
}
if let Some(v) = b.get("render_layer") {
render_layer = cx.one_of(v, "block.render_layer", &RENDER_LAYERS);
}
if let Some(v) = b.get("redstone") {
props.redstone = cx.boolean(v, "block.redstone").unwrap_or(false);
}
if let Some(v) = b.get("inventory")
&& let Some(id) = cx.string(v, "block.inventory")
{
if valid_id(id) && id.contains(':') {
props.inventory = Some(id.to_string());
let at = (cx.file.clone(), v.line, id.to_string());
cx.inventories.push(at);
} else {
cx.err(v.line, format!("block.inventory `{id}`: the id of an inventory screen — a .yui <Screen id=\"mymod:name\"> with <Slot>s"));
}
}
if let Some(v) = b.get("connect_groups") {
for g in v.items() {
if let Some(s) = cx.string(g, "block.connect_groups[]") {
props.connect_groups.push(s.to_string());
}
}
if !v.is_arr() {
cx.err(
v.line,
"block.connect_groups must be an array of group names",
);
}
}
if props.tool_level.is_some() && props.tool.is_none() {
cx.warn(
b.line,
"block.tool_level without block.tool — any tool of that level mines it",
);
}
if let Some(d) = b.get("drops") {
drops = drops_table(cx, d, &id);
}
}
if drops.is_null() && !j.get("block").and_then(|b| b.get("drops")).is_some() {
drops = drops_table_self(&id);
}
let Some(mj) = j.get("model") else {
cx.err(j.line, "a block needs a model — e.g. { \"parent\": \"cube_all\", \"textures\": { \"all\": \"@Textures.ruby_block\" } }");
return Some(entry);
};
let mut m = model(cx, mj, "model", false)?;
if let (Some(layer), Value::Object(o)) = (&render_layer, &mut m.json) {
o.insert(
"render_type".into(),
Value::String(format!("minecraft:{layer}")),
);
}
if shape_auto {
entry.block.shape = m
.bounds
.filter(|b| *b != [0.0, 0.0, 0.0, 16.0, 16.0, 16.0])
.map(|b| {
[
b[0].max(0.0),
b[1].max(0.0),
b[2].max(0.0),
b[3].min(16.0),
b[4].min(16.0),
b[5].min(16.0),
]
});
}
let main = format!("{ns}:block/{p}");
emit(cx, format!("models/block/{p}.json"), &m.json);
let mut rotations: Vec<Value> = Vec::new();
let mut arm: Option<(String, bool)> = None;
if let Some(s) = j.get("states") {
cx.unknown_keys(s, "states", &["rotations", "connect"]);
if let Some(r) = s.get("rotations") {
if !r.is_arr() || r.items().is_empty() {
cx.err(r.line, "states.rotations must be a non-empty array of { \"x\": 0, \"y\": 90, \"weight\": 1, \"uvlock\": false }");
}
for (i, rot) in r.items().iter().enumerate() {
let w = format!("states.rotations[{i}]");
cx.unknown_keys(rot, &w, &["x", "y", "weight", "uvlock"]);
let mut angle = |k: &str| -> f64 {
match rot.get(k).map(|v| (v, v.num())) {
None => 0.0,
Some((_, Some(n))) if [0.0, 90.0, 180.0, 270.0].contains(&n) => n,
Some((v, _)) => {
cx.err(v.line, format!("{w}.{k} must be 0, 90, 180 or 270"));
0.0
}
}
};
let (x, y) = (angle("x"), angle("y"));
let weight = rot
.get("weight")
.and_then(|v| cx.number(v, &format!("{w}.weight"), 1.0, 1e6));
let uvlock = rot
.get("uvlock")
.and_then(|v| cx.boolean(v, &format!("{w}.uvlock")))
.unwrap_or(false);
rotations.push(variant(&main, x, y, uvlock, weight));
}
}
if let Some(c) = s.get("connect") {
cx.unknown_keys(c, "states.connect", &["arm", "uvlock"]);
let uvlock = c
.get("uvlock")
.and_then(|v| cx.boolean(v, "states.connect.uvlock"))
.unwrap_or(false);
match c.get("arm") {
None => cx.err(
c.line,
"states.connect needs `arm` — the model of the arm towards north",
),
Some(a) => {
if let Some(am) = model(cx, a, "states.connect.arm", false) {
let mut json = am.json;
if let (Some(layer), Value::Object(o)) = (&render_layer, &mut json)
{
o.insert(
"render_type".into(),
Value::String(format!("minecraft:{layer}")),
);
}
emit(cx, format!("models/block/{p}_arm.json"), &json);
arm = Some((format!("{ns}:block/{p}_arm"), uvlock));
}
}
}
entry.block.connects = true;
if entry.block.connect_groups.is_empty() {
entry.block.connect_groups.push(id.clone());
}
}
}
let base: Value = match rotations.len() {
0 => variant(&main, 0.0, 0.0, false, None),
1 => rotations.remove(0),
_ => Value::Array(rotations),
};
let blockstate = match &arm {
None => json!({ "variants": { "": base } }),
Some((arm, uvlock)) => {
let mut parts = vec![json!({ "apply": base })];
for (dir, x, y) in [
("north", 0.0, 0.0),
("east", 0.0, 90.0),
("south", 0.0, 180.0),
("west", 0.0, 270.0),
("up", 270.0, 0.0),
("down", 90.0, 0.0),
] {
parts.push(json!({ "when": { dir: "true" }, "apply": variant(arm, x, y, *uvlock, None) }));
}
json!({ "multipart": parts })
}
};
emit(cx, format!("blockstates/{p}.json"), &blockstate);
let item_model = match j.get("item_model") {
None => Some(json!({ "parent": main })),
Some(im) => model(cx, im, "item_model", true).map(|m| m.json),
};
if let Some(im) = item_model {
emit(cx, format!("models/item/{p}.json"), &im);
}
emit(
cx,
format!("items/{p}.json"),
&json!({ "model": { "type": "minecraft:model", "model": format!("{ns}:item/{p}") } }),
);
if !drops.is_null() {
let full = format!("data/{ns}/loot_table/blocks/{p}.json");
let plural = format!("data/{ns}/loot_tables/blocks/{p}.json");
if cx.root.join(&full).is_file() || cx.root.join(&plural).is_file() {
cx.err(1, format!("{full} is written by this file (block.drops) — delete the hand-written one, or set \"drops\": \"none\""));
}
out.outputs.push((full, pretty(&drops)));
}
if let Some(tool) = &entry.block.tool {
out.tags
.entry(format!("data/minecraft/tags/block/mineable/{tool}.json"))
.or_default()
.insert(id.clone());
}
if let Some(level) = &entry.block.tool_level {
out.tags
.entry(format!("data/minecraft/tags/block/needs_{level}_tool.json"))
.or_default()
.insert(id.clone());
}
}
}
Some(entry)
}
fn drops_table_self(id: &str) -> Value {
json!({
"type": "minecraft:block",
"pools": [{
"rolls": 1,
"entries": [{ "type": "minecraft:item", "name": id }],
"conditions": [{ "condition": "minecraft:survives_explosion" }]
}]
})
}
fn drops_table(cx: &mut Ctx, d: &J, id: &str) -> Value {
match &d.v {
V::Str(s) if s == "self" => drops_table_self(id),
V::Str(s) if s == "none" => Value::Null,
V::Obj(_) => {
cx.unknown_keys(d, "block.drops", &["item", "count"]);
let item = match d.get("item") {
Some(i) => match cx.string(i, "block.drops.item") {
Some(s) if valid_id(s) => {
if s.contains(':') {
s.to_string()
} else {
format!("minecraft:{s}")
}
}
Some(s) => {
cx.err(i.line, format!("block.drops.item `{s}` isn't an item id"));
return Value::Null;
}
None => return Value::Null,
},
None => id.to_string(),
};
let mut entry = json!({ "type": "minecraft:item", "name": item });
if let Some(c) = d.get("count") {
let count = match &c.v {
V::Num(_) => cx.number(c, "block.drops.count", 1.0, 64.0).map(num_value),
V::Arr(_) => cx.vec::<2>(c, "block.drops.count", 0.0, 64.0).map(|[min, max]| {
json!({ "type": "minecraft:uniform", "min": num_value(min), "max": num_value(max) })
}),
_ => {
cx.err(c.line, "block.drops.count must be a number or [min, max]");
None
}
};
if let Some(count) = count {
entry["functions"] =
json!([{ "function": "minecraft:set_count", "count": count }]);
}
}
json!({
"type": "minecraft:block",
"pools": [{
"rolls": 1,
"entries": [entry],
"conditions": [{ "condition": "minecraft:survives_explosion" }]
}]
})
}
_ => {
cx.err(d.line, "block.drops must be \"self\", \"none\" or { \"item\": \"ns:item\", \"count\": [1, 3] }");
Value::Null
}
}
}
#[derive(Debug, Clone)]
enum RecipeKind {
Shaped { pattern: Vec<String>, key: Vec<(char, Vec<String>)> },
Shapeless(Vec<Vec<String>>),
Cooking { method: &'static str, input: Vec<String>, experience: f64, cook_time: u32 },
Stonecutting(Vec<String>),
Smithing { template: Vec<String>, base: Vec<String>, addition: Vec<String> },
Custom { kind: String, slots: Vec<(String, Vec<Vec<String>>)>, values: serde_json::Map<String, Value> },
}
struct BookFile {
id: String,
json: String,
}
fn compile_book(
cx: &mut Ctx,
text: &str,
mod_ns: Option<&str>,
known: &refs::Known,
layouts: &[yog_book::layout::RecipeLayout],
recipe_kinds: &HashMap<String, String>,
) -> Option<BookFile> {
use refs::RefKind;
use yog_data::schema::{Resolve, Text};
use yog_data::{Path, Ref, Res};
struct Check<'a> {
known: &'a refs::Known,
texts: &'a HashMap<String, (String, String)>,
entries: HashSet<String>,
categories: HashSet<String>,
text_refs: bool,
}
impl Resolve for Check<'_> {
fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
let own = match r.kind.as_str() {
"entry" => Some(&self.entries),
"category" => Some(&self.categories),
_ => None,
};
match (own, RefKind::from_word(&r.kind)) {
(Some(set), _) if set.contains(&r.id) => Ok(r.id.clone()),
(Some(_), _) => Err(format!("no {} {} in this book", r.kind, r.id)),
(None, Some(kind)) => self.known.check(kind, &r.id).map(|()| r.id.clone()),
(None, None) => Err(format!("`@{}:` names nothing a book shows", r.kind)),
}
}
fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
match t {
Text::Literal(s) => Ok(s.clone()),
Text::NameOf(r) => {
if !matches!(r.kind.as_str(), "item" | "block" | "entity") {
return Err(format!("`@{}:{}` has no name to show", r.kind, r.id));
}
self.known.check(RefKind::from_word(&r.kind).unwrap(), &r.id)?;
let (ns, p) = yog_book::refs::bare(&r.id).split_once(':').unwrap_or(("minecraft", &r.id));
self.text_refs = true;
Ok(format!("{}.{ns}.{}", r.kind, p.replace('/', ".")))
}
Text::Lang(key) => {
self.text_refs = true;
Ok(key.clone())
}
Text::Res(r) => match self.texts.get(&format!("{}.{}", r.set, r.key)) {
Some((key, _)) => {
self.text_refs = true;
Ok(key.clone())
}
None => Err(format!("`@{}.{}`: no text `{}.{}` in resources/*.yres", r.set, r.key, r.set, r.key)),
},
}
}
fn words(&mut self, r: &Res) -> Option<String> {
self.texts.get(&format!("{}.{}", r.set, r.key)).map(|(_, t)| t.clone())
}
}
let doc = match yog_data::text::parse(text) {
Ok(d) => d,
Err(e) => {
cx.err(e.at.line as usize, e.message);
return None;
}
};
let ids = |key: &str| -> HashSet<String> {
doc.value.get(key).and_then(|v| v.as_list()).unwrap_or_default().iter().filter_map(|e| e.get("id")?.as_str().map(str::to_string)).collect()
};
let mut check = Check { known, texts: &cx.refs.texts, entries: ids("entries"), categories: ids("categories"), text_refs: false };
let mut book = match yog_data::from_doc_with::<yog_book::Book>(text, &doc, &mut check) {
Ok(read) => read.value,
Err(errors) => {
for e in errors.0 {
let line = e.at.map_or(1, |a| a.line as usize);
if e.path.is_root() {
cx.err(line, e.message);
} else {
cx.err(line, format!("{}: {}", e.path, e.message));
}
}
return None;
}
};
if !book.id.contains(':') {
book.id = format!("{}:{}", mod_ns.unwrap_or("minecraft"), book.id);
}
let ns = mod_ns.unwrap_or("minecraft");
let unnamespaced: Vec<String> = (book.categories.iter().enumerate().map(|(i, c)| (format!("categories[{i}].id"), &c.id)))
.chain(book.entries.iter().enumerate().map(|(i, e)| (format!("entries[{i}].id"), &e.id)))
.filter(|(_, id)| !id.contains(':'))
.map(|(path, id)| format!("{path}: `{id}`: write its namespace (`{ns}:{id}`)"))
.collect();
if !unnamespaced.is_empty() {
for e in unnamespaced {
cx.err(1, e);
}
return None;
}
if !valid_id(&book.id) {
cx.err(1, format!("book id `{}`: not a valid id", book.id));
return None;
}
book.i18n |= check.text_refs;
let mut ok = true;
for (e, entry) in book.entries.iter().enumerate() {
for (p, page) in entry.pages.iter().enumerate() {
if let yog_book::BookPage::Recipe { recipe, .. } = page
&& let Some(kind) = recipe_kinds.get(recipe)
&& yog_book::layout::find(layouts, kind).is_none()
{
let path = yog_data::Path::root().key("entries").index(e).key("pages").index(p).key("recipe");
let file = kind.split_once(':').map_or(kind.as_str(), |(_, k)| k);
cx.err(
doc.locate(text, &path).line as usize,
format!("{path}: no layout for recipe kind {kind} — add content/{file}.ylayout (see yog_book::layout)"),
);
ok = false;
}
}
}
if !ok {
return None;
}
book.layouts = layouts.to_vec();
Some(BookFile { id: book.id.clone(), json: book.to_json() })
}
fn compile_layout(cx: &mut Ctx, text: &str, mod_ns: Option<&str>, known: &refs::Known) -> Option<yog_book::layout::RecipeLayout> {
let doc = match yog_data::text::parse(text) {
Ok(d) => d,
Err(e) => {
cx.err(e.at.line as usize, e.message);
return None;
}
};
let read = match yog_data::schema::from_doc::<yog_book::layout::RecipeLayout>(text, &doc) {
Ok(r) => r,
Err(errors) => {
for e in errors.0 {
let l = e.at.map_or(1, |a| a.line as usize);
if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
}
return None;
}
};
let mut ok = true;
for f in &read.refs {
if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
&& let Err(e) = known.check(kind, &f.value.id)
{
cx.err(doc.locate(text, &f.path).line as usize, format!("{}: {e}", f.path));
ok = false;
}
}
let l = read.value;
for (i, k) in l.kinds.iter().enumerate() {
let game = recipe::TYPES.contains(&k.as_str());
let own = mod_ns.is_some_and(|m| k.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
if !game && !own {
let path = yog_data::Path::root().key("kinds").index(i);
cx.err(doc.locate(text, &path).line as usize, format!("{path}: `{k}`: one of {}, or the mod's own kind `{}:<kind>`", recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")));
ok = false;
}
}
if l.kinds.is_empty() {
cx.err(1, "kinds: the recipe kinds it lays out, at least one");
ok = false;
}
ok.then_some(l)
}
fn is_metadata(key: &str) -> bool {
key.starts_with('$') || key.starts_with('_') || key == "comment" || key == "meta"
}
#[derive(Debug, Clone)]
struct Recipe {
id: String,
ty: String,
kind: RecipeKind,
result: String,
count: u32,
group: Option<String>,
category: Option<String>,
}
impl Recipe {
fn registration(&self) -> String {
let list = |items: &[String]| {
let quoted: Vec<String> = items.iter().map(|i| format!("{i:?}.into()")).collect();
format!("vec![{}]", quoted.join(", "))
};
let kind = match &self.kind {
RecipeKind::Shaped { pattern, key } => {
let rows: Vec<String> = pattern.iter().map(|r| format!("{r:?}.into()")).collect();
let keys: Vec<String> = key.iter().map(|(c, items)| format!("({c:?}, {})", list(items))).collect();
format!("Shaped {{ pattern: vec![{}], key: vec![{}] }}", rows.join(", "), keys.join(", "))
}
RecipeKind::Shapeless(ingredients) => {
let all: Vec<String> = ingredients.iter().map(|i| list(i)).collect();
format!("Shapeless {{ ingredients: vec![{}] }}", all.join(", "))
}
RecipeKind::Cooking { method, input, experience, cook_time } => format!(
"Cooking {{ method: ::yog_api::Cooking::{method}, ingredient: {}, experience: {}_f32, cook_time: {cook_time} }}",
list(input),
float(*experience)
),
RecipeKind::Stonecutting(input) => format!("Stonecutting {{ ingredient: {} }}", list(input)),
RecipeKind::Custom { kind, slots, values } => {
let mut o = Map::new();
o.insert("type".into(), kind.clone().into());
o.insert("slots".into(), Value::Object(slots.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
if !values.is_empty() {
o.insert("values".into(), Value::Object(values.clone()));
}
o.insert("result".into(), self.result.clone().into());
if self.count != 1 {
o.insert("count".into(), self.count.into());
}
if let Some(g) = &self.group {
o.insert("group".into(), g.clone().into());
}
return format!("registry.add_recipe_json({:?}, {:?});", self.id, Value::Object(o).to_string());
}
RecipeKind::Smithing { template, base, addition } => format!(
"Smithing {{ template: {}, base: {}, addition: {} }}",
list(template),
list(base),
list(addition)
),
};
let opt = |o: &Option<String>| o.as_ref().map_or("None".to_string(), |v| format!("Some({v:?}.into())"));
format!(
"registry.add_recipe(::yog_api::Recipe {{ id: {:?}.into(), kind: ::yog_api::RecipeKind::{kind}, result: {:?}.into(), count: {}, group: {}, category: {} }});",
self.id,
self.result,
self.count,
opt(&self.group),
opt(&self.category)
)
}
}
fn compile_recipe(cx: &mut Ctx, text: &str, path: &Path, mod_ns: Option<&str>, known: &refs::Known) -> Option<Recipe> {
use recipe::{Body, RecipeFile};
use yog_data::Path as At;
let doc = match yog_data::text::parse(text) {
Ok(d) => d,
Err(e) => {
cx.err(e.at.line as usize, e.message);
return None;
}
};
let line = |p: &At| doc.locate(text, p).line as usize;
let read = match yog_data::schema::from_doc::<RecipeFile>(text, &doc) {
Ok(r) => r,
Err(errors) => {
for e in errors.0 {
let l = e.at.map_or(1, |a| a.line as usize);
if e.path.is_root() { cx.err(l, e.message) } else { cx.err(l, format!("{}: {}", e.path, e.message)) }
}
return None;
}
};
let mut ok = true;
for f in &read.refs {
if let Some(kind) = refs::RefKind::from_word(&f.value.kind)
&& let Err(e) = known.check(kind, &f.value.id)
{
cx.err(line(&f.path), format!("{}: {e}", f.path));
ok = false;
}
}
let RecipeFile { common: c, body } = read.value;
let ty = match &body {
Body::Shaped(_) => "shaped".to_string(),
Body::Shapeless(_) => "shapeless".to_string(),
Body::Cooking(t, _) | Body::Custom(t, _) => t.clone(),
Body::Stonecutting(_) => "stonecutting".to_string(),
Body::Smithing(_) => "smithing".to_string(),
};
let root = At::root();
if let Some(f) = c.format
&& f != 1
{
cx.err(line(&root.key("format")), "format must be 1 (this yog understands format 1)");
ok = false;
}
let raw = c.id.clone().unwrap_or_else(|| path.file_stem().unwrap_or_default().to_string_lossy().into_owned());
let id_line = line(&root.key("id"));
let (ns, p) = match raw.split_once(':') {
Some((ns, p)) => (ns.to_string(), p.to_string()),
None => match mod_ns {
Some(ns) => (ns.to_string(), raw.clone()),
None => {
cx.err(id_line, "the mod id isn't a valid Minecraft namespace (a-z, 0-9, `_`, `-`, `.`), so give the namespace: \"id\": \"mynamespace:name\"");
return None;
}
},
};
if !valid_namespace(&ns) || !valid_path(&p) {
cx.err(id_line, format!("id `{ns}:{p}` isn't a valid Minecraft id — lower case a-z, 0-9, `_`, `-`, `.` (and `/` in the path)"));
return None;
}
if !(1..=64).contains(&c.count) {
cx.err(line(&root.key("count")), format!("count: {} is out of range (1..=64)", c.count));
ok = false;
}
let kind = match body {
Body::Shaped(s) => {
let width = s.pattern.first().map_or(0, |r| r.chars().count());
if s.pattern.is_empty() || s.pattern.len() > 3 || width == 0 || width > 3 || s.pattern.iter().any(|r| r.chars().count() != width) {
cx.err(line(&root.key("pattern")), "pattern must be 1–3 rows of the same 1–3 characters each");
return None;
}
let mut key = Vec::new();
for (k, ing) in &s.key.0 {
let at = line(&root.key("key").key(k));
let mut chars = k.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) if ch != ' ' => {
if !s.pattern.iter().any(|r| r.contains(ch)) {
cx.warn(at, format!("key {k:?} isn't used by the pattern"));
}
key.push((ch, ing.ids()));
}
_ => {
cx.err(at, format!("key {k:?}: one character other than space"));
ok = false;
}
}
}
for ch in s.pattern.iter().flat_map(|r| r.chars()).filter(|c| *c != ' ') {
if !s.key.0.iter().any(|(k, _)| k.chars().eq([ch])) {
cx.err(line(&root.key("pattern")), format!("pattern uses {ch:?}, which key doesn't define"));
return None;
}
}
RecipeKind::Shaped { pattern: s.pattern, key }
}
Body::Shapeless(s) => {
if s.ingredients.is_empty() || s.ingredients.len() > 9 {
cx.err(line(&root.key("ingredients")), "ingredients must be 1–9 items or tags");
return None;
}
RecipeKind::Shapeless(s.ingredients.iter().map(|i| i.ids()).collect())
}
Body::Cooking(ty, k) => {
let (method, default_time) = match ty.as_str() {
"smelting" => ("Smelting", 200),
"blasting" => ("Blasting", 100),
"smoking" => ("Smoking", 100),
_ => ("Campfire", 600),
};
if !(0.0..=1000.0).contains(&k.experience) {
cx.err(line(&root.key("experience")), "experience: 0–1000");
ok = false;
}
let cook_time = k.cook_time.unwrap_or(default_time);
if !(1..=32767).contains(&cook_time) {
cx.err(line(&root.key("cook_time")), "cook_time: 1–32767 ticks");
ok = false;
}
RecipeKind::Cooking { method, input: k.ingredient.ids(), experience: k.experience, cook_time }
}
Body::Stonecutting(k) => RecipeKind::Stonecutting(k.ingredient.ids()),
Body::Smithing(k) => RecipeKind::Smithing { template: k.template.ids(), base: k.base.ids(), addition: k.addition.ids() },
Body::Custom(kind, k) => {
let own = mod_ns.is_some_and(|m| kind.split_once(':').is_some_and(|(n, p)| n == m && valid_path(p)));
if !own {
cx.err(
line(&root.key("type")),
format!("type `{kind}`: one of {}, or the mod's own kind `{}:<kind>`", recipe::TYPES.join(", "), mod_ns.unwrap_or("mymod")),
);
return None;
}
if c.category.is_some() {
cx.err(line(&root.key("category")), format!("category is for the game's recipe types; {kind} takes \"slots\" and \"values\""));
ok = false;
}
let slots = k.slots.0.iter().map(|(name, ings)| (name.clone(), ings.iter().map(|i| i.ids()).collect())).collect();
let values = k.values.0.iter().map(|(name, v)| (name.clone(), serde_json::from_str(&yog_data::text::to_string(v)).unwrap_or(Value::Null))).collect();
RecipeKind::Custom { kind, slots, values }
}
};
ok.then(|| Recipe {
id: format!("{ns}:{p}"),
ty,
kind,
result: c.result.id,
count: c.count,
group: c.group,
category: c.category,
})
}
fn inventory_screens(dir: &Path) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
out.extend(inventory_screens(&path));
continue;
}
if path.extension().is_none_or(|x| x != "yui") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if !text.contains("<Slot") && !text.contains("<PlayerInventory") {
continue;
}
if let Some(screen) = text.find("<Screen")
&& let Some(at) = text[screen..].find(" id=\"")
{
let rest = &text[screen + at + 5..];
if let Some(end) = rest.find('"') {
out.insert(rest[..end].to_string());
}
}
}
out
}
pub fn compile(root: &Path, mod_id: &str, refs: &yres::Refs) -> Result<Compiled, String> {
let mut out = Compiled::default();
let files = find(root);
if files.is_empty() {
return Ok(out);
}
let mod_ns = valid_namespace(mod_id).then_some(mod_id);
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut textures: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut generated: BTreeMap<String, String> = BTreeMap::new();
let mut entries: Vec<Entry> = Vec::new();
let mut ids: HashMap<String, String> = HashMap::new();
let mut recipes: Vec<Recipe> = Vec::new();
let mut recipe_ids: HashMap<String, String> = HashMap::new();
let mut books: Vec<BookFile> = Vec::new();
let mut book_ids: HashMap<String, String> = HashMap::new();
let mut inventories: Vec<(String, usize, String)> = Vec::new();
let mut pending_books: Vec<(String, String)> = Vec::new();
let mut pending_recipes: Vec<(String, String, PathBuf)> = Vec::new();
let mut pending_layouts: Vec<(String, String)> = Vec::new();
for path in &files {
let shown = display(root, path);
let Some(kind) = Kind::of(path) else { continue };
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
errors.push(format!("{shown}: {e}"));
continue;
}
};
if kind == Kind::Book {
pending_books.push((shown.clone(), text));
continue;
}
if kind == Kind::Recipe {
pending_recipes.push((shown.clone(), text, path.clone()));
continue;
}
if kind == Kind::Layout {
pending_layouts.push((shown.clone(), text));
continue;
}
let j = match json::parse(&text) {
Ok(j) => j,
Err(e) => {
errors.push(format!("{shown}:{}", e.trim_start_matches("line ")));
continue;
}
};
let mut cx = Ctx {
root,
refs,
file: shown.clone(),
ns: mod_ns.unwrap_or("minecraft").to_string(),
errors: &mut errors,
warnings: &mut warnings,
textures: &mut textures,
inventories: &mut inventories,
};
if let Some(entry) = compile_file(&mut cx, &j, kind, path, mod_ns, &mut out, &mut generated)
{
if let Some(other) = ids.insert(entry.id.clone(), shown.clone()) {
errors.push(format!(
"{shown}: id {} is also defined by {other}",
entry.id
));
}
entries.push(entry);
}
}
let externals = crate::manifest::YogToml::read(&root.join("yog.toml")).map(|t| t.externals).unwrap_or_default();
let mut known = refs::Known::new(mod_ns.unwrap_or("minecraft"), &externals);
for e in &entries {
known.add(if e.kind == Kind::Block { refs::RefKind::Block } else { refs::RefKind::Item }, e.id.clone());
}
for (shown, text, path) in &pending_recipes {
let mut cx = Ctx {
root,
refs,
file: shown.clone(),
ns: mod_ns.unwrap_or("minecraft").to_string(),
errors: &mut errors,
warnings: &mut warnings,
textures: &mut textures,
inventories: &mut inventories,
};
if let Some(r) = compile_recipe(&mut cx, text, path, mod_ns, &known) {
if let Some(other) = recipe_ids.insert(r.id.clone(), shown.clone()) {
errors.push(format!("{shown}: recipe {} is also defined by {other}", r.id));
}
recipes.push(r);
}
}
for r in &recipes {
known.add(refs::RefKind::Recipe, r.id.clone());
}
let mut layouts: Vec<yog_book::layout::RecipeLayout> = Vec::new();
let mut laid_out: HashMap<String, String> = HashMap::new();
for (shown, text) in &pending_layouts {
let mut cx = Ctx {
root,
refs,
file: shown.clone(),
ns: mod_ns.unwrap_or("minecraft").to_string(),
errors: &mut errors,
warnings: &mut warnings,
textures: &mut textures,
inventories: &mut inventories,
};
if let Some(l) = compile_layout(&mut cx, text, mod_ns, &known) {
for k in &l.kinds {
if let Some(other) = laid_out.insert(k.clone(), shown.clone()) {
errors.push(format!("{shown}: recipe kind {k} is also laid out by {other}"));
}
}
layouts.push(l);
}
}
let recipe_kinds: HashMap<String, String> = recipes.iter().map(|r| (r.id.clone(), r.ty.clone())).collect();
for (shown, text) in &pending_books {
let mut cx = Ctx {
root,
refs,
file: shown.clone(),
ns: mod_ns.unwrap_or("minecraft").to_string(),
errors: &mut errors,
warnings: &mut warnings,
textures: &mut textures,
inventories: &mut inventories,
};
if let Some(b) = compile_book(&mut cx, text, mod_ns, &known, &layouts, &recipe_kinds) {
if let Some(other) = book_ids.insert(b.id.clone(), shown.clone()) {
errors.push(format!("{shown}: book {} is also defined by {other}", b.id));
}
books.push(b);
}
}
if let Some(ns) = mod_ns {
let screens = inventory_screens(&root.join("src"));
for (file, line, id) in &inventories {
if id.split_once(':').is_some_and(|(n, _)| n == ns) && !screens.contains(id) {
errors.push(format!(
"{file}:{line}: block.inventory `{id}`: no .yui <Screen id=\"{id}\"> with <Slot>s in src/"
));
}
}
}
for (target, source) in &textures {
match std::fs::read(source) {
Ok(bytes) => out.outputs.push((target.clone(), bytes)),
Err(e) => errors.push(format!("{}: {e}", display(root, source))),
}
}
out.warnings = warnings;
if !errors.is_empty() {
return Err(format!(
"invalid blocks/items in content/:\n {}",
errors.join("\n ")
));
}
out.blocks = entries.iter().filter(|e| e.kind == Kind::Block).count();
out.items = entries.len() - out.blocks;
out.recipes = recipes.len();
out.books = books.len();
if (entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty())
&& !mentions_content_macro(&root.join("src"))
{
out.warnings.push(format!(
"{} has blocks/items, but src/ never calls `yog_api::content!();` — they get models but aren't registered",
CONTENT_DIR
));
}
out.source = Some(rust_source(&entries, &recipes, &books));
Ok(out)
}
fn mentions_content_macro(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
let p = e.path();
if p.is_dir() {
mentions_content_macro(&p)
} else {
p.extension().is_some_and(|x| x == "rs")
&& std::fs::read_to_string(&p).is_ok_and(|t| t.contains("content!("))
}
})
}
fn rust_ident(path: &str) -> String {
let mut s: String = path
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
if s.starts_with(|c: char| c.is_ascii_digit()) {
s.insert(0, '_');
}
s
}
fn float(n: f64) -> String {
let s = format!("{n:?}");
if s.contains('.') || s.contains('e') {
s
} else {
format!("{s}.0")
}
}
fn rust_source(entries: &[Entry], recipes: &[Recipe], books: &[BookFile]) -> String {
let mut code = String::from(
"// <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",
);
for (kind, set, def) in [
(Kind::Block, "Blocks", "BlockDef"),
(Kind::Item, "Items", "ItemDef"),
] {
let mine: Vec<&Entry> = entries.iter().filter(|e| e.kind == kind).collect();
let _ = writeln!(
code,
"\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} {{",
if kind == Kind::Block {
"blocks"
} else {
"items"
}
);
let mut used: HashMap<String, usize> = HashMap::new();
for e in mine {
let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
let mut ident = rust_ident(path);
let n = used.entry(ident.clone()).or_insert(0);
*n += 1;
if *n > 1 {
let _ = write!(ident, "_{n}");
}
let upper = ident.to_ascii_uppercase();
let ident = if is_keyword(&ident) {
format!("r#{ident}")
} else {
ident
};
let _ = writeln!(
code,
" /// `{}` — `{}`.\n pub const {upper}: &'static str = {:?};",
e.id, e.file, e.id
);
let mut b = format!("::yog_api::{def}::new(Self::{upper})");
match kind {
Kind::Recipe | Kind::Book | Kind::Layout => unreachable!("only blocks and items get here"),
Kind::Block => {
let p = &e.block;
if p.hardness.is_some() || p.resistance.is_some() {
let _ = write!(
b,
"\n .strength({}, {})",
float(p.hardness.unwrap_or(1.5)),
float(p.resistance.unwrap_or(6.0))
);
}
if let Some(s) = &p.sound {
let _ = write!(b, "\n .sound({s:?})");
}
if p.light > 0 {
let _ = write!(b, "\n .light_level({})", p.light);
}
if p.requires_tool {
b.push_str("\n .requires_tool()");
}
if p.no_collision {
b.push_str("\n .no_collision()");
}
if let Some(s) = p.slipperiness {
let _ = write!(b, "\n .slipperiness({})", float(s));
}
if let Some(s) = p.shape {
let args: Vec<String> = s.iter().map(|n| float(*n)).collect();
let _ = write!(b, "\n .shape({})", args.join(", "));
}
if p.connects {
b.push_str("\n .connects_to_neighbors()");
}
if !p.connect_groups.is_empty() {
let groups: Vec<String> =
p.connect_groups.iter().map(|g| format!("{g:?}")).collect();
let _ =
write!(b, "\n .connect_groups(&[{}])", groups.join(", "));
}
if let Some(inv) = &p.inventory {
let _ = write!(b, "\n .inventory({inv:?})");
}
if p.redstone {
b.push_str("\n .redstone_source()");
}
}
Kind::Item => {
let p = &e.item;
if let Some(s) = p.stack_size {
let _ = write!(b, "\n .max_stack({s})");
}
if p.durability > 0 {
let _ = write!(b, "\n .max_damage({})", p.durability);
}
if p.fire_resistant {
b.push_str("\n .fire_resistant()");
}
if p.fuel_ticks > 0 {
let _ = write!(b, "\n .fuel({})", p.fuel_ticks);
}
if let Some((n, s, always)) = p.food {
let _ = write!(
b,
"\n .food(::yog_api::FoodDef::new({n}, {}){})",
float(s),
if always { ".can_always_eat()" } else { "" }
);
}
}
}
let _ = writeln!(
code,
" /// The definition from `{}` (name and tooltip come from the lang files).\n pub fn {ident}() -> ::yog_api::{def} {{\n {b}\n }}",
e.file
);
}
code.push_str("}\n");
}
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");
let mut used: HashMap<(Kind, String), usize> = HashMap::new();
for e in entries {
let path = e.id.split_once(':').map_or(e.id.as_str(), |(_, p)| p);
let mut ident = rust_ident(path);
let n = used.entry((e.kind, ident.clone())).or_insert(0);
*n += 1;
if *n > 1 {
let _ = write!(ident, "_{n}");
}
let ident = if is_keyword(&ident) {
format!("r#{ident}")
} else {
ident
};
if !e.auto {
continue;
}
let _ = match e.kind {
Kind::Block => writeln!(code, " registry.register_block(Blocks::{ident}());"),
Kind::Item => writeln!(code, " registry.register_item(Items::{ident}());"),
Kind::Recipe | Kind::Book | Kind::Layout => unreachable!("only blocks and items get here"),
};
}
for r in recipes {
let _ = writeln!(code, " {}", r.registration());
}
for b in books {
let _ = writeln!(code, " registry.register_book_json({:?}, {:?});", b.id, b.json);
}
code.push_str("}\n");
if entries.iter().any(|e| e.auto) || !recipes.is_empty() || !books.is_empty() {
code.push_str(
"\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",
);
}
code
}
fn is_keyword(s: &str) -> bool {
const KW: &[&str] = &[
"as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
"for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
"return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
"where", "while", "async", "await", "dyn", "abstract", "become", "box", "do", "final",
"macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", "gen",
];
KW.contains(&s)
}
pub fn write_source(root: &Path, compiled: &Compiled) -> Result<(), String> {
let out = root.join(GENERATED_FILE);
let Some(code) = &compiled.source else {
let _ = std::fs::remove_file(&out);
return Ok(());
};
crate::write_if_changed(&out, code)?;
Ok(())
}
#[derive(Default)]
pub struct OutputSet {
items: Vec<Option<Output>>,
at: HashMap<String, usize>,
}
impl OutputSet {
pub fn get(&self, path: &str) -> Option<&[u8]> {
let i = *self.at.get(path)?;
self.items[i].as_ref().map(|(_, b)| b.as_slice())
}
pub fn contains(&self, path: &str) -> bool {
self.at.contains_key(path)
}
pub fn paths(&self) -> impl Iterator<Item = &String> {
self.at.keys()
}
pub fn put(&mut self, path: String, bytes: Vec<u8>) {
if let Some(i) = self.at.insert(path.clone(), self.items.len()) {
self.items[i] = None;
}
self.items.push(Some((path, bytes)));
}
pub fn into_vec(self) -> Vec<Output> {
self.items.into_iter().flatten().collect()
}
}
impl From<Vec<Output>> for OutputSet {
fn from(outputs: Vec<Output>) -> Self {
let mut set = OutputSet::default();
for (path, bytes) in outputs {
set.put(path, bytes);
}
set
}
}
fn json_output(outputs: &OutputSet, root: &Path, path: &str) -> Result<Map<String, Value>, String> {
let read;
let bytes = match outputs.get(path) {
Some(b) => b,
None => match std::fs::read(root.join(path)) {
Ok(b) => {
read = b;
&read
}
Err(_) => return Ok(Map::new()),
},
};
serde_json::from_slice::<Map<String, Value>>(bytes).map_err(|e| format!("{path}: {e}"))
}
pub fn merge_outputs(
root: &Path,
outputs: &mut Vec<Output>,
compiled: Compiled,
) -> Result<(), String> {
let mut set = OutputSet::from(std::mem::take(outputs));
let mut errors = Vec::new();
if !compiled.lang_aliases.is_empty() {
let mut lang_files: BTreeSet<String> =
set.paths().filter(|p| is_lang_path(p)).cloned().collect();
if let Ok(dirs) = std::fs::read_dir(root.join("assets")) {
for ns in dirs.flatten() {
if let Ok(files) = std::fs::read_dir(ns.path().join("lang")) {
for f in files.flatten() {
if let Ok(rel) = f.path().strip_prefix(root) {
let rel = rel.to_string_lossy().replace('\\', "/");
if is_lang_path(&rel) {
lang_files.insert(rel);
}
}
}
}
}
}
for path in lang_files {
let mut map = match json_output(&set, root, &path) {
Ok(m) => m,
Err(e) => {
errors.push(e);
continue;
}
};
let mut changed = false;
for (from, to) in &compiled.lang_aliases {
if let Some(text) = map.get(from).cloned()
&& !map.contains_key(to)
{
map.insert(to.clone(), text);
changed = true;
}
}
if changed {
set.put(path, pretty(&Value::Object(map)));
}
}
}
for ((ns, locale), entries) in &compiled.lang {
let path = format!("assets/{ns}/lang/{locale}.json");
let mut map = match json_output(&set, root, &path) {
Ok(m) => m,
Err(e) => {
errors.push(e);
continue;
}
};
for (k, v) in entries {
map.entry(k.clone())
.or_insert_with(|| Value::String(v.clone()));
}
set.put(path, pretty(&Value::Object(map)));
}
for (path, ids) in &compiled.tags {
let plural = path.replacen("/tags/block/", "/tags/blocks/", 1);
let path = if !set.contains(path) && root.join(&plural).is_file() {
plural
} else {
path.clone()
};
let mut map = match json_output(&set, root, &path) {
Ok(m) => m,
Err(e) => {
errors.push(e);
continue;
}
};
let values = map
.entry("values")
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(list) = values {
for id in ids {
if !list.iter().any(|v| v.as_str() == Some(id)) {
list.push(Value::String(id.clone()));
}
}
}
map.entry("replace").or_insert(Value::Bool(false));
set.put(path, pretty(&Value::Object(map)));
}
for (path, bytes) in compiled.outputs {
set.put(path, bytes);
}
*outputs = set.into_vec();
if errors.is_empty() {
Ok(())
} else {
Err(format!(
"merging blocks/items into the mod's files:\n {}",
errors.join("\n ")
))
}
}
fn is_lang_path(p: &str) -> bool {
let parts: Vec<&str> = p.split('/').collect();
parts.len() == 4 && parts[0] == "assets" && parts[2] == "lang" && parts[3].ends_with(".json")
}
#[cfg(test)]
mod tests {
use super::*;
fn project(files: &[(&str, &str)]) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"yog-content-test-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
let _ = std::fs::remove_dir_all(&dir);
for (path, text) in files {
let p = dir.join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
dir
}
fn output(c: &Compiled, path: &str) -> Value {
let (_, b) = c
.outputs
.iter()
.find(|(p, _)| p == path)
.unwrap_or_else(|| {
panic!(
"no {path} in {:?}",
c.outputs.iter().map(|(p, _)| p).collect::<Vec<_>>()
)
});
serde_json::from_slice(b).unwrap()
}
#[test]
fn compiles_a_cube_block() {
let root = project(&[
("resources/block/ruby_ore.png", "png"),
(
"content/blocks/ruby_ore.yblock",
r#"{
"name": "Ruby Ore",
"block": { "hardness": 3, "resistance": 3, "sound": "stone", "tool": "pickaxe", "tool_level": "iron", "requires_tool": true,
"drops": { "item": "mymod:ruby", "count": [1, 3] } },
"model": { "parent": "cube_all", "textures": { "all": "block/ruby_ore.png" } }
}"#,
),
]);
let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
assert_eq!(c.blocks, 1);
assert_eq!(
output(&c, "assets/mymod/models/block/ruby_ore.json"),
json!({ "parent": "minecraft:block/cube_all", "textures": { "all": "mymod:block/ruby_ore" } })
);
assert_eq!(
output(&c, "assets/mymod/blockstates/ruby_ore.json"),
json!({ "variants": { "": { "model": "mymod:block/ruby_ore" } } })
);
assert_eq!(
output(&c, "assets/mymod/models/item/ruby_ore.json"),
json!({ "parent": "mymod:block/ruby_ore" })
);
assert_eq!(
output(&c, "assets/mymod/items/ruby_ore.json")["model"]["model"],
json!("mymod:item/ruby_ore")
);
let loot = output(&c, "data/mymod/loot_table/blocks/ruby_ore.json");
assert_eq!(loot["pools"][0]["entries"][0]["name"], json!("mymod:ruby"));
assert_eq!(
loot["pools"][0]["entries"][0]["functions"][0]["count"]["max"],
json!(3)
);
assert!(
c.outputs
.iter()
.any(|(p, _)| p == "assets/mymod/textures/block/ruby_ore.png")
);
let src = c.source.as_ref().unwrap();
assert!(
src.contains("pub const RUBY_ORE: &'static str = \"mymod:ruby_ore\";"),
"{src}"
);
assert!(src.contains(".strength(3.0, 3.0)"), "{src}");
assert!(src.contains(".requires_tool()"), "{src}");
assert!(
src.contains("registry.register_block(Blocks::ruby_ore());"),
"{src}"
);
let mut outputs = Vec::new();
merge_outputs(&root, &mut outputs, c).unwrap();
let find = |p: &str| -> Value {
serde_json::from_slice(&outputs.iter().find(|(q, _)| q == p).unwrap().1).unwrap()
};
assert_eq!(
find("assets/mymod/lang/en_us.json")["block.mymod.ruby_ore"],
json!("Ruby Ore")
);
assert_eq!(
find("data/minecraft/tags/block/mineable/pickaxe.json")["values"],
json!(["mymod:ruby_ore"])
);
assert_eq!(
find("data/minecraft/tags/block/needs_iron_tool.json")["values"],
json!(["mymod:ruby_ore"])
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn raw_parent_with_elements_gets_a_particle() {
let root = project(&[(
"content/cable.yblock",
r##"{ "model": { "parent": "minecraft:block/block", "textures": { "all": "minecraft:block/stone" },
"elements": [ { "from": [5, 5, 5], "to": [11, 11, 11], "faces": { "up": { "texture": "#all" } } } ] } }"##,
)]);
let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
let m = output(&c, "assets/mymod/models/block/cable.json");
assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn compiles_elements_shape_and_connect() {
let root = project(&[(
"content/pipe.yblock",
r##"{
"block": { "render_layer": "cutout" },
"model": {
"textures": { "0": "minecraft:block/stone" },
"elements": [ { "from": [6, 6, 6], "to": [10, 10, 10],
"rotation": { "axis": "y", "angle": 22.5 },
"faces": { "north": { "texture": "#0", "uv": [0, 0, 4, 4], "cullface": "north" } } } ]
},
"states": { "connect": { "arm": { "textures": { "0": "minecraft:block/stone" },
"elements": [ { "from": [6, 6, 0], "to": [10, 10, 6], "faces": { "up": { "texture": "#0" } } } ] } } }
}"##,
)]);
let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
let m = output(&c, "assets/mymod/models/block/pipe.json");
assert_eq!(m["textures"]["particle"], json!("minecraft:block/stone"));
assert_eq!(m["render_type"], json!("minecraft:cutout"));
assert_eq!(m["elements"][0]["rotation"]["origin"], json!([8, 8, 8]));
let bs = output(&c, "assets/mymod/blockstates/pipe.json");
assert_eq!(bs["multipart"].as_array().unwrap().len(), 7);
assert_eq!(bs["multipart"][5]["apply"]["x"], json!(270));
let src = c.source.unwrap();
assert!(
src.contains(".shape(6.0, 6.0, 6.0, 10.0, 10.0, 10.0)"),
"{src}"
);
assert!(src.contains(".connects_to_neighbors()"), "{src}");
assert!(src.contains(".connect_groups(&[\"mymod:pipe\"])"), "{src}");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn reports_errors_with_lines() {
let root = project(&[(
"content/bad.yblock",
"{\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}",
)]);
let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
assert!(
e.contains("content/bad.yblock:6: model.elements[0].rotation.axis"),
"{e}"
);
assert!(
e.contains("content/bad.yblock:6: model.elements[0].rotation.angle is 30"),
"{e}"
);
assert!(
e.contains("content/bad.yblock:7: model.elements[0].faces.north.texture `#1`"),
"{e}"
);
assert!(
e.contains("content/bad.yblock:10: unknown key \"bogus\""),
"{e}"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn compiles_recipes() {
let root = project(&[
("content/ruby.yitem", r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#),
(
"content/recipes/ruby_block.yrecipe",
r##"{ "type": "shaped", "pattern": ["RR", "RR"], "key": { "R": "@item:mymod:ruby" }, "result": "@item:minecraft:emerald_block" }"##,
),
(
"content/recipes/ruby.yrecipe",
r##"{ "type": "shapeless", "ingredients": ["@item:minecraft:emerald", "@tag:item:c:dusts"], "result": "@item:mymod:ruby", "count": 2 }"##,
),
(
"content/recipes/smelt_ruby.yrecipe",
r##"{ "type": "smelting", "ingredient": "@tag:item:c:ores", "result": "@item:mymod:ruby", "experience": 0.7, "$comment": "metadata goes", "_editor": { "x": 1 } }"##,
),
(
"content/recipes/cut.yrecipe",
r##"{ "type": "stonecutting", "ingredient": ["@item:minecraft:stone", "@item:minecraft:andesite"], "result": "@item:mymod:ruby", "group": "rubies" }"##,
),
]);
let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
assert_eq!(c.recipes, 4);
let src = c.source.clone().unwrap();
for want in [
r#"kind: ::yog_api::RecipeKind::Shaped { pattern: vec!["RR".into(), "RR".into()], key: vec![('R', vec!["mymod:ruby".into()])] }, result: "minecraft:emerald_block".into(), count: 1"#,
r##"Shapeless { ingredients: vec![vec!["minecraft:emerald".into()], vec!["#c:dusts".into()]] }, result: "mymod:ruby".into(), count: 2"##,
r##"Cooking { method: ::yog_api::Cooking::Smelting, ingredient: vec!["#c:ores".into()], experience: 0.7_f32, cook_time: 200 }"##,
r##"Stonecutting { ingredient: vec!["minecraft:stone".into(), "minecraft:andesite".into()] }"##,
r##"group: Some("rubies".into())"##,
] {
assert!(src.contains(want), "missing {want}\n{src}");
}
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn recipe_mistakes() {
let bad = |recipe: &str| {
let root = project(&[("content/r.yrecipe", recipe)]);
let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
let _ = std::fs::remove_dir_all(root);
e
};
let e = bad(r#"{ "type": "shaped", "pattern": ["AB"], "key": { "A": "@item:minecraft:stick" }, "result": "@item:minecraft:stick" }"#);
assert!(e.contains("pattern uses 'B'"), "{e}");
let e = bad(r#"{ "type": "shapeless", "ingredients": ["stick"], "result": "@item:minecraft:stick" }"#);
assert!(e.contains("ingredients[0]: `stick`: name it by a reference — `@item:stick`"), "{e}");
let e = bad(r##"{ "type": "shapeless", "ingredients": ["#c:sand"], "result": "@item:minecraft:glass" }"##);
assert!(e.contains("`#c:sand`: write a tag as `@tag:item:c:sand`"), "{e}");
let e = bad(r##"{ "type": "smelting", "ingredient": "@item:minecraft:sand", "result": "@tag:item:c:glass" }"##);
assert!(e.contains("result: expected a reference (`@item:`), found a tag"), "{e}");
let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:sand"], "result": "@item:mymod:nothing" }"#);
assert!(e.contains("result: no item mymod:nothing in this mod"), "{e}");
let e = bad(r#"{ "type": "shapeless", "ingredients": ["@item:minecraft:nope"], "result": "@item:minecraft:glass" }"#);
assert!(e.contains("ingredients[0]: no item minecraft:nope in Minecraft"), "{e}");
let e = bad(r#"{ "type": "shapeless", "pattern": ["A"], "ingredients": ["@item:minecraft:sand"], "result": "@item:minecraft:glass" }"#);
assert!(e.contains("unknown field `pattern`"), "{e}");
let e = bad(r#"{ "type": "smeltin", "ingredient": "@item:minecraft:sand", "result": "@item:minecraft:glass" }"#);
assert!(e.contains("unknown `smeltin` — did you mean `smelting`?"), "{e}");
}
#[test]
fn block_inventory_names_a_screen() {
let block = r#"{ "block": { "inventory": "mymod:bench" }, "model": { "parent": "cube_all", "textures": { "all": "minecraft:block/stone" } } }"#;
let root = project(&[
("content/bench.yblock", block),
("src/bench.yui", r#"<Screen id="mymod:bench" width="176" height="166" x:ViewModel="Vm"><Slot index="0"/></Screen>"#),
]);
let c = compile(&root, "mymod", &yres::Refs::default()).unwrap();
assert!(c.source.unwrap().contains(r#".inventory("mymod:bench")"#));
let _ = std::fs::remove_dir_all(root);
let root = project(&[("content/bench.yblock", block)]);
let e = compile(&root, "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("no .yui <Screen id=\"mymod:bench\"> with <Slot>s"), "{e}");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn compiles_a_book() {
let book = r#"{
"id": "guide",
"name": "@Book.title",
"categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
"entries": [{
"id": "mymod:ruby", "name": "@item:mymod:ruby", "category": "@category:mymod:basics",
"pages": [
{ "type": "text", "text": "@@not a ref, {link=@entry:mymod:ruby|see}" },
{ "type": "spotlight", "item": "@item:mymod:ruby" },
{ "type": "entity", "entity": "@entity:minecraft:allay" }
]
}]
}"#;
let ruby = r#"{ "name": "Ruby", "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/emerald" } } }"#;
let root = project(&[("content/guide.ybook", book), ("content/ruby.yitem", ruby)]);
let mut refs = yres::Refs::default();
refs.texts.insert("Book.title".into(), ("mymod.book.title".into(), "Guide".into()));
let out = compile(&root, "mymod", &refs).unwrap();
assert_eq!(out.books, 1);
let src = out.source.unwrap();
assert!(src.contains("registry.register_book_json(\"mymod:guide\""), "{src}");
assert!(src.contains("mymod.book.title") && src.contains("item.mymod.ruby"), "{src}");
assert!(src.contains("not a ref") && src.contains("i18n"), "{src}");
assert!(src.contains(r#"\"item\":\"mymod:ruby\""#) && src.contains(r#"\"category\":\"mymod:basics\""#), "references become ids: {src}");
let bad = r#"{ "id": "x",
"categories": [{ "id": "c", "name": "C" }],
"entries": [{ "id": "e", "name": "E", "category": "c",
"pages": [
{ "type": "spotlight", "item": "minecraft:stick" },
{ "type": "spotlight", "item": "@item:minecraft:short_grass" },
{ "type": "recipe", "recipe": "@recipe:mymod:nope" },
{ "type": "entity", "entity": "@entity:create:train" }
] }] }"#;
let e = compile(&project(&[("content/bad.ybook", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("bad.ybook:3: entries[0].category: `c`: name it by a reference — `@category:c`"), "{e}");
assert!(e.contains("bad.ybook:5:") && e.contains("`@item:minecraft:stick`"), "{e}");
assert!(e.contains("not in 1.20.1"), "{e}");
assert!(e.contains("no recipe mymod:nope in this mod"), "{e}");
assert!(e.contains("[external.create]"), "{e}");
let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "entries": [{ "pages": [{ "type": "nope" }] }] }"#)]);
let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("entries[0].pages[0].type: unknown `nope`"), "{e}");
let bad = project(&[("content/bad.ybook", r#"{ "id": "x", "name": "X", "categories": [{ "id": "c", "name": "C" }] }"#)]);
let e = compile(&bad, "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("categories[0].id: `c`: write its namespace (`mymod:c`)"), "{e}");
}
#[test]
fn books_lay_out_the_mods_own_recipe_kinds() {
let recipe = r#"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"] }, result: "@item:minecraft:emerald" }"#;
let book = r#"{ id: "guide", name: "G", categories: [{ id: "mymod:c", name: "C" }],
entries: [{ id: "mymod:e", name: "E", category: "@category:mymod:c",
pages: [{ type: "recipe", recipe: "@recipe:mymod:infusion" }] }] }"#;
let layout = r#"{ kinds: ["mymod:infusion"], size: [96, 24], parts: [
{ type: "slots", slot: "core", at: [[4, 4]], frame: true },
{ type: "item", item: "@item:minecraft:beacon", at: [40, 4] },
{ type: "result", at: [76, 4], frame: true },
] }"#;
let files = [("content/infusion.yrecipe", recipe), ("content/guide.ybook", book)];
let e = compile(&project(&files), "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("entries[0].pages[0].recipe: no layout for recipe kind mymod:infusion — add content/infusion.ylayout"), "{e}");
let c = compile(&project(&[files[0], files[1], ("content/infusion.ylayout", layout)]), "mymod", &yres::Refs::default()).unwrap();
assert!(c.source.unwrap().contains(r#"\"layouts\":[{\"kinds\":[\"mymod:infusion\"]"#), "the book carries the mod's layouts");
let bad = r#"{ kinds: ["create:mixing"], size: [1, 1], parts: [{ type: "item", item: "@item:minecraft:nope", at: [0, 0] }] }"#;
let e = compile(&project(&[("content/bad.ylayout", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("kinds[0]: `create:mixing`: one of shaped") && e.contains("parts[0].item: no item minecraft:nope in Minecraft"), "{e}");
}
#[test]
fn compiles_a_recipe_of_the_mods_own_kind() {
let r = r##"{ type: "mymod:infusion", slots: { core: ["@item:minecraft:diamond"], ring: [["@item:minecraft:coal", "@item:minecraft:charcoal"], "@tag:item:c:dusts"] },
values: { time: 200 }, result: "@item:minecraft:emerald", count: 2 }"##;
let c = compile(&project(&[("content/infusion.yrecipe", r)]), "mymod", &yres::Refs::default()).unwrap();
let src = c.source.unwrap();
assert!(src.contains("registry.add_recipe_json(\"mymod:infusion\""), "{src}");
assert!(src.contains(r#"\"type\":\"mymod:infusion\""#) && src.contains(r#"\"time\":200"#), "{src}");
let bad = r#"{ "type": "mymod:infusion", "ingredient": "@item:minecraft:stone", "result": "@item:minecraft:emerald" }"#;
let e = compile(&project(&[("content/bad.yrecipe", bad)]), "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("unknown field `ingredient`") && e.contains("missing `slots`"), "{e}");
let other = r#"{ "type": "create:mixing", "slots": {}, "result": "@item:minecraft:emerald" }"#;
let e = compile(&project(&[("content/o.yrecipe", other)]), "mymod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("type `create:mixing`: one of shaped"), "{e}");
}
#[test]
fn compiles_an_item_with_resource_refs() {
let root = project(&[(
"content/items/ruby.yitem",
r#"{ "name": "@Items.ruby", "tooltip": "@Strings.ruby_tip",
"item": { "stack_size": 16, "food": { "nutrition": 4, "saturation": 0.3 } },
"model": { "parent": "generated", "textures": { "layer0": "@Textures.ruby" } } }"#,
)]);
let mut refs = yres::Refs::default();
refs.texts.insert(
"Items.ruby".into(),
("item.mymod.ruby".into(), "Ruby".into()),
);
refs.texts.insert(
"Strings.ruby_tip".into(),
("mymod.ruby_tip".into(), "Shiny".into()),
);
refs.images
.insert("Textures.ruby".into(), "mymod:item/ruby".into());
let c = compile(&root, "mymod", &refs).unwrap();
assert_eq!(
output(&c, "assets/mymod/models/item/ruby.json"),
json!({ "parent": "minecraft:item/generated", "textures": { "layer0": "mymod:item/ruby" } })
);
let src = c.source.clone().unwrap();
assert!(src.contains(".max_stack(16)"), "{src}");
assert!(
src.contains(".food(::yog_api::FoodDef::new(4, 0.3))"),
"{src}"
);
let mut outputs = vec![(
"assets/mymod/lang/ru_ru.json".to_string(),
r#"{"item.mymod.ruby": "Рубин", "mymod.ruby_tip": "Блестит"}"#
.as_bytes()
.to_vec(),
)];
merge_outputs(&root, &mut outputs, c).unwrap();
let ru: Value = serde_json::from_slice(&outputs[0].1).unwrap();
assert_eq!(ru["item.mymod.ruby.desc"], json!("Блестит"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn namespace_needed_for_odd_mod_ids() {
let root = project(&[(
"content/x.yitem",
r#"{ "model": { "parent": "generated", "textures": { "layer0": "minecraft:item/apple" } } }"#,
)]);
let e = compile(&root, "Example-Mod", &yres::Refs::default()).unwrap_err();
assert!(e.contains("give the namespace"), "{e}");
let _ = std::fs::remove_dir_all(root);
}
}