Files
horizon-airdef/tools/test_model_baking_parity.py
Ludovic Marie 6d7f11feb2 Prepare: require only this platform's libraries; accept republished 26.3 manifests
lab.prepare imported every client library in the version JSON, ignoring
its OS rules, so a Linux install failed on the macOS-only
java-objc-bridge the official launcher never downloads there. The rule
evaluation moves from runtime.py into lab.py and one selector,
client_library_artifacts, now serves preparation, the runtime and the
model baking harness: OS rules as the launcher applies them, and the
natives for this architecture (classifier suffixes, plain natives being
the x64 build only when an ARM variant is listed). Required libraries
are still checksummed and a missing one still errors.

Mojang republished 26.3.json twice (702fe591, then 11f1aefe, the current
one) with only assetIndex changed: non-English translations, same jars
and libraries. The lock pins the current file and accepts both earlier
ones; preparation takes the launcher's copy so it matches the asset
index installed with it, and an unknown republish errors clearly.
Fixes #41

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:20:17 +02:00

116 lines
8.2 KiB
Python

#!/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 artifact in lab.client_library_artifacts(manifest):
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','-sourcepath',str(lab.ROOT/'mods/horizon/src/test/java'), '-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()