Decode vanilla blockstate variants and multipart conditions

This commit is contained in:
Ludovic Marie committed 2026-10-06 21:16:15 +02:00
1 parent b3a508dba8
commit 54a2b552ee
10 files changed
+469 -1

No files matched your search

Generated
+1
View File
@@ -578,6 +578,7 @@ checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
name = "horizon-assets"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
@@ -0,0 +1,48 @@
{
"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_blockstates": 1288,
"synthetic_definitions": 1351,
"rejected": 468,
"sources_sha256": {
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockStateModelOracle.java": "f6e3bacca9dcd04f4dde093c588f4cda435bb0237f57d27521af2dfe409358d2",
"mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCuboidModelOracle.java": "7526f20513c549483122ab0d5ad24b9e87f38dc2f6aae55addd701a91ac85e43",
"tools/test_blockstate_model_parity.py": "adf720719432c677f37bd10ed6e02089195c1fc4dcf227bb01187c3fd5a1747a",
"tools/test_cuboid_model_parity.py": "4a8b1cdcac335751c23413341ae939eea1c49c38d33d70d0708b8904ab8d0461",
"tools/lab.py": "d00bf107fa01b6c579734a082c3ca4ade3018f0d6851d46844acf17574f66e64",
"tools/test_rust_parity.py": "29d3db08005873528894b45ca10dcd6278753650473b3eb574e53e06ede40ec6",
"Cargo.toml": "3f93319c1eb70dbd7ba25cd74ef343fbf35c52c7c4752f955605c58699cbff9d",
"Cargo.lock": "3b8e27b28da541475b05111f24f2ab96d2e5db88e62d34ab848ac057c09d33e7",
"crates/horizon-assets/Cargo.toml": "d09f70e42198d36c97e6f0be9d3c251f649690824f78ac241158e01171d1f81c",
"crates/horizon-assets/examples/blockstate_model_fixture.rs": "8f063322e57abdf9361a5a3a3badce2a7e727ee6d5e3947071cb448dfb38e8fd",
"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/sprite_animation_fixture.rs": "254314d52deda129ab461221d3d8b95323a60730d374e566c1cd599fba85f072",
"crates/horizon-assets/examples/support/mod.rs": "bcf5a6d8a54845982191e037911c24139237771d6afa4b01b1e83c011489c3f5",
"crates/horizon-assets/src/animation.rs": "ebbf7952146a0e66b41396174492f5702627170b03a57e6cd5a45c97a08c975e",
"crates/horizon-assets/src/bake.rs": "0bc519e93ad8200d89543ceb5c811dd82bbeb0e9003b143385c8a03e758dfe65",
"crates/horizon-assets/src/blockstate.rs": "ec5fef8eb7cf4c5e367c76d394554e97c598371e4d4dc6029b15c6483a75616c",
"crates/horizon-assets/src/json.rs": "99fe8af4fbf5d4eed408a92e829ac1a39597a0d92471a6bff73771fad9c830a8",
"crates/horizon-assets/src/lib.rs": "e39f21c59a2c3e1c53d977b29bd5e0638e5fc41d86ff0fcc1882a6d90fd961e4",
"crates/horizon-assets/src/model.rs": "57ddb3d65e88e7a8aa22c62bc298a2dae7f4e994326a873a31f06c0f36ed051b",
"crates/horizon-assets/src/resolve.rs": "3927ac926cdcbd7d334901cc32d0ecc1e498b1bec98f76a46bca6d4964c13541"
},
"fixtures_sha256": {
".local/rust-blockstate-model-parity/inputs.jsonl": "9ac61d1c85d00d1c4ca1246a832359459a055077d04401599534885cd6f341b9"
},
"rust_output_sha256": "53300626594228aeb6fb75923b49430a26a230f729aa0ead7f05080d039c34b0",
"references": [
{
"side": "client",
"jar_sha256": "4508d006323f24fa02876310c192d739af56516eb259000ac50f0909a68c9a2d",
"output_sha256": "53300626594228aeb6fb75923b49430a26a230f729aa0ead7f05080d039c34b0",
"matched_output_lines": 2639
}
],
"scope": "Actual StrictJsonParser and BlockStateModelDispatcher codec used by the pinned resource loader. Every bundled blockstate JSON plus variants, XYZ quarter turns, UV-lock flags, weighted entries, source order, multipart conditions and malformed synthetic cases. No registry binding, property selection, random draw selection, transformed baking, rendered pixels or gameplay parity claim."
}
+1
View File
@@ -7,4 +7,5 @@ rust-version.workspace = true
description = "Horizon version-specific model and resource decoding"
[dependencies]
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["raw_value"] }
@@ -0,0 +1,44 @@
use horizon_assets::blockstate::{BlockStateDefinition, Condition, Variant, VariantSet};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn variant(v: &Variant) -> Value {
json!({"model":v.model,"rotations":v.rotations,"uvlock":v.uv_lock})
}
fn model(v: &VariantSet) -> Value {
match v {
VariantSet::Single(v) => json!({"single":variant(v)}),
VariantSet::Weighted(entries) => {
json!({"weighted":entries.iter().map(|(v,w)|json!([variant(v),w])).collect::<Vec<_>>()})
}
}
}
fn condition(v: &Condition) -> Value {
match v {
Condition::Or(v) => json!({"or":v.iter().map(condition).collect::<Vec<_>>()}),
Condition::And(v) => json!({"and":v.iter().map(condition).collect::<Vec<_>>()}),
Condition::Properties(v) => {
json!({"properties":v.iter().map(|(key,terms)| (key, terms.iter().map(|t|json!({"value":t.value,"negated":t.negated})).collect::<Vec<_>>())).collect::<BTreeMap<_,_>>()})
}
}
}
fn definition(v: &BlockStateDefinition) -> Value {
json!({"variants":v.variants.as_ref().map(|v|v.iter().map(|(k,v)|json!([k,model(v)])).collect::<Vec<_>>()),"multipart":v.multipart.as_ref().map(|v|v.iter().map(|s|json!({"when":s.condition.as_ref().map(condition),"apply":model(&s.variant)})).collect::<Vec<_>>())})
}
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 line in input.lines() {
let row: Vec<String> = serde_json::from_str(&line?)?;
let result = match BlockStateDefinition::from_json(&row[1]) {
Ok(v) => json!({"name":row[0],"definition":definition(&v)}),
Err(_) => json!({"name":row[0],"error":true}),
};
writeln!(output, "{result}")?;
}
Ok(())
}
+226
View File
@@ -0,0 +1,226 @@
//! Pinned blockstate resource definitions, before registry binding and baking.
use crate::json::*;
use serde::{
Deserializer,
de::{MapAccess, Visitor},
};
use serde_json::value::RawValue;
use std::{collections::BTreeMap, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Variant {
pub model: String,
/// X, Y, Z rotations in quarter turns.
pub rotations: [u8; 3],
pub uv_lock: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VariantSet {
Single(Variant),
Weighted(Vec<(Variant, i32)>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Term {
pub value: String,
pub negated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Condition {
Or(Vec<Condition>),
And(Vec<Condition>),
Properties(BTreeMap<String, Vec<Term>>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Selector {
pub condition: Option<Condition>,
pub variant: VariantSet,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlockStateDefinition {
/// Preserve resource order: overlapping variant selectors have order-sensitive
/// native recovery behavior when bound to a registry.
pub variants: Option<Vec<(String, VariantSet)>>,
pub multipart: Option<Vec<Selector>>,
}
fn optional<'a>(fields: &Object<'a>, name: &str) -> Option<&'a RawValue> {
fields.get(name).copied().filter(|v| v.get() != "null")
}
fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
struct Ordered;
impl<'de> Visitor<'de> for Ordered {
type Value = Vec<(String, &'de RawValue)>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an object")
}
fn visit_map<M: MapAccess<'de>>(
self,
mut map: M,
) -> std::result::Result<Self::Value, M::Error> {
let mut values: Self::Value = Vec::new();
let mut indices = BTreeMap::new();
while let Some((name, value)) = map.next_entry::<String, &'de RawValue>()? {
if let Some(&index) = indices.get(&name) {
values[index] = (name, value);
} else {
indices.insert(name.clone(), values.len());
values.push((name, value));
}
}
Ok(values)
}
}
Ok(serde_json::Deserializer::from_str(raw.get()).deserialize_map(Ordered)?)
}
impl BlockStateDefinition {
pub fn from_json(text: &str) -> Result<Self> {
let raw: &RawValue = serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(text))?;
validate_number_tokens(raw)?;
let fields = object(raw)?;
let variants = optional(&fields, "variants")
.map(|v| {
let values = ordered_object(v)?;
if values.is_empty() {
return Err(error("empty variant map"));
}
values
.into_iter()
.map(|(key, value)| Ok((key, VariantSet::decode(value)?)))
.collect::<Result<Vec<_>>>()
})
.transpose()?;
let multipart = optional(&fields, "multipart")
.map(|v| {
let values = array(v)?;
if values.is_empty() {
return Err(error("empty multipart list"));
}
values
.into_iter()
.map(|v| {
let fields = object(v)?;
Ok(Selector {
condition: optional(&fields, "when")
.map(Condition::decode)
.transpose()?,
variant: VariantSet::decode(required(&fields, "apply")?)?,
})
})
.collect::<Result<Vec<_>>>()
})
.transpose()?;
if variants.is_none() && multipart.is_none() {
return Err(error("no variants or multipart"));
}
Ok(Self {
variants,
multipart,
})
}
}
impl Variant {
fn decode(raw: &RawValue) -> Result<Self> {
let fields = object(raw)?;
let mut rotations = [0; 3];
for (target, name) in rotations.iter_mut().zip(["x", "y", "z"]) {
let angle = optional(&fields, name)
.map(integer)
.transpose()?
.unwrap_or(0)
.rem_euclid(360);
if angle % 90 != 0 {
return Err(error("model rotation must be a multiple of 90"));
}
*target = (angle / 90) as u8;
}
Ok(Self {
model: identifier(&string(required(&fields, "model")?)?)?,
rotations,
uv_lock: optional(&fields, "uvlock")
.map(|v| serde_json::from_str(v.get()))
.transpose()?
.unwrap_or(false),
})
}
}
impl VariantSet {
fn decode(raw: &RawValue) -> Result<Self> {
if !raw.get().starts_with('[') {
return Ok(Self::Single(Variant::decode(raw)?));
}
let values = array(raw)?;
if values.is_empty() {
return Err(error("empty weighted variants"));
}
let mut total = 0i32;
let entries = values
.into_iter()
.map(|v| {
let fields = object(v)?;
let weight = optional(&fields, "weight")
.map(integer)
.transpose()?
.unwrap_or(1);
if weight <= 0 {
return Err(error("nonpositive variant weight"));
}
total = total
.checked_add(weight)
.ok_or_else(|| error("variant weight overflow"))?;
Ok((Variant::decode(v)?, weight))
})
.collect::<Result<Vec<_>>>()?;
Ok(Self::Weighted(entries))
}
}
impl Condition {
fn decode(raw: &RawValue) -> Result<Self> {
let fields = object(raw)?;
if fields.len() == 1 {
for name in ["OR", "AND"] {
if let Some(raw) = fields.get(name) {
if let Ok(values) = array(raw).and_then(|values| {
values
.into_iter()
.map(Self::decode)
.collect::<Result<Vec<_>>>()
}) {
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
}
}
}
if fields.is_empty() {
return Err(error("empty property conditions"));
}
let tests = fields
.into_iter()
.map(|(key, raw)| {
let value = match raw.get().as_bytes().first() {
Some(b'"') => string(raw)?,
Some(b't' | b'f') => raw.get().to_owned(),
_ => integer(raw)?.to_string(),
};
let terms = value
.split('|')
.map(|v| {
let negated = v.starts_with('!');
let value = v.strip_prefix('!').unwrap_or(v);
if value.is_empty() {
return Err(error("empty property term"));
}
Ok(Term {
value: value.to_owned(),
negated,
})
})
.collect::<Result<Vec<_>>>()?;
Ok((key, terms))
})
.collect::<Result<_>>()?;
Ok(Self::Properties(tests))
}
}
+1
View File
@@ -3,6 +3,7 @@
pub mod animation;
pub mod bake;
pub mod blockstate;
mod json;
pub mod model;
pub mod resolve;
+14
View File
@@ -129,6 +129,20 @@ transparency classification, actual tint/light evaluation and GPU rendering
remain separate from the geometry result. Other operating systems still need
their native validation runs.
Blockstate resources now decode into ordered variant selectors and multipart
conditions. The [native blockstate comparison](../benchmarks/rust-blockstate-model-decoding-2026-10-06/scoped-parity.json)
covers all 1,288 bundled definitions and 1,351 synthetic cases (2,639 total,
468 expected rejections). It uses the same `StrictJsonParser` and
`BlockStateModelDispatcher.CODEC` as the pinned loader. The Rust representation
preserves single versus weighted variants, all three quarter-turn axes, UV-lock
flags, positive weights and total-weight limits, ordered selectors, nested
AND/OR conditions and per-term negation. Registry binding, property selection,
random draw consumption and transformed baking remain separate work.
```sh
python3 tools/test_blockstate_model_parity.py
```
The sprite animation module reproduces the pinned metadata codec's integer
conversion, strict primitive types, optional null fields and frame dimensions.
It preserves repeated frames, filters out-of-image frame indices, and treats
+3 -1
View File
@@ -19,7 +19,9 @@ block-model JSON files and 2,457 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 and cuboid baking over
7,100 roots with identity blockstate transforms. Blockstate selection, transformed
models and UV locking remain unfinished. The native renderer now draws an
models and UV locking remain unfinished. Blockstate resource decoding now
matches all 1,288 bundled definitions and 1,351 synthetic cases; registry
binding and model selection are not yet connected to the renderer. The native renderer now draws an
inspection gallery from the decoded/resolved/baked models, including binary
cutouts and stepped lantern animation; these are not yet gameplay blocks.
Animation metadata, frame sequences and tick-state selection now have native
@@ -0,0 +1,48 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import com.mojang.serialization.JsonOps;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.renderer.block.dispatch.*;
import net.minecraft.client.renderer.block.dispatch.multipart.*;
import net.minecraft.util.StrictJsonParser;
import static dev.horizonlab.parity.VanillaCuboidModelOracle.map;
/** Original adapter around the actual blockstate resource parser and codec. */
public final class VanillaBlockStateModelOracle {
private static final Gson JSON = new GsonBuilder().serializeNulls().create();
private static Object variant(Variant value) {
var state=value.modelState();
return map("model",value.modelLocation().toString(),"rotations",new int[]{state.x().ordinal(),state.y().ordinal(),state.z().ordinal()},"uvlock",state.uvLock());
}
private static Object model(BlockStateModel.Unbaked value) {
if(value instanceof SingleVariant.Unbaked single)return map("single",variant(single.variant()));
var weighted=(WeightedVariants.Unbaked)value;
return map("weighted",weighted.entries().unwrap().stream().map(e->Arrays.asList(variant(((SingleVariant.Unbaked)e.value()).variant()),e.weight())).toList());
}
private static Object condition(Condition value) {
if(value instanceof CombinedCondition combined)return map(combined.operation().name().toLowerCase(Locale.ROOT),combined.terms().stream().map(VanillaBlockStateModelOracle::condition).toList());
var tests=new TreeMap<String,Object>();
((KeyValueCondition)value).tests().forEach((key,terms)->tests.put(key,terms.entries().stream().map(t->map("value",t.value(),"negated",t.negated())).toList()));
return map("properties",tests);
}
private static Object definition(BlockStateModelDispatcher value) {
var variants=value.simpleModels().map(m->m.models().entrySet().stream().map(e->Arrays.asList(e.getKey(),model(e.getValue()))).toList()).orElse(null);
var multipart=value.multiPart().map(m->m.selectors().stream().map(s->map("when",s.condition().map(VanillaBlockStateModelOracle::condition).orElse(null),"apply",model(s.variant()))).toList()).orElse(null);
return map("variants",variants,"multipart",multipart);
}
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]))) {
for(String line;(line=input.readLine())!=null;) {
var row=JsonParser.parseString(line).getAsJsonArray();Object result;
BlockStateModelDispatcher decoded;
try { decoded=BlockStateModelDispatcher.CODEC.parse(JsonOps.INSTANCE,StrictJsonParser.parse(row.get(1).getAsString())).getOrThrow(); }
catch(RuntimeException error) {output.write(JSON.toJson(map("name",row.get(0).getAsString(),"error",true)));output.newLine();continue;}
result=map("name",row.get(0).getAsString(),"definition",definition(decoded));
output.write(JSON.toJson(result));output.newLine();
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Compare blockstate resource decoding with the pinned client loader's codec."""
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 corpus():
model={'model':'block/stone'}
def root(value): return {'variants':{'':value}}
for value in [None,{},[],False,0,'x',{'variants':{}},{'multipart':[]},{'variants':None},{'variants':None,'multipart':[{'apply':model}]},root(model),root([model]),root([])]:yield json.dumps(value)
values=[None,True,False,0,1,-1,90,90.9,-90,270,360,450,4294967296,4294967386,1e100,'90','true','',[],{},[model]]
for key in ['model','x','y','z','uvlock','weight']:
for value in values:
variant=dict(model);variant[key]=value
yield json.dumps(root(variant));yield json.dumps(root([variant]))
for weights in [[2147483647],[2147483647,1],[2147483646,1],[0,1],[-1,2],[1,1],[1.9,2.9]]:
yield json.dumps(root([dict(model,weight=w) for w in weights]))
for name in ['block/stone',':block/stone',':','minecraft:','a:b:c','A:a','a:é','../a']:
yield json.dumps(root({'model':name}))
for x in [-360,-90,0,90,180,270,360,450]:
for y in [0,90,180,270]:
for z in [0,90,180,270]:yield json.dumps(root(dict(model,x=x,y=y,z=z,uvlock=True)))
terms=[None,True,False,0,1,1.9,-1,4294967297,'true','false','!true','true|false','!true|false','!!true','!','', '|','true|','|true','a||b',' a ',[],{},['true']]
conditions=[{},None,True,[],{'OR':[]},{'AND':[]},{'OR':[{'a':'b'}]},{'AND':[{'a':'b'},{'c':'d'}]},{'OR':'true'},{'OR':[],'AND':[]},{'OR':[],'a':'b'}]
for key in ['a','OR','AND','']:
conditions.extend({key:t} for t in terms)
for condition in conditions:
for apply in [model,[model],[],None]:yield json.dumps({'multipart':[{'when':condition,'apply':apply}]})
for raw in ['{} trailing','{} {}','\ufeff'+json.dumps(root(model)), '{"variants":{"b":{"model":"b"},"a":{"model":"a"},"b":{"model":"c"}}}', '{"variants":{"":{"model":"a"}},"multipart":[{"apply":{"model":"b"}}]}']:
yield raw
rng=random.Random(0x424c4f434b)
for _ in range(500):
selectors=[]
for _ in range(rng.randrange(1,8)):
condition={rng.choice(['a','b','c']):rng.choice(['x','y','!x','x|y'])}
if rng.randrange(2):condition={rng.choice(['OR','AND']):[condition,{'a':'x'}]}
variants=[dict(model,weight=rng.randrange(1,10),x=rng.randrange(-4,5)*90,y=rng.randrange(4)*90,z=rng.randrange(4)*90,uvlock=bool(rng.randrange(2))) for _ in range(rng.randrange(1,6))]
selectors.append({'when':condition,'apply':variants if rng.randrange(2) else variants[0]})
yield json.dumps({'multipart':selectors})
for number in ['1844674407370955161601','18446744073709551616.1','1e10000','1e9999','1' * 1024]:
yield '{"variants":{"":{"model":"block/stone","x":'+number+'}}}'
yield '{"variants":{"":{"model":"block/stone"}},"unknown":'+number+'}'
def main():
lock,jars,libraries=base.verify_artifacts()
build=lab.LOCAL/'rust-blockstate-model-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/VanillaBlockStateModelOracle.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=base.source_hashes(sources)
with zipfile.ZipFile(jars['client']) as archive:
names=sorted(n for n in archive.namelist() if n.startswith('assets/minecraft/blockstates/') and n.endswith('.json'))
rows=[(n,archive.read(n).decode()) for n in names]
synthetic=list(corpus());rows.extend((f'synthetic/{i}',raw) for i,raw in enumerate(synthetic))
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','blockstate_model_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'/('blockstate_model_fixture.exe' if os.name=='nt' else 'blockstate_model_fixture')
run([binary,inputs,build/'rust.jsonl'],'rust');run(['java','-Xmx512M','-cp',str(classes)+os.pathsep+dependencies,'dev.horizonlab.parity.VanillaBlockStateModelOracle',inputs,build/'client.jsonl'],'client')
rejected=decoding.canonicalize(build/'client.jsonl');decoding.canonicalize(build/'rust.jsonl')
native=[json.loads(line) for line in (build/'client.jsonl').read_text().splitlines()];rust=[json.loads(line) for line in (build/'rust.jsonl').read_text().splitlines()]
for left,right in zip(native,rust,strict=True):
if left!=right:raise AssertionError('Blockstate model mismatch: '+left['name'])
matched=base.compare(build/'client.jsonl',build/'rust.jsonl');assert all('error' not in row for row in native[:len(names)]),'Bundled blockstate rejected'
base.ensure_sources_unchanged(sources,tested);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_blockstates':len(names),'synthetic_definitions':len(synthetic),'rejected':rejected,'sources_sha256':tested,'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 StrictJsonParser and BlockStateModelDispatcher codec used by the pinned resource loader. Every bundled blockstate JSON plus variants, XYZ quarter turns, UV-lock flags, weighted entries, source order, multipart conditions and malformed synthetic cases. No registry binding, property selection, random draw selection, transformed baking, rendered pixels or gameplay parity claim.'}
lab.write_json(report_path,report);print(f'Native blockstate models: {matched:,} definitions match ({rejected:,} rejected); {report_path}',flush=True)
if __name__=='__main__':main()