Resolve enchantment-dependent component item models

Co-Authored-By: Codex GPT-6.1 <[email protected]>
This commit is contained in:
oskal675-designandCodex GPT-6.1 committed 2026-10-31 17:28:33 +05:00
1 parent 3c68c05d0a
commit c6c043e8b5
4 files changed
+238 -15

No files matched your search

+10 -10
View File
@@ -16,6 +16,7 @@ use std::{
sync::{Arc, Mutex},
};
mod component_predicate;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
ExtendedView,
KeybindDown(String),
ViewEntity,
/// A `component` predicate, evaluated by the host.
/// A single `component` predicate; external predicate types use the host.
Component(Value),
}
impl ConditionProperty {
@@ -651,13 +652,7 @@ impl ConditionProperty {
"minecraft:extended_view" => Self::ExtendedView,
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
"minecraft:view_entity" => Self::ViewEntity,
"minecraft:component" => {
let predicate = required(m, "predicate")?;
if object(predicate)?.len() != 1 {
return Err(err("a single component predicate is expected"));
}
Self::Component(predicate.clone())
}
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
_ => return Err(err("unknown condition property")),
})
}
@@ -964,10 +959,14 @@ pub trait ItemEnv {
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
0.0
}
/// `ItemStack.getMaxStackSize` when the prototype has no component.
/// Additional component predicate types supplied by the host.
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
false
}
/// Membership in the level's enchantment registry tags.
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
false
}
fn is_keybind_down(&self, _keybind: &str) -> bool {
false
}
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
ConditionProperty::Component(predicate) => {
self.env.component_matches(self.stack, predicate)
component_predicate::matches(predicate, self.components, self.env)
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
}
}
}
@@ -0,0 +1,177 @@
//! Component conditions used by enchantment-dependent item models.
use super::*;
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
/// (`predicate`) and its wrapped codec's separate `value` field.
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
let id = component_type(required(m, "predicate")?)?;
let mut value = required(m, "value")?.clone();
if matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
let mut entries = Vec::new();
for entry in list(&value)? {
let mut entry = object(entry)?.clone();
if let Some(selector) = get(&entry, "enchantments") {
let selector = match selector {
Value::Array(ids) => Value::Array(
ids.iter()
.map(ident)
.map(|r| r.map(Value::String))
.collect::<Result<_>>()?,
),
Value::String(name) if name.starts_with('#') => {
Value::String(format!("#{}", identifier(&name[1..])?))
}
_ => Value::String(ident(selector)?),
};
entry.insert("enchantments".into(), selector);
}
if let Some(levels) = get(&entry, "levels") {
let levels = if levels.is_object() {
let mut bounds = Map::new();
for key in ["min", "max"] {
if let Some(n) = get(object(levels)?, key) {
bounds.insert(key.into(), Value::from(int(n)?));
}
}
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
if bounds
.get("min")
.and_then(Value::as_i64)
.zip(bounds.get("max").and_then(Value::as_i64))
.is_some_and(|(min, max)| min > max)
{
return Err(err("minimum exceeds maximum"));
}
Value::Object(bounds)
} else {
Value::from(int(levels)?)
};
entry.insert("levels".into(), levels);
}
entries.push(Value::Object(entry));
}
value = Value::Array(entries);
}
Ok(Value::Object(Map::from_iter([(id, value)])))
}
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
/// all entries must match; each entry needs one selected, present enchantment
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
if !matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
return None;
}
let Some(have) = components.get(id).and_then(Value::as_object) else {
return Some(false);
};
Some(expected.as_array()?.iter().all(|entry| {
let Some(entry) = entry.as_object() else {
return false;
};
have.iter().any(|(id, level)| {
let level = level.as_i64().unwrap_or(0);
let selector = get(entry, "enchantments");
let selected = match selector {
None => true,
Some(Value::String(s)) => s
.strip_prefix('#')
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
_ => false,
};
selected
&& if selector.is_some() {
matches_enchantment(level, get(entry, "levels"))
} else {
level_matches(level, get(entry, "levels"))
}
})
}))
}
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
/// nonzero level even when its bounds accept zero.
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
level != 0 && level_matches(level, bounds)
}
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
match bounds {
None => true,
Some(Value::Number(n)) => n.as_i64() == Some(level),
Some(Value::Object(bounds)) => {
get(bounds, "min")
.and_then(Value::as_i64)
.is_none_or(|min| level >= min)
&& get(bounds, "max")
.and_then(Value::as_i64)
.is_none_or(|max| level <= max)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct Tags;
impl ItemEnv for Tags {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
enchantment == "minecraft:density" && tag == "horizon:mace"
}
}
#[test]
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
let components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
&NoEnv
),
Some(false)
);
}
#[test]
fn enchantment_selectors_use_the_hosts_registry_tags() {
let condition = json!({"predicate":"enchantments", "value":[
{"enchantments":"#horizon:mace", "levels":{"min":3}}
]});
let predicate = decode(condition.as_object().unwrap()).unwrap();
let mut components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
@@ -22,6 +22,9 @@ pub(super) struct Env<'a, 'g> {
game: &'a Game<'g>,
}
impl ItemEnv for Env<'_, '_> {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
self.game.stations().enchantments.in_tag(tag, enchantment)
}
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
let id = contents["potion"].as_str();
+48 -5
View File
@@ -3,14 +3,16 @@
The vanilla side (VanillaItemModelOracle) decodes every `items/*.json` with the
client codec, discovers and bakes all models with `ModelBakery` against real
PNG sprites, and resolves stacks through `ItemStackRenderState` layers. The
Rust side (`item_model_fixture`) must pick the same layers, tints, foil,
PNG sprites, and resolves stacks through `ItemStackRenderState` layers.
Pass `--component-conditions` to run only the synthetic component-condition corpus.
The Rust side (`item_model_fixture`) must pick the same layers, tints, foil,
transformations, special-renderer configurations and baked quads."""
import json
import os
import platform
import random
import subprocess
import sys
import zipfile
from pathlib import Path
try:
@@ -246,6 +248,38 @@ def definitions():
return rows
def component_definitions():
"""Original fixtures for ComponentMatches' discriminator and enchantment rules."""
leaf = lambda name: {'type': 'minecraft:model', 'model': 'minecraft:item/' + name}
def condition(value, predicate='minecraft:enchantments'):
return {'type': 'minecraft:condition', 'property': 'minecraft:component',
'predicate': predicate, 'value': value,
'on_true': leaf('diamond'), 'on_false': leaf('stick')}
values = [[], [{}], [{'enchantments': 'minecraft:density'}],
[{'enchantments': 'density', 'levels': {'min': 3}}],
[{'enchantments': ['minecraft:density', 'minecraft:breach'], 'levels': {'max': 4}}],
[{'enchantments': [], 'levels': {'min': 0}}],
[{'enchantments': 'minecraft:wind_burst', 'levels': 2}],
[{'levels': {'min': 2, 'max': 4}}], [{'levels': {'max': 0}}],
[{'levels': {}}],
[{'enchantments': 'minecraft:breach'}, {'enchantments': 'minecraft:wind_burst'}]]
rows = {f'horizon:component_enchant_{i}': {'model': condition(value)} for i, value in enumerate(values)}
rows['horizon:component_stored'] = {'model': condition([{'enchantments': 'minecraft:density'}],
'minecraft:stored_enchantments')}
rows['horizon:component_composite'] = {'model': {'type': 'minecraft:composite', 'models': [
condition([{'enchantments': 'minecraft:density'}]),
dict(condition([{'enchantments': 'minecraft:wind_burst'}]), on_false={'type': 'minecraft:empty'})]}}
rows['horizon:component_nested'] = {'model': dict(condition([{'enchantments': 'minecraft:density'}]),
on_false=condition([{'enchantments': 'minecraft:breach'}]))}
rows['horizon:component_missing_value'] = {'model': condition([])}
del rows['horizon:component_missing_value']['model']['value']
rows['horizon:component_old_object'] = {'model': dict(condition([]), predicate={'minecraft:enchantments': []})}
rows['horizon:component_bad_list'] = {'model': condition({})}
rows['horizon:component_bad_levels'] = {'model': condition([{'levels': 'bad'}])}
rows['horizon:component_reversed_levels'] = {'model': condition([{'levels': {'min': 5, 'max': 2}}])}
return {name: json.dumps(row) for name, row in rows.items()}
def definition_cases(rows):
cases = []
stacks = [('minecraft:diamond_sword', {'minecraft:damage': 100}, 1), ('minecraft:diamond_sword', {'minecraft:damage': 1560}, 1),
@@ -260,6 +294,13 @@ def definition_cases(rows):
('minecraft:bundle', {'minecraft:bundle_contents': []}, 1),
('minecraft:stone', {'minecraft:enchantments': {'minecraft:sharpness': 1}}, 1),
('minecraft:stone', {'minecraft:custom_name': '"x"'}, 1)]
stacks += [
('minecraft:mace', {}, 1),
('minecraft:mace', {'minecraft:enchantments': {'minecraft:density': 5}}, 1),
('minecraft:mace', {'minecraft:enchantments': {'minecraft:breach': 4, 'minecraft:wind_burst': 2}}, 1),
('minecraft:enchanted_book', {'minecraft:stored_enchantments': {'minecraft:density': 3}}, 1),
('minecraft:mace', {'!minecraft:enchantments': {}}, 1),
('minecraft:mace', {'minecraft:enchantments': {'minecraft:density': 0}}, 1)]
for name in rows:
for stack_index, (item, components, count) in enumerate(stacks):
for context in ('gui', 'head', 'firstperson_lefthand', 'ground'):
@@ -314,8 +355,10 @@ def main():
_, directory = lab.config()
prototypes_path = lab.LOCAL / 'horizon-client' / lock['minecraft']['version'] / 'items.json'
prototypes = json.loads(prototypes_path.read_text())
cases = corpus(prototypes)
rows = definitions()
focused = '--component-conditions' in sys.argv
cases = [] if focused else corpus(prototypes)
rows = {} if focused else definitions()
rows.update(component_definitions())
cases += definition_cases(rows)
inputs = build / 'cases.jsonl'; inputs.write_text(''.join(json.dumps(c) + '\n' for c in cases))
(build / 'cases.jsonl.definitions').write_text(''.join(json.dumps({'name': n, 'json': j}) + '\n' for n, j in rows.items()))
@@ -407,7 +450,7 @@ def main():
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
'cases': len(cases), 'compared': compared, 'rejected_by_vanilla': skipped, 'definitions_decoded': decoded,
'definitions_unbaked': unbaked, 'sources_sha256': tested,
'scope': 'Actual ClientItem codec, ModelDiscovery, ModelBakery item baking and ItemModelResolver semantics against real sprites in a unit atlas. Every bundled items/*.json and synthetic definitions; every item prototype in every display context with representative components. Layer content, tints, foil, composed transformation matrices (numeric equality), special-renderer codec forms and arguments, baked quads by float bits, render properties. Not covered: GPU rendering, atlas packing, level-dependent needles (see the needle tests), player/keybind/team-based properties that need a live client.'}
'scope': ('Synthetic component-condition corpus only. ' if focused else '') + 'Actual ClientItem codec, ModelDiscovery, ModelBakery item baking and ItemModelResolver semantics against real sprites in a unit atlas. Every bundled items/*.json is decoded. Full mode resolves every item prototype in every display context; focused mode resolves synthetic component conditions on representative stacks. Layer content, tints, foil, composed transformation matrices (numeric equality), special-renderer codec forms and arguments, baked quads by float bits, render properties. Not covered: GPU rendering, atlas packing, level-dependent needles (see the needle tests), player/keybind/team-based properties that need a live client.'}
lab.write_json(report_path, report)
print(f'Item models: {compared:,} resolved stacks match ({skipped:,} rejected by vanilla, {decoded:,} definitions); {report_path}', flush=True)