Bake vanilla cuboid geometry with native float-bit validation

This commit is contained in:
Ludovic Marie
2026-10-06 20:30:09 +02:00
parent f0a03aea52
commit 8c7d8f6416
8 changed files with 569 additions and 4 deletions
@@ -0,0 +1,45 @@
{
"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": 440,
"rejected_roots": 1039,
"sources_sha256": {
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaModelBakingOracle.java": "fca267347a21149791a3260826be98df55705208c30396277ef6c3feba875748",
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "7526f20513c549483122ab0d5ad24b9e87f38dc2f6aae55addd701a91ac85e43",
"tools/test_model_baking_parity.py": "392ffec44918fb91740ca4a1316d684480d39f14bd34d46175f7f44946d15bd7",
"tools/test_cuboid_model_parity.py": "3b7c018570b0f609aae43cef60ff3139c543d5d629be804a5aa257e3ab2dd67c",
"tools/test_model_resolution_parity.py": "d9595f2f1442f1f590b72d2a46600657e79ae103bec3d4c45db98242b964bde0",
"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_baking_fixture.rs": "59caca09dd5afd0b70a2e8fdb8e949deb9322f00adc617918b33d1e7e5b0b522",
"crates/horizon-assets/examples/model_resolution_fixture.rs": "c071067140299e49345a6ebd94e15b3b6d853cb9ae11d8de46e3cbdfb8b01f0e",
"crates/horizon-assets/examples/support/mod.rs": "bcf5a6d8a54845982191e037911c24139237771d6afa4b01b1e83c011489c3f5",
"crates/horizon-assets/src/bake.rs": "0bc519e93ad8200d89543ceb5c811dd82bbeb0e9003b143385c8a03e758dfe65",
"crates/horizon-assets/src/json.rs": "e7b89bf4852f561a0e5bf1a984857c61e755961dfd86fcc326b6d55558615f39",
"crates/horizon-assets/src/lib.rs": "5fe567bdf1e63230cee2b960719c5cb6166176a674772955329a9d04cf22b9db",
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b",
"crates/horizon-assets/src/resolve.rs": "3927ac926cdcbd7d334901cc32d0ecc1e498b1bec98f76a46bca6d4964c13541"
},
"fixtures_sha256": {
".local/rust-model-baking-parity/inputs.jsonl": "aba24d38f4fc1af060502f983923c2f279c016d0fd412d40f6d50093fac1d811"
},
"rust_output_sha256": "9216be3bbae9e59bb89b6ed780ccedc5608cd4583c8e7527e50b165a0d8d30da",
"references": [
{
"side": "client",
"jar_sha256": "4508d006323f24fa02876310c192d739af56516eb259000ac50f0909a68c9a2d",
"output_sha256": "9216be3bbae9e59bb89b6ed780ccedc5608cd4583c8e7527e50b165a0d8d30da",
"matched_output_lines": 7100
}
],
"scope": "Actual client FaceBakery geometry overload with a real unit-atlas sprite and default JOML math options. Identity blockstate transform, all bundled cuboid geometry and synthetic rotations, rescaling, default/explicit UVs, winding, degenerate and reversed bounds, inherited models and missing materials. Positions, UVs and element matrix coefficients compare by float bits. This does not cover blockstate transforms/UV lock, atlas packing, transparency classification, lighting, GPU rendering or gameplay."
}
@@ -0,0 +1,51 @@
use horizon_assets::{
bake::{ElementMatrix, bake_model},
model::*,
resolve::ModelLibrary,
};
use serde_json::{Value, json};
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn bits(value: f32) -> u32 {
if value.is_nan() {
0x7fc0_0000
} else {
value.to_bits()
}
}
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 baked = library.resolve(name).and_then(|model| {
let quads = bake_model(&model)?.into_iter().map(|q| json!({
"positions":q.positions.map(|v| v.map(bits)),"uv":q.uv.map(|v| v.map(bits)),
"facing":q.facing.name(),"cull":q.cull.map(Direction::name),"tint":q.tint_index,
"shade":q.shade_direction_override.map(Direction::name),"light":q.light_emission,
"material":{"sprite":q.material.sprite,"translucent":q.material.force_translucent}
})).collect::<Vec<_>>();
let matrices: Vec<_> = model.elements().iter().map(|e| e.as_ref().unwrap().rotation.as_ref().map(|r| ElementMatrix::from_rotation(r).rows.map(|row| row.map(bits)))).collect();
Ok(json!({"quads":quads,"matrices":matrices}))
});
let value = match baked {
Ok(baked) => json!({"case":index,"name":name,"baked":baked}),
Err(_) => json!({"case":index,"name":name,"error":true}),
};
writeln!(output, "{value}")?;
}
}
Ok(())
}
+241
View File
@@ -0,0 +1,241 @@
//! Cuboid geometry in block units, before blockstate rotation and UV locking.
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
#[derive(Clone, Debug, PartialEq)]
pub struct ModelQuad {
pub positions: [[f32; 3]; 4],
/// Normalized unit-atlas coordinates, before placement in a larger atlas.
pub uv: [[f32; 2]; 4],
pub facing: Direction,
pub cull: Option<Direction>,
pub material: Material,
pub tint_index: i32,
pub shade_direction_override: Option<Direction>,
pub light_emission: u8,
}
#[derive(Clone, Copy, Debug)]
pub struct ElementMatrix {
pub rows: [[f32; 3]; 3],
identity: bool,
}
impl ElementMatrix {
pub fn from_rotation(rotation: &ElementRotation) -> Self {
const DEG: f32 = std::f32::consts::PI / 180.0;
let mut matrix = match rotation.value {
RotationValue::SingleAxis { angle: 0.0, .. } => Self {
rows: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
identity: true,
},
RotationValue::SingleAxis { axis, angle } => {
let (s, c) = sin_cos(angle * DEG);
let rows = match axis {
Axis::X => [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]],
Axis::Y => [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]],
Axis::Z => [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]],
};
Self {
rows,
identity: false,
}
}
RotationValue::EulerXyz([x, y, z]) => {
let (sx, cx) = sin_cos(x * DEG);
let (sy, cy) = sin_cos(y * DEG);
let (sz, cz) = sin_cos(z * DEG);
let czsy = cz * sy;
let szsy = sz * sy;
Self {
rows: [
[cz * cy, -sz * cx + czsy * sx, -sz * -sx + czsy * cx],
[sz * cy, cz * cx + szsy * sx, cz * -sx + szsy * cx],
[-sy, cy * sx, cy * cx],
],
identity: false,
}
}
};
if rotation.rescale && !matrix.identity {
let scales: [f32; 3] = std::array::from_fn(|axis| {
let basis = std::array::from_fn(|i| if i == axis { 1.0 } else { 0.0 });
let column = matrix.direction(basis);
let maximum = java_max(java_max(column[0].abs(), column[1].abs()), column[2].abs());
1.0 / maximum
});
for (axis, scale) in scales.into_iter().enumerate() {
for row in &mut matrix.rows {
row[axis] *= scale;
}
}
}
matrix
}
fn direction(self, value: [f32; 3]) -> [f32; 3] {
self.rows
.map(|r| r[0] * value[0] + (r[1] * value[1] + r[2] * value[2]))
}
fn position(self, value: [f32; 3]) -> [f32; 3] {
if self.identity {
value
} else {
self.rows
.map(|r| r[0] * value[0] + (r[1] * value[1] + (r[2] * value[2] + 0.0)))
}
}
}
fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else {
a.max(b)
}
}
/// JOML's default float path uses a double sine rounded to float, and obtains
/// cosine's magnitude from that rounded sine. Keep all subsequent steps f32.
fn sin_cos(angle: f32) -> (f32, f32) {
let sin = f64::from(angle).sin() as f32;
let cos = (1.0 - sin * sin).sqrt();
let shifted = angle + std::f32::consts::FRAC_PI_2;
let mut period =
shifted - (shifted / std::f32::consts::TAU) as i32 as f32 * std::f32::consts::TAU;
if period < 0.0 {
period += std::f32::consts::TAU;
}
(
sin,
if period >= std::f32::consts::PI {
-cos
} else {
cos
},
)
}
pub fn default_uv(from: [f32; 3], to: [f32; 3], direction: Direction) -> [f32; 4] {
match direction {
Direction::Down => [from[0], 16.0 - to[2], to[0], 16.0 - from[2]],
Direction::Up => [from[0], from[2], to[0], to[2]],
Direction::North => [16.0 - to[0], 16.0 - to[1], 16.0 - from[0], 16.0 - from[1]],
Direction::South => [from[0], 16.0 - to[1], to[0], 16.0 - from[1]],
Direction::West => [from[2], 16.0 - to[1], to[2], 16.0 - from[1]],
Direction::East => [16.0 - to[2], 16.0 - to[1], 16.0 - from[2], 16.0 - from[1]],
}
}
fn corners(from: [f32; 3], to: [f32; 3], direction: Direction) -> [[f32; 3]; 4] {
let selectors = match direction {
Direction::Down => [[0, 0, 1], [0, 0, 0], [1, 0, 0], [1, 0, 1]],
Direction::Up => [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]],
Direction::North => [[1, 1, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0]],
Direction::South => [[0, 1, 1], [0, 0, 1], [1, 0, 1], [1, 1, 1]],
Direction::West => [[0, 1, 0], [0, 0, 0], [0, 0, 1], [0, 1, 1]],
Direction::East => [[1, 1, 1], [1, 0, 1], [1, 0, 0], [1, 1, 0]],
};
selectors
.map(|selector| std::array::from_fn(|i| if selector[i] == 0 { from[i] } else { to[i] }))
}
fn facing(vertices: &[[f32; 3]; 4]) -> Option<Direction> {
let a: [f32; 3] = std::array::from_fn(|i| vertices[1][i] - vertices[0][i]);
let b: [f32; 3] = std::array::from_fn(|i| vertices[2][i] - vertices[0][i]);
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let inverse_length = 1.0 / (n[0] * n[0] + (n[1] * n[1] + n[2] * n[2])).sqrt();
let n = n.map(|v| v * inverse_length);
if n.iter().any(|v| !v.is_finite()) {
return None;
}
let mut best = 0.0;
let mut result = None;
for (direction, dot) in Direction::ALL
.into_iter()
.zip([-n[1], n[1], -n[2], n[2], -n[0], n[0]])
{
if dot >= 0.0 && dot > best {
best = dot;
result = Some(direction);
}
}
result
}
pub fn bake_model(model: &ResolvedCuboidModel) -> Result<Vec<ModelQuad>> {
let mut output = Vec::new();
for element in model.elements() {
let element = element
.as_ref()
.ok_or_else(|| error("null model element"))?;
let matrix = element.rotation.as_ref().map(ElementMatrix::from_rotation);
for (direction, face) in &element.faces {
let face = face.as_ref().ok_or_else(|| error("null model face"))?;
let material = model.material(&face.texture)?.cloned().unwrap_or(Material {
sprite: "minecraft:missingno".to_owned(),
force_translucent: false,
});
output.push(bake_face(element, face, *direction, matrix, material)?);
}
}
Ok(output)
}
fn bake_face(
element: &Element,
face: &Face,
direction: Direction,
matrix: Option<ElementMatrix>,
material: Material,
) -> Result<ModelQuad> {
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
for vertex in &mut positions {
let relative = std::array::from_fn(|i| vertex[i] - rotation.origin[i]);
let rotated = matrix.position(relative);
*vertex = std::array::from_fn(|i| rotated[i] + rotation.origin[i]);
}
}
let bounds = face
.uv
.unwrap_or_else(|| default_uv(element.from, element.to, direction));
let mut uv = std::array::from_fn(|i| {
let corner = (i + usize::from(face.rotation)) % 4;
[
bounds[if corner < 2 { 0 } else { 2 }] / 16.0,
bounds[if corner == 0 || corner == 3 { 1 } else { 3 }] / 16.0,
]
// Native TextureAtlasSprite applies origin + extent * coordinate;
// even the unit atlas changes negative zero to positive zero.
.map(|value| 0.0 + value)
});
let normal = facing(&positions);
if let (None, Some(facing)) = (&element.rotation, normal) {
let mut min = [999.0; 3];
let mut max = [-999.0; 3];
for vertex in &positions {
for axis in 0..3 {
if vertex[axis] < min[axis] {
min[axis] = vertex[axis];
}
if vertex[axis] > max[axis] {
max[axis] = vertex[axis];
}
}
}
for (index, expected) in corners(min, max, facing).into_iter().enumerate() {
let found = (index..4)
.find(|i| positions[*i] == expected)
.ok_or_else(|| error("cannot reorder model face"))?;
positions.swap(index, found);
uv.swap(index, found);
}
}
Ok(ModelQuad {
positions,
uv,
facing: normal.unwrap_or(Direction::Up),
cull: face.cull,
material,
tint_index: face.tint_index,
shade_direction_override: element.shade_direction_override,
light_emission: element.light_emission,
})
}
+1
View File
@@ -1,6 +1,7 @@
//! Resource decoding for the pinned Java Edition client.
#![forbid(unsafe_code)]
pub mod bake;
mod json;
pub mod model;
pub mod resolve;
+22 -2
View File
@@ -69,8 +69,8 @@ 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. Rotation matrices, quad baking,
blockstate selection and rendered appearance need their own checks.
yet connected to the development renderer. Blockstate selection, transformed
models, UV locking and rendered appearance still need their own checks.
`ModelLibrary` resolves parent geometry, ambient occlusion, GUI lighting and
each display context. It shares inherited elements rather than cloning them.
@@ -88,6 +88,26 @@ python3 tools/test_model_resolution_parity.py
cargo +stable test --locked -p horizon-assets
```
Cuboid baking now produces positions in block units, normalized unit-atlas UVs,
face winding and facing, material references, tint indices, shade overrides and
light-emission metadata. It supports single-axis and Euler element rotations
with the pinned rescaling rule. The
[baking comparison](../benchmarks/rust-model-baking-2026-10-06/scoped-parity.json)
matches **7,100 roots across 440 graphs**, using native `FaceBakery` with a real
sprite and the default JOML math options. All bundled block models bake without
errors. Synthetic coverage includes reversed/degenerate bounds, signed zero,
arbitrary rotations, rescaling, null geometry children and parent cycles.
Position, UV and matrix fields compare by exact float bits for these fixtures.
```sh
python3 tools/test_model_baking_parity.py
```
This comparison uses the identity blockstate transform. Atlas packing,
transparency classification, actual tint/light evaluation and GPU rendering
remain separate from the geometry result. Other operating systems still need
their native validation runs.
```sh
cargo +stable test -p horizon-client
cargo +stable clippy --workspace --all-targets -- -D warnings
+3 -2
View File
@@ -16,8 +16,9 @@ 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)
also include parent/texture resolution over 5,648 roots. Quad baking,
blockstate selection and integration with the renderer remain unfinished.
also include parent/texture resolution over 5,648 roots and cuboid baking over
7,100 roots with identity blockstate transforms. Blockstate selection, transformed
models, UV locking and integration with the renderer remain unfinished.
The playable Java performance packs remain available during development.
## Completion criteria and playable builds
@@ -0,0 +1,89 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import com.mojang.blaze3d.platform.NativeImage;
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.*;
import net.minecraft.client.resources.model.sprite.Material;
import net.minecraft.client.renderer.block.dispatch.ModelState;
import net.minecraft.client.renderer.texture.*;
import net.minecraft.client.resources.metadata.animation.FrameSize;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import org.joml.*;
import static dev.horizonlab.parity.VanillaCuboidModelOracle.map;
/** Geometry-only native baker probe with a real 16x16 sprite in a unit atlas. */
public final class VanillaModelBakingOracle {
private static final Gson JSON = new GsonBuilder().serializeNulls().create();
private static final class Sprite extends TextureAtlasSprite {
Sprite(SpriteContents contents) { super(Identifier.parse("horizon:atlas"),contents,16,16,0,0,0); }
}
private static final ModelBaker.Interner INTERNER = new ModelBaker.Interner() {
public Vector3fc vector(Vector3fc vector) { return vector; }
public BakedQuad.MaterialInfo materialInfo(BakedQuad.MaterialInfo value) { return value; }
};
private static long bits(float value) { return Integer.toUnsignedLong(Float.floatToIntBits(value)); }
private static Object matrix(CuboidRotation rotation) {
if(rotation==null)return null;
var m=rotation.transform();
return new long[][]{{bits(m.m00()),bits(m.m10()),bits(m.m20())},
{bits(m.m01()),bits(m.m11()),bits(m.m21())},{bits(m.m02()),bits(m.m12()),bits(m.m22())}};
}
private static Object bake(ResolvedModel model, Sprite sprite) throws Exception {
var geometry=model.getTopGeometry();
var quads=new ArrayList<Object>(); var matrices=new ArrayList<Object>();
if(geometry==UnbakedGeometry.EMPTY)return map("quads",quads,"matrices",matrices);
var textures=model.getTopTextureSlots();
var defaultUv=FaceBakery.class.getDeclaredMethod("defaultFaceUV",Vector3fc.class,Vector3fc.class,Direction.class);defaultUv.setAccessible(true);
for(var element:((UnbakedCuboidGeometry)geometry).elements()) {
matrices.add(matrix(element.rotation()));
for(var entry:element.faces().entrySet()) {
var face=entry.getValue();
var material=textures.getMaterial(face.texture());
if(material==null)material=new Material(Identifier.parse("minecraft:missingno"));
var uv=face.uvs();
if(uv==null)uv=(CuboidFace.UVs)defaultUv.invoke(null,element.from(),element.to(),entry.getKey());
var info=new BakedQuad.MaterialInfo(sprite,null,null,null,null,face.tintIndex(),element.shadeDirectionOverride(),element.lightEmission());
var quad=FaceBakery.bakeQuad(INTERNER,element.from(),element.to(),uv,face.rotation(),info,entry.getKey(),new ModelState(){},element.rotation());
var positions=new long[4][3];var uvs=new long[4][2];
for(int i=0;i<4;i++) {
var p=quad.position(i);positions[i]=new long[]{bits(p.x()),bits(p.y()),bits(p.z())};
long packed=quad.packedUV(i);uvs[i]=new long[]{packed >>> 32,packed & 0xffffffffL};
}
quads.add(map("positions",positions,"uv",uvs,"facing",quad.direction().getName(),
"cull",face.cullForDirection()==null?null:face.cullForDirection().getName(),"tint",face.tintIndex(),
"shade",element.shadeDirectionOverride()==null?null:element.shadeDirectionOverride().getName(),
"light",element.lightEmission(),"material",map("sprite",material.sprite().toString(),"translucent",material.forceTranslucent())));
}
}
return map("quads",quads,"matrices",matrices);
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();
if(org.joml.Options.FASTMATH || org.joml.Options.USE_MATH_FMA)throw new AssertionError("Expected default JOML math options");
var image=new NativeImage(16,16,true);
var contents=new SpriteContents(Identifier.parse("horizon:probe"),new FrameSize(16,16),image);
var sprite=new Sprite(contents);
try (contents; 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>();
for(var entry:row.getAsJsonObject("models").entrySet())models.put(Identifier.parse(entry.getKey()),CuboidModel.fromStream(new StringReader(entry.getValue().getAsString())));
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();Object value;
try {value=bake(Objects.requireNonNull(resolved.get(Identifier.parse(name))),sprite);}
catch(RuntimeException error) {output.write(JSON.toJson(map("case",index,"name",name,"error",true)));output.newLine();continue;}
output.write(JSON.toJson(map("case",index,"name",name,"baked",value)));output.newLine();
}
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Compare cuboid quad positions, UVs and element matrices with the pinned 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, test_model_resolution_parity as resolution
except ImportError:
import lab, test_rust_parity as base, test_cuboid_model_parity as decoding, test_model_resolution_parity as resolution
def corpus():
yield from resolution.corpus()
directions = ['down', 'up', 'north', 'south', 'west', 'east']
models = {}
def add(rotation, start=None, end=None, explicit=False):
index = len(models)
faces = {d: {'texture': '#all', 'cullface': d, 'rotation': 90 * (index % 4),
'tintindex': index % 3 - 1} for d in directions}
if explicit:
for face in faces.values(): face['uv'] = [-0.0, 3.25, 15.5, -1.0]
element = {'from': start or [0, 0, 0], 'to': end or [16, 16, 16], 'faces': faces,
'light_emission': index % 16}
if rotation is not None: element['rotation'] = rotation
if index % 3 == 0: element['shade_direction_override'] = directions[index % 6]
models[f'horizon:geometry_{index}'] = {'textures': {'all': 'block/stone'}, 'elements': [element]}
for bounds in [([0,0,0],[16,16,16]), ([16,16,16],[0,0,0]), ([0,8,0],[16,8,16]),
([0,0,0],[0,0,0]), ([-16,-16,-16],[32,32,32]), ([-0.0,0,0],[16,16,16])]:
for explicit in [False, True]: add(None, *bounds, explicit)
for angle in [-720,-360,-180,-90,-63,-45,-22.5,-0.0,0,22.5,45,63,90,180,270,360,540,720]:
for axis in ['x','y','z']:
for rescale in [False,True]:
add({'origin':[8,8,8], 'axis':axis, 'angle':angle, 'rescale':rescale}, explicit=True)
for x in [0,22.5,90,180]:
for y in [0,45,90,270]:
for z in [-90,0,45,180]:
for rescale in [False,True]:
add({'origin':[8,3,-4], 'x':x, 'y':y, 'z':z, 'rescale':rescale})
rng=random.Random(0x42414b494e47)
for _ in range(1200):
rotation={'origin':[rng.uniform(-64,64) for _ in range(3)], 'rescale':bool(rng.randrange(2))}
if rng.randrange(2): rotation.update(axis=rng.choice(['x','y','z']), angle=rng.uniform(-3600,3600))
else: rotation.update(zip(['x','y','z'],[rng.uniform(-3600,3600) for _ in range(3)]))
add(rotation, [rng.uniform(-16,32) for _ in range(3)], [rng.uniform(-16,32) for _ in range(3)], bool(rng.randrange(2)))
row=resolution.bundle(models)
for token in ['1e100','-1e100','1e-100','-1e-100']:
name='horizon:extreme_'+str(len(row['models']))
raw=json.dumps({'elements':[{'from':[0,0,0],'to':[16,16,16],'faces':{'up':{'texture':'#all'}},'rotation':{'origin':[8,8,8],'axis':'x','angle':'TOKEN','rescale':True}}]})
row['models'][name]=raw.replace('"TOKEN"',token); row['roots'].append(name)
yield row
def client_libraries():
_, directory=lab.config()
manifest=json.loads((directory/'manifest.json').read_text())
libraries=[]
for library in manifest['libraries']:
artifact=library.get('downloads',{}).get('artifact')
if artifact:
relative=Path(artifact['path'])
if relative.is_absolute() or '..' in relative.parts: raise ValueError('Unsafe client library path')
path=directory/'libraries/client'/relative
if lab.digest(path,'sha1')!=artifact['sha1']: raise ValueError('Client dependency checksum changed: '+str(relative))
libraries.append(path)
return libraries
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-baking-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/VanillaModelBakingOracle.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/test_model_resolution_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'], *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_baking_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_baking_fixture.exe' if os.name == 'nt' else 'model_baking_fixture')
run([binary, inputs, build / 'rust.jsonl'], 'rust')
run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaModelBakingOracle', 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_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 client FaceBakery geometry overload with a real unit-atlas sprite and default JOML math options. Identity blockstate transform, all bundled cuboid geometry and synthetic rotations, rescaling, default/explicit UVs, winding, degenerate and reversed bounds, inherited models and missing materials. Positions, UVs and element matrix coefficients compare by float bits. This does not cover blockstate transforms/UV lock, atlas packing, transparency classification, lighting, GPU rendering or gameplay.'}
lab.write_json(report_path, report); print(f'Client cuboid baking: {matched:,} roots match ({rejected:,} rejected); {report_path}', flush=True)
if __name__ == '__main__': main()