Preserve custom model data flags through the item render bridge

This commit is contained in:
voidsip committed 2026-11-05 21:20:17 -06:00
1 parent 6beb50ebcd
commit 23a880843d
4 files changed
+204 -9

No files matched your search

@@ -1,12 +1,53 @@
//! Actual stack decoding -> render view -> item-model foil selection, without a GPU.
use horizon_assets::item_model::{
ClientItem, Context, DisplayContext, FoilType, ItemModels, NoEnv,
ClientItem, Context, DisplayContext, FoilType, ItemModels, LayerContent, NoEnv,
};
use horizon_client::{item_data::decode_components, item_render::stack_view, items::ItemStack};
use horizon_world::nbt::Tag;
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs};
fn model_flags(input: &Value) -> horizon_client::Result<Value> {
let text = input["nbt"].as_str().ok_or("missing NBT text")?;
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
if used != text.len() {
return Err("trailing NBT".into());
}
let mut stack = ItemStack::new("minecraft:diamond", 1);
(stack.damage, stack.extra) =
decode_components(Some(&Tag::compound([("minecraft:custom_model_data", tag)])));
let view = stack_view(&stack);
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond".into(),
BTreeMap::from([("minecraft:item_model".into(), json!("minecraft:diamond"))]),
);
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&json!({
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": input["index"],
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
}))?);
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&NoEnv,
);
if resolved.layers.len() != 1 {
return Err("expected one conditional model layer".into());
}
let LayerContent::Model { model } = &resolved.layers[0].content else {
return Err("expected model content".into());
};
Ok(
json!({"id": input["id"], "component": view.patch["minecraft:custom_model_data"], "selected": model}),
)
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let mut models = ItemModels::default();
@@ -19,6 +60,11 @@ pub fn main() -> horizon_client::Result<()> {
let mut output = String::new();
for line in fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?.lines() {
let input: Value = serde_json::from_str(line)?;
if input["kind"] == "flags" {
output.push_str(&serde_json::to_string(&model_flags(&input)?)?);
output.push('\n');
continue;
}
let item = input["item"].as_str().ok_or("missing item")?;
models.prototypes.insert(
item.into(),
+89
View File
@@ -1036,6 +1036,34 @@ pub fn tag_json(tag: &Tag) -> Value {
Tag::LongArray(a) => Value::Array(a.iter().map(|v| Value::from(*v)).collect()),
}
}
/// Custom model flags use Codec.BOOL even though NBT stores them as numbers.
/// Keep the other custom-model fields, and generic NBT JSON, numeric.
fn custom_model_data_json(tag: &Tag) -> Value {
let mut value = tag_json(tag);
let flags: Option<Vec<Value>> = match tag.get("flags") {
Some(Tag::List(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(flag).map(Value::Bool))
.collect(),
Some(Tag::ByteArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Byte(*flag as i8)).map(Value::Bool))
.collect(),
Some(Tag::IntArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Int(*flag)).map(Value::Bool))
.collect(),
Some(Tag::LongArray(flags)) => flags
.iter()
.map(|flag| crate::item_data::component_bool(&Tag::Long(*flag)).map(Value::Bool))
.collect(),
_ => None,
};
if let (Some(flags), Some(object)) = (flags, value.as_object_mut()) {
object.insert("flags".into(), Value::Array(flags));
}
value
}
/// The resolver's view of a client stack: its item, count and component patch.
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
let mut view = StackView::new(&stack.item, stack.count as i32);
@@ -1052,6 +1080,8 @@ pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
crate::item_data::component_bool(v)
.map(Value::Bool)
.unwrap_or_else(|| tag_json(v))
} else if key == "minecraft:custom_model_data" {
custom_model_data_json(v)
} else {
tag_json(v)
};
@@ -1183,6 +1213,65 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64
mod tests {
use super::*;
#[test]
fn custom_model_flags_select_their_model_after_stack_nbt_decoding() {
let mut actual = Vec::new();
for flags in [
Tag::List(vec![Tag::Byte(1), Tag::Byte(0)]),
Tag::ByteArray(vec![1, 0]),
] {
let mut stack = crate::items::ItemStack::new("minecraft:diamond", 1);
let component = Tag::compound([
("flags", flags),
("floats", Tag::List(vec![Tag::Float(1.25f32.to_bits())])),
("colors", Tag::List(vec![Tag::Int(0x12_34_56)])),
("strings", Tag::List(vec![Tag::String("keep".into())])),
]);
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(
&Tag::compound([("minecraft:custom_model_data", component)]),
));
let view = stack_view(&stack);
for index in 0..3 {
let mut models = ItemModels::default();
models.prototypes.insert(
"minecraft:diamond".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
serde_json::json!("minecraft:diamond"),
)]),
);
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&serde_json::json!({
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": index,
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
})).unwrap());
let resolved = models.resolve(
&view,
&Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
},
&horizon_assets::item_model::NoEnv,
);
assert_eq!(resolved.layers.len(), 1);
actual.push(
resolved.layers[0].content
== LayerContent::Model {
model: "horizon:item/yes".into(),
},
);
}
// Non-boolean fields must retain their codec's numeric/string values.
let component = view.patch["minecraft:custom_model_data"].as_ref().unwrap();
assert_eq!(component["floats"], serde_json::json!([1.25]));
assert_eq!(component["colors"], serde_json::json!([0x12_34_56]));
assert_eq!(component["strings"], serde_json::json!(["keep"]));
}
assert_eq!(actual, [true, false, false, true, false, false]);
}
#[test]
fn posed_item_bounds_include_each_cluster_transform() {
let state = ItemRenderState {
@@ -0,0 +1,40 @@
package dev.horizonlab.parity;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.client.renderer.item.properties.conditional.CustomModelDataProperty;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.NbtOps;
import net.minecraft.nbt.TagParser;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Actual component codec and client conditional property, without graphics. */
public final class VanillaStackModelFlagsOracle {
public static void main(String[] args) throws Exception {
ParityData.init();
try (var out = Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)) {
for (var line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) {
var input = JsonParser.parseString(line).getAsJsonObject();
var codec = DataComponents.CUSTOM_MODEL_DATA.codec();
var tag = TagParser.parseCompoundFully(input.get("nbt").getAsString());
var data = codec.parse(NbtOps.INSTANCE, tag).getOrThrow();
var stack = new ItemStack(Items.DIAMOND);
stack.set(DataComponents.CUSTOM_MODEL_DATA, data);
var property = new CustomModelDataProperty(input.get("index").getAsInt());
boolean selected = property.get(stack, null, null, 0, ItemDisplayContext.GUI);
var result = new JsonObject();
result.add("id", input.get("id"));
result.add("component", codec.encodeStart(JsonOps.INSTANCE, data).getOrThrow());
result.addProperty("selected", selected ? "horizon:item/yes" : "horizon:item/no");
out.write(result.toString());
out.newLine();
}
}
}
}
+28 -8
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Check NBT glint overrides through the real stack-to-render bridge against vanilla.
"""Check NBT boolean item components through the real stack-to-render bridge.
Uses actual DataComponents.ENCHANTMENT_GLINT_OVERRIDE codecs and ItemStack.hasFoil
on both pinned JARs. Rust decodes stack NBT, constructs the production render view
and resolves a synthetic model layer. No game window, sprites or GPU are needed.
and resolves synthetic foil and custom-model flag layers. No game window, sprites or GPU are needed.
"""
import json
import os
@@ -29,6 +29,15 @@ def corpus():
yield case
def flags_corpus():
forms = ['[B;1b,0b]', '[I;1,0]', '[L;1L,0L]', '[1b,0b]', '[1s,0s]',
'[1,0]', '[1L,0L]', '[0.5f,-0.0f]', '[0.5d,-0.0d]']
for form in forms:
nbt = '{flags:' + form + ',floats:[1.25f],colors:[1193046],strings:["keep"]}'
for index in range(3):
yield {'id': f'{form}:{index}', 'kind': 'flags', 'nbt': nbt, 'index': index}
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'stack-foil-parity'
@@ -38,8 +47,11 @@ def main():
inputs = build / 'cases.jsonl'
inputs.write_text(''.join(json.dumps(c) + '\n' for c in corpus()), encoding='utf-8')
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaStackFoilOracle.java'
flags_oracle = oracle.with_name('VanillaStackModelFlagsOracle.java')
flags_inputs = build / 'flags-cases.jsonl'
flags_inputs.write_text(''.join(json.dumps(c) + '\n' for c in flags_corpus()), encoding='utf-8')
probe = lab.ROOT / 'crates/horizon-client/examples/probe/stack_foil_fixture.rs'
sources = [Path(__file__), oracle, oracle.with_name('ParityData.java'), probe,
sources = [Path(__file__), oracle, flags_oracle, oracle.with_name('ParityData.java'), probe,
lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'Cargo.lock', lab.ROOT / 'crates/horizon-client/src/item_render.rs',
lab.ROOT / 'crates/horizon-items/src/item_data.rs', lab.ROOT / 'crates/horizon-assets/src/item_model.rs']
tested = base.source_hashes(sources)
@@ -48,13 +60,14 @@ def main():
deps = os.pathsep.join(map(str, libraries))
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none',
'-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'),
'-cp', str(jars['client']) + os.pathsep + deps, '-d', str(classes), str(oracle)], check=True)
'-cp', str(jars['client']) + os.pathsep + deps, '-d', str(classes), str(oracle), str(flags_oracle)], check=True)
subprocess.run(['cargo', 'build', '--locked', '-p', 'horizon-client-lib', '--example', 'client_probe'],
cwd=lab.ROOT, check=True)
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps',
'--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'debug/examples' / ('client_probe.exe' if os.name == 'nt' else 'client_probe')
subprocess.run([str(binary), 'stack_foil_fixture', str(inputs), str(build / 'rust.jsonl')], check=True)
subprocess.run([str(binary), 'stack_foil_fixture', str(flags_inputs), str(build / 'rust-flags.jsonl')], check=True)
native = []
# Canonical JSON text keeps bool distinct from int (Python otherwise treats True == 1).
def rows(path):
@@ -65,15 +78,22 @@ def main():
subprocess.run(['java', '-Xmx512M', '-cp', os.pathsep.join([str(classes), str(jar), deps]),
'dev.horizonlab.parity.VanillaStackFoilOracle', str(inputs), str(output)], check=True)
expected = rows(output)
assert len(actual) == len(expected), f'{side}: output length differs'
assert len(actual) == len(expected) == len(list(corpus())), f'{side}: output length differs'
assert actual == expected, f'{side}: stack foil differs: {[(a,b) for a,b in zip(actual,expected) if a != b][:3]}'
native.append({'side': side, 'sha256': lab.digest(jar, 'sha256'), 'matched': len(expected)})
flags_output = build / 'client-flags.jsonl'
subprocess.run(['java', '-Xmx512M', '-cp', os.pathsep.join([str(classes), str(jars['client']), deps]),
'dev.horizonlab.parity.VanillaStackModelFlagsOracle', str(flags_inputs), str(flags_output)], check=True)
actual_flags, expected_flags = rows(build / 'rust-flags.jsonl'), rows(flags_output)
assert len(actual_flags) == len(expected_flags) == len(list(flags_corpus())), 'Custom model flag output length differs'
assert actual_flags == expected_flags, f'Custom model flags differ: {[(a,b) for a,b in zip(actual_flags,expected_flags) if a != b][:3]}'
native.append({'side': 'client', 'component': 'custom_model_data.flags', 'matched': len(actual_flags)})
base.ensure_sources_unchanged(sources, tested)
lab.write_json(report_path, {'schema': 1, 'platform': platform.platform(),
'manifest_sha1': lock['minecraft']['manifest_sha1'], 'minecraft': lock['minecraft']['version'], 'cases': len(actual),
'manifest_sha1': lock['minecraft']['manifest_sha1'], 'minecraft': lock['minecraft']['version'], 'cases': len(actual), 'custom_model_flag_cases': len(actual_flags),
'references': native, 'sources_sha256': tested,
'scope': 'NBT boolean decoding, JSON render bridge and standard model-layer foil. No GPU drawing or invalid command validation.'})
print(f'Stack foil: {len(actual)} cases match each pinned vanilla JAR; {report_path}')
'scope': 'NBT boolean decoding, JSON render bridge, standard model-layer foil and custom-model conditional selection. No GPU drawing or invalid command validation.'})
print(f'Stack boolean components: {len(actual)} foil cases match each pinned JAR; {len(actual_flags)} model flag cases match the client; {report_path}')
if __name__ == '__main__':