Resolve vanilla model inheritance and texture aliases

This commit is contained in:
Ludovic Marie
2026-10-06 20:17:53 +02:00
parent 9dbd93192f
commit f0a03aea52
12 changed files with 637 additions and 75 deletions
@@ -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})
}
+1
View File
@@ -3,5 +3,6 @@
mod json;
pub mod model;
pub mod resolve;
pub use json::DecodeError;
+243
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
}
}
}
+119
View File
@@ -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()