Resolve vanilla model inheritance and texture aliases
This commit is contained in:
@@ -9,17 +9,20 @@
|
||||
"synthetic_models": 2337,
|
||||
"rejected_models": 1034,
|
||||
"sources_sha256": {
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "f6618f16614a47eb62b1cab40ebd514f075aa8590466a884f7e24e9f1844b065",
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "7526f20513c549483122ab0d5ad24b9e87f38dc2f6aae55addd701a91ac85e43",
|
||||
"tools/test_cuboid_model_parity.py": "3b7c018570b0f609aae43cef60ff3139c543d5d629be804a5aa257e3ab2dd67c",
|
||||
"tools/lab.py": "d00bf107fa01b6c579734a082c3ca4ade3018f0d6851d46844acf17574f66e64",
|
||||
"tools/test_rust_parity.py": "29d3db08005873528894b45ca10dcd6278753650473b3eb574e53e06ede40ec6",
|
||||
"Cargo.toml": "3f93319c1eb70dbd7ba25cd74ef343fbf35c52c7c4752f955605c58699cbff9d",
|
||||
"Cargo.lock": "7b4ab0ffa35af7ea8b6e38525f18ff6c3e94768198915f5ffe560d84263423f8",
|
||||
"crates/horizon-assets/Cargo.toml": "fadb78cf819e026500606c45d27138ed67fcbbc967ae8cee6c1e7c82c9046b36",
|
||||
"crates/horizon-assets/examples/cuboid_model_fixture.rs": "03e44a7040326660b9681e17a78de86d6f5ff295f1cbef69b6fcb64ab4282e8b",
|
||||
"crates/horizon-assets/examples/cuboid_model_fixture.rs": "97609d90e3f6ce340f3590296db2dc778113bb2b33c7d1b54080a1d6f3023f40",
|
||||
"crates/horizon-assets/examples/model_resolution_fixture.rs": "c071067140299e49345a6ebd94e15b3b6d853cb9ae11d8de46e3cbdfb8b01f0e",
|
||||
"crates/horizon-assets/examples/support/mod.rs": "bcf5a6d8a54845982191e037911c24139237771d6afa4b01b1e83c011489c3f5",
|
||||
"crates/horizon-assets/src/json.rs": "e7b89bf4852f561a0e5bf1a984857c61e755961dfd86fcc326b6d55558615f39",
|
||||
"crates/horizon-assets/src/lib.rs": "3dbc77b4438dbc1d77e6fb9c61fccbc29fd14ac90a54d35c1518385765cbe13e",
|
||||
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b"
|
||||
"crates/horizon-assets/src/lib.rs": "0d31dd0fa84d7b4467a78a1a2b8a8e179533ef5e64a7cbae88e5358e59160a40",
|
||||
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b",
|
||||
"crates/horizon-assets/src/resolve.rs": "3927ac926cdcbd7d334901cc32d0ecc1e498b1bec98f76a46bca6d4964c13541"
|
||||
},
|
||||
"fixtures_sha256": {
|
||||
".local/rust-cuboid-model-parity/inputs.jsonl": "9c1822180e1d6869e65eea81a38cbcf3786b9286d4c839e31ba686501694d171"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"minecraft": "26.3",
|
||||
"manifest_sha1": "4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3",
|
||||
"platform": "Linux-7.0.0-31-generic-x86_64-with-glibc2.43",
|
||||
"java": "openjdk 25.0.4.1 2026-08-18",
|
||||
"rustc": "rustc 1.99.0 (b940084d7 2026-09-28)",
|
||||
"resource_models": 2883,
|
||||
"graph_fixtures": 439,
|
||||
"rejected_cyclic_roots": 702,
|
||||
"sources_sha256": {
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaModelResolutionOracle.java": "bd6581d68c69a96586d158ede8be4f3270d0d7a16288ca82dddab16785685b26",
|
||||
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "7526f20513c549483122ab0d5ad24b9e87f38dc2f6aae55addd701a91ac85e43",
|
||||
"tools/test_model_resolution_parity.py": "d9595f2f1442f1f590b72d2a46600657e79ae103bec3d4c45db98242b964bde0",
|
||||
"tools/test_cuboid_model_parity.py": "3b7c018570b0f609aae43cef60ff3139c543d5d629be804a5aa257e3ab2dd67c",
|
||||
"tools/lab.py": "d00bf107fa01b6c579734a082c3ca4ade3018f0d6851d46844acf17574f66e64",
|
||||
"tools/test_rust_parity.py": "29d3db08005873528894b45ca10dcd6278753650473b3eb574e53e06ede40ec6",
|
||||
"Cargo.toml": "3f93319c1eb70dbd7ba25cd74ef343fbf35c52c7c4752f955605c58699cbff9d",
|
||||
"Cargo.lock": "7b4ab0ffa35af7ea8b6e38525f18ff6c3e94768198915f5ffe560d84263423f8",
|
||||
"crates/horizon-assets/Cargo.toml": "fadb78cf819e026500606c45d27138ed67fcbbc967ae8cee6c1e7c82c9046b36",
|
||||
"crates/horizon-assets/examples/cuboid_model_fixture.rs": "97609d90e3f6ce340f3590296db2dc778113bb2b33c7d1b54080a1d6f3023f40",
|
||||
"crates/horizon-assets/examples/model_resolution_fixture.rs": "c071067140299e49345a6ebd94e15b3b6d853cb9ae11d8de46e3cbdfb8b01f0e",
|
||||
"crates/horizon-assets/examples/support/mod.rs": "bcf5a6d8a54845982191e037911c24139237771d6afa4b01b1e83c011489c3f5",
|
||||
"crates/horizon-assets/src/json.rs": "e7b89bf4852f561a0e5bf1a984857c61e755961dfd86fcc326b6d55558615f39",
|
||||
"crates/horizon-assets/src/lib.rs": "0d31dd0fa84d7b4467a78a1a2b8a8e179533ef5e64a7cbae88e5358e59160a40",
|
||||
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b",
|
||||
"crates/horizon-assets/src/resolve.rs": "3927ac926cdcbd7d334901cc32d0ecc1e498b1bec98f76a46bca6d4964c13541"
|
||||
},
|
||||
"fixtures_sha256": {
|
||||
".local/rust-model-resolution-parity/inputs.jsonl": "9b3f9cb64bdb84303a3393ab035a00c11e857a5140e2992c0c5ebac0da2d3744"
|
||||
},
|
||||
"rust_output_sha256": "146858eb5c73a16d71bf255bbd21f6082f330df5b322f10a288b4149b2cc98a4",
|
||||
"references": [
|
||||
{
|
||||
"side": "client",
|
||||
"jar_sha256": "4508d006323f24fa02876310c192d739af56516eb259000ac50f0909a68c9a2d",
|
||||
"output_sha256": "146858eb5c73a16d71bf255bbd21f6082f330df5b322f10a288b4149b2cc98a4",
|
||||
"matched_output_lines": 5648
|
||||
}
|
||||
],
|
||||
"scope": "Actual ModelDiscovery and ResolvedModel/TextureSlots APIs over all bundled block models plus synthetic parent/alias graphs. Compares inherited geometry, AO/gui defaults, per-context display transforms, resolved materials, missing-model fallback, ignored built-in override, cyclic parent rejection, long alias/parent chains and slot lookup. Native rejection text/logging and discovery map iteration order are not compared. Geometry is shared through Arc in Rust; this does not prove quad baking, blockstate dispatch, pixels or gameplay."
|
||||
}
|
||||
@@ -1,73 +1,12 @@
|
||||
use horizon_assets::model::*;
|
||||
use serde_json::{Value, json};
|
||||
mod support;
|
||||
use horizon_assets::model::CuboidModel;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
use support::model;
|
||||
|
||||
fn bits<const N: usize>(values: [f32; N]) -> Vec<u32> {
|
||||
values.map(f32::to_bits).to_vec()
|
||||
}
|
||||
fn transform(value: &DisplayTransform) -> Value {
|
||||
match value {
|
||||
DisplayTransform::Inherit => json!("inherit"),
|
||||
DisplayTransform::Explicit(None) => Value::Null,
|
||||
DisplayTransform::Explicit(Some(v)) => {
|
||||
json!({"rotation":bits(v.rotation),"translation":bits(v.translation),"scale":bits(v.scale)})
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rotation(value: &ElementRotation) -> Value {
|
||||
let angles = match value.value {
|
||||
RotationValue::SingleAxis { axis, angle } => {
|
||||
json!({"axis": match axis { Axis::X => "x", Axis::Y => "y", Axis::Z => "z" }, "angle":angle.to_bits()})
|
||||
}
|
||||
RotationValue::EulerXyz(v) => json!(bits(v)),
|
||||
};
|
||||
json!({"origin":bits(value.origin),"value":angles,"rescale":value.rescale})
|
||||
}
|
||||
fn face(v: &Face) -> Value {
|
||||
json!({"cull":v.cull.map(Direction::name),"tint":v.tint_index,"texture":v.texture,"rotation":v.rotation,"uv":v.uv.map(bits)})
|
||||
}
|
||||
fn element(v: &Element) -> Value {
|
||||
let faces: BTreeMap<_, _> = v
|
||||
.faces
|
||||
.iter()
|
||||
.map(|(k, v)| (k.name(), v.as_ref().map(face)))
|
||||
.collect();
|
||||
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
|
||||
}
|
||||
fn model(v: &CuboidModel) -> Value {
|
||||
let textures: BTreeMap<_, _> = v
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
match value {
|
||||
TextureSlot::Reference(r) => json!({"reference":r}),
|
||||
TextureSlot::Material(m) => {
|
||||
json!({"sprite":m.sprite,"translucent":m.force_translucent})
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let elements = v.elements.as_ref().map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| v.as_ref().map(element))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let display = v.display.as_ref().map(|v| {
|
||||
DISPLAY_CONTEXTS
|
||||
.into_iter()
|
||||
.zip(v.contexts.iter().map(transform))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
});
|
||||
json!({"parent":v.parent,"ao":v.ambient_occlusion,"gui":v.gui_light.map(|v| match v { GuiLight::Front => "front", GuiLight::Side => "side" }),"textures":textures,"elements":elements,"display":display})
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
mod support;
|
||||
use horizon_assets::{model::*, resolve::ModelLibrary};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn material(value: &Material) -> Value {
|
||||
json!({"sprite":value.sprite,"translucent":value.force_translucent})
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let input = BufReader::new(File::open(&args[1])?);
|
||||
let mut output = BufWriter::new(File::create(&args[2])?);
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
let row: Value = serde_json::from_str(&line?)?;
|
||||
let models = row["models"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(name, raw)| Ok((name.clone(), CuboidModel::from_json(raw.as_str().unwrap())?)))
|
||||
.collect::<Result<_, horizon_assets::DecodeError>>()?;
|
||||
let library = ModelLibrary::new(models)?;
|
||||
for root in row["roots"].as_array().unwrap() {
|
||||
let name = root.as_str().unwrap();
|
||||
let Ok(model) = library.resolve(name) else {
|
||||
writeln!(output, "{}", json!({"case":index,"name":name,"error":true}))?;
|
||||
continue;
|
||||
};
|
||||
let elements: Vec<_> = model
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|e| e.as_ref().map(support::element))
|
||||
.collect();
|
||||
let display: BTreeMap<_, _> = DISPLAY_CONTEXTS
|
||||
.into_iter()
|
||||
.zip(model.display.contexts.iter().map(support::transform))
|
||||
.collect();
|
||||
let textures: BTreeMap<_, _> = model
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(key, value)| (key, material(value)))
|
||||
.collect();
|
||||
let queries: Vec<_> = row["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
let slot = raw.as_str().unwrap();
|
||||
match model.material(slot) {
|
||||
Ok(value) => json!({"slot":slot,"value":value.map(material)}),
|
||||
Err(_) => json!({"slot":slot,"error":true}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
json!({"case":index,"name":name,"ao":model.ambient_occlusion,
|
||||
"gui":match model.gui_light { GuiLight::Front => "front", GuiLight::Side => "side" },
|
||||
"elements":elements,"display":display,"textures":textures,"queries":queries})
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use horizon_assets::model::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn bits<const N: usize>(values: [f32; N]) -> Vec<u32> {
|
||||
values.map(f32::to_bits).to_vec()
|
||||
}
|
||||
pub fn transform(value: &DisplayTransform) -> Value {
|
||||
match value {
|
||||
DisplayTransform::Inherit => json!("inherit"),
|
||||
DisplayTransform::Explicit(None) => Value::Null,
|
||||
DisplayTransform::Explicit(Some(v)) => {
|
||||
json!({"rotation":bits(v.rotation),"translation":bits(v.translation),"scale":bits(v.scale)})
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rotation(value: &ElementRotation) -> Value {
|
||||
let angles = match value.value {
|
||||
RotationValue::SingleAxis { axis, angle } => {
|
||||
json!({"axis": match axis { Axis::X => "x", Axis::Y => "y", Axis::Z => "z" }, "angle":angle.to_bits()})
|
||||
}
|
||||
RotationValue::EulerXyz(v) => json!(bits(v)),
|
||||
};
|
||||
json!({"origin":bits(value.origin),"value":angles,"rescale":value.rescale})
|
||||
}
|
||||
fn face(v: &Face) -> Value {
|
||||
json!({"cull":v.cull.map(Direction::name),"tint":v.tint_index,"texture":v.texture,"rotation":v.rotation,"uv":v.uv.map(bits)})
|
||||
}
|
||||
pub fn element(v: &Element) -> Value {
|
||||
let faces: BTreeMap<_, _> = v
|
||||
.faces
|
||||
.iter()
|
||||
.map(|(k, v)| (k.name(), v.as_ref().map(face)))
|
||||
.collect();
|
||||
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
|
||||
}
|
||||
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
|
||||
pub fn model(v: &CuboidModel) -> Value {
|
||||
let textures: BTreeMap<_, _> = v
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
match value {
|
||||
TextureSlot::Reference(r) => json!({"reference":r}),
|
||||
TextureSlot::Material(m) => {
|
||||
json!({"sprite":m.sprite,"translucent":m.force_translucent})
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let elements = v.elements.as_ref().map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| v.as_ref().map(element))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let display = v.display.as_ref().map(|v| {
|
||||
DISPLAY_CONTEXTS
|
||||
.into_iter()
|
||||
.zip(v.contexts.iter().map(transform))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
});
|
||||
json!({"parent":v.parent,"ao":v.ambient_occlusion,"gui":v.gui_light.map(|v| match v { GuiLight::Front => "front", GuiLight::Side => "side" }),"textures":textures,"elements":elements,"display":display})
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
|
||||
mod json;
|
||||
pub mod model;
|
||||
pub mod resolve;
|
||||
|
||||
pub use json::DecodeError;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Cuboid parent inheritance and texture-slot resolution.
|
||||
use crate::{json::*, model::*};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
|
||||
|
||||
/// Decoded models are immutable and their inherited geometry is shared.
|
||||
pub struct ModelLibrary {
|
||||
models: BTreeMap<String, Arc<CuboidModel>>,
|
||||
missing: Arc<CuboidModel>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ResolvedCuboidModel {
|
||||
pub ambient_occlusion: bool,
|
||||
pub gui_light: GuiLight,
|
||||
pub display: ItemTransforms,
|
||||
pub textures: BTreeMap<String, Material>,
|
||||
/// Cyclic and dangling aliases are absent from `textures`.
|
||||
pub unresolved_textures: BTreeSet<String>,
|
||||
geometry: Option<Arc<CuboidModel>>,
|
||||
}
|
||||
impl ResolvedCuboidModel {
|
||||
pub fn elements(&self) -> &[Option<Element>] {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.and_then(|m| m.elements.as_deref())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
/// Native slot lookup strips a single leading '#'. Non-reference names
|
||||
/// are still slot names, not direct sprite identifiers.
|
||||
pub fn material(&self, slot: &str) -> Result<Option<&Material>> {
|
||||
if slot.is_empty() {
|
||||
return Err(error("empty texture lookup"));
|
||||
}
|
||||
Ok(self.textures.get(slot.strip_prefix('#').unwrap_or(slot)))
|
||||
}
|
||||
}
|
||||
impl ModelLibrary {
|
||||
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
let mut decoded = BTreeMap::new();
|
||||
for (name, model) in models {
|
||||
if decoded
|
||||
.insert(identifier(&name)?, Arc::new(model))
|
||||
.is_some()
|
||||
{
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
}
|
||||
let missing = Arc::new(missing_model());
|
||||
// The native discovery registers this built-in before consulting resources.
|
||||
decoded.insert(MISSING_MODEL.to_owned(), missing.clone());
|
||||
Ok(Self {
|
||||
models: decoded,
|
||||
missing,
|
||||
})
|
||||
}
|
||||
/// Follow the complete parent chain. A missing resource uses the built-in
|
||||
/// model; a cyclic chain is rejected, as native discovery excludes it.
|
||||
/// The returned model can outlive this library without copying its elements.
|
||||
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
|
||||
let mut name = identifier(name)?;
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut chain = Vec::new();
|
||||
loop {
|
||||
if !visited.insert(name.clone()) {
|
||||
return Err(error("cyclic model parent"));
|
||||
}
|
||||
let model = self.models.get(&name).unwrap_or(&self.missing);
|
||||
chain.push(model);
|
||||
match &model.parent {
|
||||
Some(parent) => name = parent.clone(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let ambient_occlusion = chain
|
||||
.iter()
|
||||
.find_map(|m| m.ambient_occlusion)
|
||||
.unwrap_or(true);
|
||||
let gui_light = chain
|
||||
.iter()
|
||||
.find_map(|m| m.gui_light)
|
||||
.unwrap_or(GuiLight::Side);
|
||||
let geometry = chain
|
||||
.iter()
|
||||
.find(|m| m.elements.is_some())
|
||||
.map(|m| Arc::clone(m));
|
||||
let display = ItemTransforms {
|
||||
contexts: std::array::from_fn(|index| {
|
||||
chain
|
||||
.iter()
|
||||
.filter_map(|m| m.display.as_ref())
|
||||
.map(|d| &d.contexts[index])
|
||||
.find(|t| **t != DisplayTransform::Inherit)
|
||||
.cloned()
|
||||
.unwrap_or(DisplayTransform::Inherit)
|
||||
}),
|
||||
};
|
||||
// Resolve aliases only after applying every child override. A reference
|
||||
// inherited from a parent binds to the child's final slot definition.
|
||||
let mut slots = BTreeMap::new();
|
||||
for model in chain.into_iter().rev() {
|
||||
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
let mut textures = BTreeMap::<String, Material>::new();
|
||||
let mut unresolved_textures = BTreeSet::<String>::new();
|
||||
for start in slots.keys() {
|
||||
let mut current = start.as_str();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut path = Vec::new();
|
||||
let material = loop {
|
||||
if let Some(material) = textures.get(current) {
|
||||
break Some(material.clone());
|
||||
}
|
||||
if unresolved_textures.contains(current) || !seen.insert(current) {
|
||||
break None;
|
||||
}
|
||||
let Some(slot) = slots.get(current) else {
|
||||
break None;
|
||||
};
|
||||
path.push(current);
|
||||
match slot {
|
||||
TextureSlot::Reference(target) => current = target,
|
||||
TextureSlot::Material(material) => break Some(material.clone()),
|
||||
}
|
||||
};
|
||||
// Cache the entire walk, including failures, so long alias chains
|
||||
// and cycles are traversed once rather than once per referencing slot.
|
||||
match material {
|
||||
Some(material) => {
|
||||
for name in path {
|
||||
textures.insert(name.to_owned(), material.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
unresolved_textures.extend(path.into_iter().map(str::to_owned));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ResolvedCuboidModel {
|
||||
ambient_occlusion,
|
||||
gui_light,
|
||||
display,
|
||||
textures,
|
||||
unresolved_textures,
|
||||
geometry,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The native missing-model cube uses slot names without a leading '#'.
|
||||
pub fn missing_model() -> CuboidModel {
|
||||
let faces = Direction::ALL
|
||||
.into_iter()
|
||||
.map(|direction| {
|
||||
(
|
||||
direction,
|
||||
Some(Face {
|
||||
cull: Some(direction),
|
||||
tint_index: -1,
|
||||
texture: "missingno".to_owned(),
|
||||
uv: Some([0.0, 0.0, 16.0, 16.0]),
|
||||
rotation: 0,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
CuboidModel {
|
||||
parent: None,
|
||||
ambient_occlusion: None,
|
||||
gui_light: None,
|
||||
elements: Some(vec![Some(Element {
|
||||
from: [0.0; 3],
|
||||
to: [16.0; 3],
|
||||
faces,
|
||||
rotation: None,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
})]),
|
||||
display: Some(ItemTransforms {
|
||||
contexts: std::array::from_fn(|_| DisplayTransform::Inherit),
|
||||
}),
|
||||
textures: BTreeMap::from([
|
||||
(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("missingno".to_owned()),
|
||||
),
|
||||
(
|
||||
"missingno".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: "minecraft:missingno".to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn inherited_alias_uses_child_material_and_geometry_outlives_library() {
|
||||
let parent = CuboidModel::from_json(r##"{"textures":{"side":"#all","all":"block/stone"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"}}}]}"##).unwrap();
|
||||
let child = CuboidModel::from_json(r#"{"parent":"horizon:parent","textures":{"all":{"sprite":"block/glass","force_translucent":true}}}"#).unwrap();
|
||||
let library = ModelLibrary::new(BTreeMap::from([
|
||||
("horizon:parent".into(), parent),
|
||||
("horizon:child".into(), child),
|
||||
]))
|
||||
.unwrap();
|
||||
let model = library.resolve("horizon:child").unwrap();
|
||||
drop(library);
|
||||
assert_eq!(model.elements()[0].as_ref().unwrap().to, [16.0, 8.0, 16.0]);
|
||||
assert_eq!(
|
||||
model.material("#side").unwrap().unwrap(),
|
||||
&Material {
|
||||
sprite: "minecraft:block/glass".into(),
|
||||
force_translucent: true
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
|
||||
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
|
||||
let b = CuboidModel::from_json(r#"{"parent":"horizon:a"}"#).unwrap();
|
||||
let child = CuboidModel::from_json(r#"{"parent":"horizon:a","elements":[]}"#).unwrap();
|
||||
let library = ModelLibrary::new(BTreeMap::from([
|
||||
("horizon:a".into(), a),
|
||||
("horizon:b".into(), b),
|
||||
("horizon:child".into(), child),
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(library.resolve("horizon:child").is_err());
|
||||
let absent = library.resolve("horizon:absent").unwrap();
|
||||
assert_eq!(absent.elements().len(), 1);
|
||||
assert_eq!(
|
||||
absent.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:missingno"
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -69,8 +69,24 @@ python3 tools/test_cuboid_model_parity.py
|
||||
The [archived decoder result](../benchmarks/rust-cuboid-model-decoding-2026-10-06/scoped-parity.json)
|
||||
records source, fixture and reference hashes. The oracle uses actual client
|
||||
classes; those rendering APIs are absent from the server. This decoder is not
|
||||
yet connected to the development renderer. Parent resolution, rotation matrices,
|
||||
quad baking, blockstate selection and rendered appearance need their own checks.
|
||||
yet connected to the development renderer. Rotation matrices, quad baking,
|
||||
blockstate selection and rendered appearance need their own checks.
|
||||
|
||||
`ModelLibrary` resolves parent geometry, ambient occlusion, GUI lighting and
|
||||
each display context. It shares inherited elements rather than cloning them.
|
||||
Texture aliases bind after child overrides are applied; successful and failed
|
||||
alias walks are cached. Missing resources use the native missing-model cube;
|
||||
parent cycles invalidate their descendants. The
|
||||
[resolution comparison](../benchmarks/rust-model-resolution-2026-10-06/scoped-parity.json)
|
||||
matches **5,648 requested roots across 439 graphs**, including the full bundled
|
||||
block-model catalog, missing parents, 702 cyclic roots, 1,024-model parent chains,
|
||||
512-slot alias chains, explicit empty geometry and identity/null transforms.
|
||||
It calls native `ModelDiscovery`, `ResolvedModel` and `TextureSlots` APIs.
|
||||
|
||||
```sh
|
||||
python3 tools/test_model_resolution_parity.py
|
||||
cargo +stable test --locked -p horizon-assets
|
||||
```
|
||||
|
||||
```sh
|
||||
cargo +stable test -p horizon-client
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ record the exact tested scope and remaining gaps.
|
||||
The `horizon-assets` crate now decodes pinned cuboid models, with all 2,883 bundled
|
||||
block-model JSON files and 2,337 synthetic cases compared against the actual
|
||||
client parser. [Decoder scope and validation](native-client.md#validation)
|
||||
are separate from the unfinished model-resolution and rendering integration.
|
||||
also include parent/texture resolution over 5,648 roots. Quad baking,
|
||||
blockstate selection and integration with the renderer remain unfinished.
|
||||
The playable Java performance packs remain available during development.
|
||||
|
||||
## Completion criteria and playable builds
|
||||
|
||||
@@ -15,12 +15,12 @@ public final class VanillaCuboidModelOracle {
|
||||
private static long bits(float value) { return Integer.toUnsignedLong(Float.floatToIntBits(value)); }
|
||||
private static Object vector(Vector3fc value) { return new long[]{bits(value.x()), bits(value.y()), bits(value.z())}; }
|
||||
private static String direction(net.minecraft.core.Direction value) { return value == null ? null : value.getName(); }
|
||||
private static Map<String, Object> map(Object... values) {
|
||||
static Map<String, Object> map(Object... values) {
|
||||
var result = new TreeMap<String, Object>();
|
||||
for (int i=0;i<values.length;i+=2) result.put((String)values[i], values[i+1]);
|
||||
return result;
|
||||
}
|
||||
private static Object transform(ItemTransform value) {
|
||||
static Object transform(ItemTransform value) {
|
||||
if (value == ItemTransform.NO_TRANSFORM) return "inherit";
|
||||
if (value == null) return null;
|
||||
return map("rotation", vector(value.rotation()), "translation", vector(value.translation()), "scale", vector(value.scale()));
|
||||
@@ -40,7 +40,7 @@ public final class VanillaCuboidModelOracle {
|
||||
return map("cull", direction(value.cullForDirection()), "tint", value.tintIndex(), "texture", value.texture(),
|
||||
"rotation", value.rotation().ordinal(), "uv", uv == null ? null : new long[]{bits(uv.minU()), bits(uv.minV()), bits(uv.maxU()), bits(uv.maxV())});
|
||||
}
|
||||
private static Object element(CuboidModelElement value) {
|
||||
static Object element(CuboidModelElement value) {
|
||||
if (value == null) return null;
|
||||
var faces = new TreeMap<String, Object>();
|
||||
value.faces().forEach((key, f) -> faces.put(key.getName(), face(f)));
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.resources.model.*;
|
||||
import net.minecraft.client.resources.model.cuboid.*;
|
||||
import net.minecraft.client.resources.model.geometry.UnbakedGeometry;
|
||||
import net.minecraft.client.resources.model.sprite.Material;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemDisplayContext;
|
||||
import static dev.horizonlab.parity.VanillaCuboidModelOracle.*;
|
||||
|
||||
/** Exercises actual ModelDiscovery, ResolvedModel and TextureSlots resolution. */
|
||||
public final class VanillaModelResolutionOracle {
|
||||
private static final Gson JSON = new GsonBuilder().serializeNulls().create();
|
||||
private static Object material(Material value) {
|
||||
return value == null ? null : map("sprite", value.sprite().toString(), "translucent", value.forceTranslucent());
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
try (var input=Files.newBufferedReader(Path.of(args[0])); var output=Files.newBufferedWriter(Path.of(args[1]))) {
|
||||
int index=0;
|
||||
for (String line; (line=input.readLine())!=null; index++) {
|
||||
var row=JsonParser.parseString(line).getAsJsonObject();
|
||||
var models=new HashMap<Identifier, UnbakedModel>();
|
||||
var slots=new TreeSet<String>(); slots.add("missingno"); slots.add("particle");
|
||||
for (var entry:row.getAsJsonObject("models").entrySet()) {
|
||||
var model=CuboidModel.fromStream(new StringReader(entry.getValue().getAsString()));
|
||||
models.put(Identifier.parse(entry.getKey()),model);
|
||||
slots.addAll(model.textureSlots().values().keySet());
|
||||
}
|
||||
var discovery=new ModelDiscovery(models,MissingCuboidModel.missingModel());
|
||||
discovery.addRoot(resolver -> {for(var root:row.getAsJsonArray("roots")) resolver.markDependency(Identifier.parse(root.getAsString()));});
|
||||
var resolved=discovery.resolve();
|
||||
for (var root:row.getAsJsonArray("roots")) {
|
||||
String name=root.getAsString();
|
||||
var model=resolved.get(Identifier.parse(name));
|
||||
if(model==null) {output.write(JSON.toJson(map("case",index,"name",name,"error",true)));output.newLine();continue;}
|
||||
var geometry=model.getTopGeometry();
|
||||
var elements=new ArrayList<Object>();
|
||||
if(geometry!=UnbakedGeometry.EMPTY) for(var e:((UnbakedCuboidGeometry)geometry).elements()) elements.add(element(e));
|
||||
var display=new TreeMap<String,Object>();
|
||||
var transforms=model.getTopTransforms();
|
||||
for(var context:ItemDisplayContext.values()) if(context!=ItemDisplayContext.NONE) display.put(context.getSerializedName(),transform(transforms.getTransform(context)));
|
||||
var textures=model.getTopTextureSlots();
|
||||
var values=new TreeMap<String,Object>();
|
||||
for(var slot:slots) {var value=textures.getMaterial("#"+slot);if(value!=null) values.put(slot,material(value));}
|
||||
var queries=new ArrayList<Object>();
|
||||
for(var raw:row.getAsJsonArray("queries")) {
|
||||
String slot=raw.getAsString();
|
||||
try {queries.add(map("slot",slot,"value",material(textures.getMaterial(slot))));}
|
||||
catch(RuntimeException failure) {queries.add(map("slot",slot,"error",true));}
|
||||
}
|
||||
output.write(JSON.toJson(map("case",index,"name",name,"ao",model.getTopAmbientOcclusion(),
|
||||
"gui",model.getTopGuiLight().name().toLowerCase(Locale.ROOT),"elements",elements,
|
||||
"display",display,"textures",values,"queries",queries)));output.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare model inheritance, missing fallback and texture aliases with the client."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import random
|
||||
import subprocess
|
||||
import zipfile
|
||||
try:
|
||||
from . import lab, test_rust_parity as base, test_cuboid_model_parity as decoding
|
||||
except ImportError:
|
||||
import lab, test_rust_parity as base, test_cuboid_model_parity as decoding
|
||||
|
||||
|
||||
def bundle(models, roots=None):
|
||||
return {'models': {key: json.dumps(value) for key, value in models.items()},
|
||||
'roots': list(models) if roots is None else roots,
|
||||
'queries': ['', '#', 'all', '#all', 'side', '#side', 'particle', '#particle',
|
||||
'missingno', 'unknown', 'minecraft:block/stone', '##all', '']}
|
||||
|
||||
|
||||
def corpus():
|
||||
parent = {'ambientocclusion': False, 'gui_light': 'front', 'textures': {'side': '#all', 'all': 'block/stone'},
|
||||
'elements': [{'from': [0, 0, 0], 'to': [16, 8, 16], 'faces': {'up': {'texture': '#side'}}}],
|
||||
'display': {'thirdperson_righthand': {'rotation': [1, 2, 3]}, 'gui': {'scale': [2, 2, 2]}}}
|
||||
for child in [{}, {'elements': []}, {'ambientocclusion': True}, {'gui_light': 'side'},
|
||||
{'display': {}}, {'display': {'gui': {}}}, {'display': {'gui': None}},
|
||||
{'display': {'thirdperson_righthand': {'scale': [3, 2, 1]}}},
|
||||
{'display': {'thirdperson_lefthand': {}, 'thirdperson_righthand': {'scale': [3, 2, 1]}}},
|
||||
{'textures': {'all': 'block/dirt'}}, {'textures': {'all': '#side'}},
|
||||
{'textures': {'side': '#absent'}}, {'textures': {'all': {'sprite': 'block/glass', 'force_translucent': True}}}]:
|
||||
yield bundle({'horizon:parent': parent, 'horizon:child': {'parent': 'horizon:parent', **child}})
|
||||
yield bundle({}, ['horizon:missing', 'minecraft:builtin/missing'])
|
||||
yield bundle({'minecraft:builtin/missing': {'elements': []}, 'horizon:child': {'parent': 'builtin/missing'}})
|
||||
for chain in [
|
||||
{'horizon:a': {'parent': 'horizon:absent'}},
|
||||
{'horizon:a': {'parent': 'horizon:a'}},
|
||||
{'horizon:a': {'parent': 'horizon:b'}, 'horizon:b': {'parent': 'horizon:a'}},
|
||||
{'horizon:a': {'parent': 'horizon:b'}, 'horizon:b': {'parent': 'horizon:b'}, 'horizon:c': {'parent': 'horizon:a', 'elements': []}},
|
||||
]:
|
||||
yield bundle(chain)
|
||||
for tail in ['block/stone', '#absent', '#s0000', '#s0040']:
|
||||
for length in [1, 8, 64, 512]:
|
||||
textures = {f's{i:04}': f'#s{i+1:04}' for i in range(length - 1)}
|
||||
textures[f's{length-1:04}'] = tail
|
||||
textures.update({'all': '#s0000', 'side': '#all', 'particle': '#side', '': 'block/dirt', '#all': '#'})
|
||||
yield bundle({'horizon:chain': {'textures': textures}})
|
||||
# Deep parent chains use iterative traversal, including descendant cycles.
|
||||
for final in [{}, {'parent': 'horizon:n0'}, {'parent': 'horizon:missing'}]:
|
||||
models = {f'horizon:n{i}': {'parent': f'horizon:n{i+1}'} for i in range(1023)}
|
||||
models['horizon:n1023'] = final
|
||||
yield bundle(models, ['horizon:n0', 'horizon:n512', 'horizon:n1023'])
|
||||
rng = random.Random(0x5245534f4c5645)
|
||||
for _ in range(400):
|
||||
count = rng.randrange(1, 13)
|
||||
models = {}
|
||||
for i in range(count):
|
||||
model = {}
|
||||
if rng.randrange(4): model['parent'] = f'horizon:n{rng.randrange(count+2)}'
|
||||
if rng.randrange(3) == 0: model['ambientocclusion'] = rng.choice([True, False])
|
||||
if rng.randrange(3) == 0: model['gui_light'] = rng.choice(['front', 'side'])
|
||||
if rng.randrange(3) == 0: model['elements'] = rng.choice([[], [None], parent['elements']])
|
||||
if rng.randrange(3) == 0:
|
||||
model['display'] = {rng.choice(['gui', 'firstperson_righthand', 'thirdperson_lefthand', 'on_shelf']):
|
||||
rng.choice([None, {}, {'translation': [i, -i, 0]}])}
|
||||
model['textures'] = {key: rng.choice(['block/stone', '#all', '#side', '#missingno', '#', '#absent',
|
||||
{'sprite': 'block/glass', 'force_translucent': True}])
|
||||
for key in rng.sample(['all', 'side', 'particle', '', 'missingno'], rng.randrange(6))}
|
||||
models[f'horizon:n{i}'] = model
|
||||
yield bundle(models)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__); parser.add_argument('--prepare', action='store_true'); args = parser.parse_args()
|
||||
if args.prepare: lab.prepare()
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / 'rust-model-resolution-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'; report_path.unlink(missing_ok=True)
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaModelResolutionOracle.java'
|
||||
helper = oracle.with_name('VanillaCuboidModelOracle.java')
|
||||
crate = lab.ROOT / 'crates/horizon-assets'
|
||||
sources = [oracle, helper, Path(__file__), lab.ROOT / 'tools/test_cuboid_model_parity.py',
|
||||
lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'Cargo.toml',
|
||||
lab.ROOT / 'Cargo.lock', crate / 'Cargo.toml', *sorted(crate.rglob('*.rs'))]
|
||||
tested_sources = base.source_hashes(sources)
|
||||
with zipfile.ZipFile(jars['client']) as archive:
|
||||
names = sorted(name for name in archive.namelist() if name.startswith('assets/minecraft/models/block/') and name.endswith('.json'))
|
||||
models = {'minecraft:' + name.removeprefix('assets/minecraft/models/').removesuffix('.json'): archive.read(name).decode() for name in names}
|
||||
rows = [{'models': models, 'roots': list(models) + ['minecraft:builtin/missing', 'horizon:missing'],
|
||||
'queries': ['particle', '#particle', 'all', '#all', 'missingno', 'unknown', '', '#']}, *corpus()]
|
||||
inputs = build / 'inputs.jsonl'; inputs.write_text(''.join(json.dumps(row) + '\n' for row in rows)); fixtures = base.source_hashes([inputs])
|
||||
classes = build / 'classes'; classes.mkdir(exist_ok=True)
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w') as log:
|
||||
subprocess.run(list(map(str, command)), cwd=lab.ROOT, check=True, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
|
||||
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-cp', dependencies, '-d', classes, oracle, helper], 'compile-java')
|
||||
run(['cargo', 'build', '--locked', '-p', 'horizon-assets', '--example', 'model_resolution_fixture'], 'compile-rust')
|
||||
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps', '--format-version', '1'], cwd=lab.ROOT))
|
||||
binary = Path(metadata['target_directory']) / 'debug/examples' / ('model_resolution_fixture.exe' if os.name == 'nt' else 'model_resolution_fixture')
|
||||
run([binary, inputs, build / 'rust.jsonl'], 'rust')
|
||||
run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaModelResolutionOracle', inputs, build / 'client.jsonl'], 'client')
|
||||
rejected = decoding.canonicalize(build / 'client.jsonl'); decoding.canonicalize(build / 'rust.jsonl')
|
||||
matched = base.compare(build / 'client.jsonl', build / 'rust.jsonl')
|
||||
base.ensure_sources_unchanged(sources, tested_sources); base.ensure_sources_unchanged([inputs], fixtures)
|
||||
report = {'schema': 1, 'minecraft': lock['minecraft']['version'], 'manifest_sha1': lock['minecraft']['manifest_sha1'],
|
||||
'platform': platform.platform(), 'java': subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
|
||||
'resource_models': len(names), 'graph_fixtures': len(rows), 'rejected_cyclic_roots': rejected,
|
||||
'sources_sha256': tested_sources, 'fixtures_sha256': fixtures, 'rust_output_sha256': lab.digest(build / 'rust.jsonl', 'sha256'),
|
||||
'references': [{'side': 'client', 'jar_sha256': lab.digest(jars['client'], 'sha256'),
|
||||
'output_sha256': lab.digest(build / 'client.jsonl', 'sha256'), 'matched_output_lines': matched}],
|
||||
'scope': 'Actual ModelDiscovery and ResolvedModel/TextureSlots APIs over all bundled block models plus synthetic parent/alias graphs. Compares inherited geometry, AO/gui defaults, per-context display transforms, resolved materials, missing-model fallback, ignored built-in override, cyclic parent rejection, long alias/parent chains and slot lookup. Native rejection text/logging and discovery map iteration order are not compared. Geometry is shared through Arc in Rust; this does not prove quad baking, blockstate dispatch, pixels or gameplay.'}
|
||||
lab.write_json(report_path, report); print(f'Client model resolution: {matched:,} roots match ({rejected:,} cyclic); {report_path}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
Reference in New Issue
Block a user