Item models: mods' own types fall back to vanilla's behaviour

An item definition with a node type, property, special model or tint source from
another namespace decodes to the closest vanilla behaviour instead of being
dropped (minecraft: names still fail). A definition that fails to load is logged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
umcekoandClaude Sonnet 5.5 committed 2026-11-02 19:56:14 +03:00
1 parent 43d5322894
commit 68c6f12e92
6 files changed
+351 -7

No files matched your search

+35
View File
@@ -17,6 +17,7 @@ use std::{
};
mod component_predicate;
mod foreign;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -451,12 +452,21 @@ impl ItemModelNode {
.collect::<Result<_>>()?,
transformation: optional_transformation(m)?,
},
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
Self::decode(required(m, "on_false")?)?,
optional_transformation(m)?,
),
"minecraft:condition" => Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::decode(m)?,
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
},
"minecraft:select" if foreign::property_is_foreign(m)? => {
// No case can match a property Horizon cannot read.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
}
"minecraft:select" => {
let transformation = optional_transformation(m)?;
let property = SelectProperty::decode(m)?;
@@ -484,6 +494,7 @@ impl ItemModelNode {
}
}
"minecraft:range_dispatch" => {
let foreign_property = foreign::property_is_foreign(m)?;
let mut entries = list(required(m, "entries")?)?
.iter()
.map(|e| {
@@ -496,6 +507,17 @@ impl ItemModelNode {
.collect::<Result<Vec<_>>>()?;
// `Comparator.comparingDouble`: Double.compare, a total order.
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
if foreign_property {
// A property Horizon cannot read is 0.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
let chosen = last_index_less_or_equal(&entries, 0.0)
.map(|i| entries.swap_remove(i).1)
.or(fallback);
return Ok(foreign::wrap(
chosen.unwrap_or(Self::Empty),
optional_transformation(m)?,
));
}
Self::RangeDispatch {
transformation: optional_transformation(m)?,
property: NumericProperty::decode(m)?,
@@ -506,12 +528,21 @@ impl ItemModelNode {
.transpose()?,
}
}
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
// The mod's own renderer is not run: the base model stands in.
Self::Model(ModelLeaf {
model: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
tints: Vec::new(),
})
}
"minecraft:special" => Self::Special {
base: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
model: SpecialModel::decode(required(m, "model")?)?,
},
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
_ => return Err(err("unknown item model type")),
})
}
@@ -603,6 +634,8 @@ impl TintSource {
"minecraft:firework" => Self::Firework(default()?),
"minecraft:potion" => Self::Potion(default()?),
"minecraft:team" => Self::Team(default()?),
// A mod's own tint source, which its Java code computes: untinted here.
other if foreign::is_foreign(other) => Self::Constant(-1),
_ => return Err(err("unknown item tint source")),
})
}
@@ -1717,5 +1750,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
Err(low as usize)
}
#[cfg(test)]
mod foreign_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,114 @@
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
use super::{
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
optional_transformation,
};
use crate::{json::error as err, math::Mat4};
use serde_json::{Map, Value};
/// Keys a mod's node usually keeps its real model under, in order of preference.
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
pub fn is_foreign(id: &str) -> bool {
!id.starts_with("minecraft:")
}
/// A composite carries the transformation a collapsed node had.
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
match transformation {
None => node,
Some(_) => ItemModelNode::Composite {
models: vec![node],
transformation,
},
}
}
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
let property = m
.get("property")
.ok_or_else(|| err("missing property"))
.and_then(ident)?;
Ok(is_foreign(&property))
}
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
let tints = get(m, "tints")
.and_then(|t| list(t).ok())
.map(|t| {
t.iter()
.filter_map(|t| TintSource::decode(t).ok())
.collect()
})
.unwrap_or_default();
ItemModelNode::Model(ModelLeaf {
model,
transformation: optional_transformation(m).ok().flatten(),
tints,
})
}
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
if kind == "neoforge:fluid_container" {
return fluid_container(m);
}
for key in MODEL_KEYS {
match m.get(key) {
Some(nested @ Value::Object(_)) => {
if let Ok(node) = ItemModelNode::decode(nested) {
return wrap(node, optional_transformation(m).ok().flatten());
}
}
Some(Value::String(id)) => {
if let Ok(id) = identifier(id) {
return leaf(id, m);
}
}
_ => {}
}
}
let by_suffix = m
.iter()
.filter(|(k, _)| k.ends_with("_model"))
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
match by_suffix {
Some(id) => leaf(id, m),
None => ItemModelNode::Empty,
}
}
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
let textures = m.get("textures").and_then(|t| object(t).ok());
let texture = |key: &str| {
textures
.and_then(|t| t.get(key))
.and_then(Value::as_str)
.and_then(|id| identifier(id).ok())
};
let mut layers = vec![texture("base")];
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
layers.push(texture("cover"));
}
let mut models: Vec<ItemModelNode> = layers
.into_iter()
.flatten()
.map(|model| {
ItemModelNode::Model(ModelLeaf {
model,
transformation: None,
tints: Vec::new(),
})
})
.collect();
let transformation = optional_transformation(m).ok().flatten();
match models.len() {
0 => ItemModelNode::Empty,
1 => wrap(models.remove(0), transformation),
_ => ItemModelNode::Composite {
models,
transformation,
},
}
}
@@ -0,0 +1,174 @@
use super::*;
fn node(json: &str) -> Result<ItemModelNode> {
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
}
fn model_id(n: &ItemModelNode) -> &str {
match n {
ItemModelNode::Model(leaf) => &leaf.model,
other => panic!("not a model: {other:?}"),
}
}
#[test]
fn node_with_a_model_id_draws_that_model() {
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
.unwrap();
assert_eq!(model_id(&n), "mod:item/thing");
let ItemModelNode::Model(leaf) = n else {
unreachable!()
};
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/base");
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/a");
}
#[test]
fn node_with_a_nested_node_decodes_it() {
let n = node(
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/boots");
let n = node(
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
)
.unwrap();
let ItemModelNode::Composite {
models,
transformation,
} = n
else {
panic!("a transformation wraps the nested node")
};
assert!(transformation.is_some());
assert_eq!(model_id(&models[0]), "mod:item/inner");
}
#[test]
fn node_with_nothing_usable_is_empty() {
assert!(matches!(
node(r#"{"type":"mod:facade"}"#).unwrap(),
ItemModelNode::Empty
));
assert!(matches!(
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
ItemModelNode::Empty
));
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
}
#[test]
fn unknown_vanilla_names_still_fail() {
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
for json in [
format!(
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
),
] {
assert!(node(&json).is_err(), "{json}");
}
assert!(
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
.is_err()
);
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
}
#[test]
fn condition_property_is_false() {
let n = node(
r#"{"type":"minecraft:condition","property":"mod:flag",
"on_true":{"type":"minecraft:model","model":"a:yes"},
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:no");
}
#[test]
fn select_property_takes_the_fallback_or_nothing() {
let n = node(
r#"{"type":"minecraft:select","property":"mod:level",
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:other");
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn range_property_reads_zero() {
let entry = |t: f32, m: &str| {
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
};
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
let make = |entries: &[String], fallback: &str| {
node(&format!(
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
entries.join(",")
))
.unwrap()
};
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
assert_eq!(model_id(&n), "a:fb");
let n = make(
&[
entry(0.0, "a:zero"),
entry(-1.0, "a:neg"),
entry(0.5, "a:half"),
],
fallback,
);
assert_eq!(model_id(&n), "a:zero");
let n = make(&[entry(0.2, "a:one")], "");
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn special_type_keeps_its_base_model() {
let n = node(
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/cube");
let n =
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
.unwrap();
assert!(matches!(n, ItemModelNode::Special { .. }));
}
#[test]
fn fluid_container_draws_its_base_without_the_fluid() {
let n = node(
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "minecraft:item/bucket");
let n = node(
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
)
.unwrap();
let ItemModelNode::Composite { models, .. } = n else {
panic!("base and cover")
};
assert_eq!(models.len(), 2);
assert!(matches!(
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
ItemModelNode::Empty
));
}
@@ -77,6 +77,13 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
}
impl SpecialModel {
/// A special model type of another namespace, which Horizon has no renderer for.
pub fn is_foreign(v: &Value) -> bool {
v.get("type")
.and_then(Value::as_str)
.and_then(|t| super::identifier(t).ok())
.is_some_and(|t| super::foreign::is_foreign(&t))
}
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
@@ -18,3 +18,13 @@ fn display_context_names_round_trip() {
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
}
}
#[test]
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
assert_eq!(
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
TintSource::Constant(-1)
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
+11 -7
View File
@@ -646,13 +646,17 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
};
let namespace = ns.file_name().to_string_lossy().into_owned();
for file in &folder.files {
if let Some(item) = file
.bytes()
.ok()
.and_then(|b| std::str::from_utf8(b).ok())
.and_then(|t| ClientItem::from_json(t).ok())
{
out.insert(format!("{namespace}:{}", file.name), item);
let Some(text) = file.bytes().ok().and_then(|b| std::str::from_utf8(b).ok()) else {
continue;
};
match ClientItem::from_json(text) {
Ok(item) => {
out.insert(format!("{namespace}:{}", file.name), item);
}
Err(e) => log::warn!(
"Horizon: item definition {namespace}:{} not loaded: {e}",
file.name
),
}
}
}