diff --git a/Cargo.lock b/Cargo.lock index 6ae1e0340..1d3c8f0c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,6 +578,7 @@ checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" name = "horizon-assets" version = "0.1.0" dependencies = [ + "serde", "serde_json", ] diff --git a/benchmarks/rust-blockstate-model-decoding-2026-10-06/scoped-parity.json b/benchmarks/rust-blockstate-model-decoding-2026-10-06/scoped-parity.json new file mode 100644 index 000000000..2a746dff6 --- /dev/null +++ b/benchmarks/rust-blockstate-model-decoding-2026-10-06/scoped-parity.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." +} diff --git a/crates/horizon-assets/Cargo.toml b/crates/horizon-assets/Cargo.toml index c0e9f214d..a530f5baf 100644 --- a/crates/horizon-assets/Cargo.toml +++ b/crates/horizon-assets/Cargo.toml @@ -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"] } diff --git a/crates/horizon-assets/examples/blockstate_model_fixture.rs b/crates/horizon-assets/examples/blockstate_model_fixture.rs new file mode 100644 index 000000000..53ec83fd2 --- /dev/null +++ b/crates/horizon-assets/examples/blockstate_model_fixture.rs @@ -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::>()}) + } + } +} +fn condition(v: &Condition) -> Value { + match v { + Condition::Or(v) => json!({"or":v.iter().map(condition).collect::>()}), + Condition::And(v) => json!({"and":v.iter().map(condition).collect::>()}), + Condition::Properties(v) => { + json!({"properties":v.iter().map(|(key,terms)| (key, terms.iter().map(|t|json!({"value":t.value,"negated":t.negated})).collect::>())).collect::>()}) + } + } +} +fn definition(v: &BlockStateDefinition) -> Value { + json!({"variants":v.variants.as_ref().map(|v|v.iter().map(|(k,v)|json!([k,model(v)])).collect::>()),"multipart":v.multipart.as_ref().map(|v|v.iter().map(|s|json!({"when":s.condition.as_ref().map(condition),"apply":model(&s.variant)})).collect::>())}) +} +fn main() -> Result<(), Box> { + 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 = 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(()) +} diff --git a/crates/horizon-assets/src/blockstate.rs b/crates/horizon-assets/src/blockstate.rs new file mode 100644 index 000000000..7113e60af --- /dev/null +++ b/crates/horizon-assets/src/blockstate.rs @@ -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), + And(Vec), + Properties(BTreeMap>), +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Selector { + pub condition: Option, + 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>, + pub multipart: Option>, +} +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> { + 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>( + self, + mut map: M, + ) -> std::result::Result { + let mut values: Self::Value = Vec::new(); + let mut indices = BTreeMap::new(); + while let Some((name, value)) = map.next_entry::()? { + 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 { + 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::>>() + }) + .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::>>() + }) + .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 { + 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 { + 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::>>()?; + Ok(Self::Weighted(entries)) + } +} +impl Condition { + fn decode(raw: &RawValue) -> Result { + 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::>>() + }) { + 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::>>()?; + Ok((key, terms)) + }) + .collect::>()?; + Ok(Self::Properties(tests)) + } +} diff --git a/crates/horizon-assets/src/lib.rs b/crates/horizon-assets/src/lib.rs index f487ac555..e52fe3a32 100644 --- a/crates/horizon-assets/src/lib.rs +++ b/crates/horizon-assets/src/lib.rs @@ -3,6 +3,7 @@ pub mod animation; pub mod bake; +pub mod blockstate; mod json; pub mod model; pub mod resolve; diff --git a/docs/native-client.md b/docs/native-client.md index 4e4ddc78f..873f83e40 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -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 diff --git a/docs/rust-parity.md b/docs/rust-parity.md index 023d83016..307398184 100644 --- a/docs/rust-parity.md +++ b/docs/rust-parity.md @@ -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 diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockStateModelOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockStateModelOracle.java new file mode 100644 index 000000000..0ecbe597d --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockStateModelOracle.java @@ -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(); + ((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(); + } + } + } +} diff --git a/tools/test_blockstate_model_parity.py b/tools/test_blockstate_model_parity.py new file mode 100644 index 000000000..8a2548d53 --- /dev/null +++ b/tools/test_blockstate_model_parity.py @@ -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()