mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Preserve custom model data flags through the item render bridge
This commit is contained in:
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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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__':
|
||||
|
||||
Reference in new issue
Block a user