Files
horizon-airdef/tools/test_entity_model_parity.py
T
Ludovic Marie 58fe23d330 Match vanilla entity model poses on every compared layer
The warden's walk, idle wobble and pulsating spots now use Mth's sine table
as WardenModel and WardenRenderer do, and baby sniffers no longer apply
SnifferAnimation.BABY_TRANSFORM: SnifferRenderer wraps the baby geometry in a
plain SnifferModel, so that animation never runs in 26.3. The harness now
drives the illusioner through explicit arm poses like the other illagers, and
the oracle shows its hat as IllusionerRenderer does. All 132 compared layers
match over 3,876 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:58:56 +02:00

297 lines
18 KiB
Python

#!/usr/bin/env python3
"""Compare entity model geometry and setupAnim poses with the vanilla 26.3 client.
The Java oracle bakes each vanilla model layer, builds the model's own render
state class with the generated walk/age/head/attack inputs (plus per-species
state fields), runs the real `setupAnim` and writes every part's pose.
`examples/entity_anim_fixture` replays the same inputs through Horizon's
`mobs::anim::setup` and the loaded model tree; the two must agree on every part
pose and on the baked polygon count and vertex sums.
test_entity_model_parity.py [--filter SUBSTRING] [--detail LAYER] [--tolerance EPS]
"""
import argparse
import json
import math
import os
from pathlib import Path
import random
import re
import subprocess
import sys
try:
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
except ImportError:
import lab, test_rust_parity as base, test_model_baking_parity as baking
CLIENT = lab.LOCAL / 'minecraft' / '26.3' / 'sources' / 'client' / 'net' / 'minecraft' / 'client'
# Models that no renderer builds with a plain `new Model(context.bakeLayer(CONSTANT))`.
EXTRAS = [
('minecraft:zombie#main', 'ZombieModel'), ('minecraft:zombie_baby#main', 'BabyZombieModel'),
('minecraft:husk#main', 'ZombieModel'), ('minecraft:husk_baby#main', 'BabyZombieModel'),
('minecraft:zombie_villager#main', 'ZombieVillagerModel'),
('minecraft:zombie_villager_baby#main', 'BabyZombieVillagerModel'),
('minecraft:zombified_piglin#main', 'AdultZombifiedPiglinModel'),
('minecraft:zombified_piglin_baby#main', 'BabyZombifiedPiglinModel'),
('minecraft:skeleton#main', 'SkeletonModel'), ('minecraft:stray#main', 'SkeletonModel'),
('minecraft:wither_skeleton#main', 'SkeletonModel'),
('minecraft:spider#main', 'SpiderModel'), ('minecraft:cave_spider#main', 'SpiderModel'),
('minecraft:enderman#main', 'EndermanModel'), ('minecraft:endermite#main', 'EndermiteModel'),
('minecraft:piglin#main', 'AdultPiglinModel'), ('minecraft:piglin_brute#main', 'AdultPiglinModel'),
('minecraft:piglin_baby#main', 'BabyPiglinModel'),
('minecraft:hoglin#main', 'HoglinModel'), ('minecraft:zoglin#main', 'HoglinModel'),
('minecraft:hoglin_baby#main', 'BabyHoglinModel'), ('minecraft:zoglin_baby#main', 'BabyHoglinModel'),
('minecraft:guardian#main', 'GuardianModel'), ('minecraft:elder_guardian#main', 'GuardianModel'),
('minecraft:evoker#main', 'IllagerModel'), ('minecraft:illusioner#main', 'IllagerModel'),
('minecraft:pillager#main', 'IllagerModel'), ('minecraft:vindicator#main', 'IllagerModel'),
('minecraft:llama#main', 'LlamaModel'), ('minecraft:trader_llama#main', 'LlamaModel'),
('minecraft:llama_baby#main', 'BabyLlamaModel'), ('minecraft:trader_llama_baby#main', 'BabyLlamaModel'),
('minecraft:donkey#main', 'DonkeyModel'), ('minecraft:mule#main', 'DonkeyModel'),
('minecraft:donkey_baby#main', 'BabyDonkeyModel'), ('minecraft:mule_baby#main', 'BabyDonkeyModel'),
('minecraft:skeleton_horse#main', 'HorseModel'), ('minecraft:zombie_horse#main', 'HorseModel'),
('minecraft:skeleton_horse_baby#main', 'BabyHorseModel'), ('minecraft:zombie_horse_baby#main', 'BabyHorseModel'),
('minecraft:cave_spider#main', 'SpiderModel'),
('minecraft:stray#main', 'SkeletonModel'),
]
# Layers a renderer reuses for another entity under a different constant.
SKIP_LAYERS = re.compile(r'(banner|bell|book|skull|head|chest|shulker_box|boat|cushion|conduit|leash|arrow|trident|'
r'shield|elytra|spin_attack|ears|cape|decorated_pot|minecart|wind_charge|evoker_fangs|'
r'llama_spit|shulker_bullet|bee_stinger|end_crystal|armor_stand|copper_golem|#(helmet|chestplate|leggings|'
r'boots|outer|armor|decor|saddle|collar|wool|wool_undercoat|eyes|wind|inner|pattern|tendrils|'
r'heart|bioluminescent|pulsating_spots|water_patch|flag|cage|shell)$)')
def layer_constant(name):
"""minecraft:cow_baby#main -> constant COW_BABY (the main layer of a model)."""
return name.split(':')[1].split('#')[0].upper()
def renderer_pairs():
pairs = {}
pattern = re.compile(r'new (\w+)\(\s*(?:context|this\.context|\w*)\.bakeLayer\(ModelLayers\.(\w+)\)')
for path in sorted((CLIENT / 'renderer').rglob('*.java')):
for match in pattern.finditer(path.read_text()):
pairs[match.group(2)] = match.group(1)
return pairs
def layers(models):
chosen = {}
pairs = renderer_pairs()
for key in models:
if not key.endswith('#main') or SKIP_LAYERS.search(key):
continue
constant = layer_constant(key)
if constant in pairs:
chosen[key] = pairs[constant]
for key, cls in EXTRAS:
if key in models:
chosen[key] = cls
return chosen
def cases(layer):
rng = random.Random(0x414e494d + sum(map(ord, layer)))
baby = '_baby#' in layer
base_cases = [
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
(1.0, 0.5, 10.0, 0.0, 0.0, 0.0),
(3.7, 0.6, 57.3, 20.0, -15.0, 0.0),
(12.25, 1.0, 123.5, -35.0, 40.0, 0.0),
(7.5, 0.3, 33.3, 5.0, 5.0, 0.35),
(2.0, 0.0, 77.7, 45.0, -60.0, 0.8),
]
for _ in range(14):
base_cases.append((rng.uniform(0, 30), rng.uniform(0, 1), rng.uniform(0, 400),
rng.uniform(-50, 50), rng.uniform(-70, 70),
rng.choice([0.0, rng.uniform(0.01, 0.99)])))
cases_out = []
for pos, speed, age, y, x, attack in base_cases:
cases_out.append({'walkAnimationPos': pos, 'walkAnimationSpeed': speed, 'ageInTicks': age,
'yRot': y, 'xRot': x, 'swingAnimation': attack, 'isBaby': baby, 'ageScale': 0.5 if baby else 1.0,
'entityId': 7, 'yHeadRot': y, 'yBodyRot': 0.0})
name = layer.split(':')[1].split('#')[0].removesuffix('_baby')
cases_out = [{**c, **DEFAULTS.get(name, {})} for c in cases_out]
yield from cases_out
yield from species_cases(layer, cases_out)
T, F = True, False
FOX = [{'isCrouching': T, 'crouchAmount': 0.8}, {'isSleeping': T}, {'isSitting': T}, {'isPouncing': T, 'crouchAmount': 0.6},
{'isFaceplanted': T}, {'headRollAngle': 0.3}, {'isCrouching': T, 'isPouncing': T, 'crouchAmount': 0.4}]
ILLAGER = [{'armPose': pose, 'maxCrossbowChargeDuration': 25, 'ticksUsingItem': 10.0}
for pose in ['NEUTRAL', 'CROSSED', 'ATTACKING', 'SPELLCASTING', 'BOW_AND_ARROW', 'CROSSBOW_HOLD',
'CROSSBOW_CHARGE', 'CELEBRATING']] + [{'isRiding': T, 'armPose': 'CROSSED'}]
FELINE = [{'isCrouching': T}, {'isSprinting': T}, {'isSitting': T}, {'lieDownAmount': 0.7, 'lieDownAmountTail': 0.5},
{'relaxStateOneAmount': 0.6}, {'isSitting': T, 'lieDownAmount': 1.0, 'lieDownAmountTail': 0.2}]
ZOMBIE = [{'isAggressive': T}, {'isCrouching': T}, {'isPassenger': T}, {'isAggressive': T, 'isPassenger': T, 'isCrouching': T}]
SKELETON = ZOMBIE + [{'isAggressive': T, 'isHoldingBow': T, 'rightArmPose': 'BOW_AND_ARROW'}, {'isHoldingBow': T},
{'isAggressive': T, 'isHoldingBow': T, 'rightArmPose': 'BOW_AND_ARROW', 'isCrouching': T}]
PIGLIN = [{'armPose': pose, 'maxCrossbowChageDuration': 25.0, 'ticksUsingItem': 12.0}
for pose in ['DEFAULT', 'DANCING', 'ATTACKING_WITH_MELEE_WEAPON', 'CROSSBOW_HOLD', 'CROSSBOW_CHARGE', 'ADMIRING_ITEM']]
EQUINE = [{'eatAnimation': 0.6}, {'standAnimation': 0.5}, {'eatAnimation': 0.3, 'standAnimation': 0.4, 'feedingAnimation': 0.7},
{'isInWater': T}, {'standAnimation': 1.0, 'eatAnimation': 1.0}]
WATER = [{'isInWater': T}, {'isInWater': F}]
SPECIES = {
'horse': EQUINE, 'donkey': EQUINE + [{'hasChest': T}], 'mule': EQUINE + [{'hasChest': T}], 'zombie_horse': EQUINE, 'skeleton_horse': EQUINE,
'iron_golem': [{'attackTicksRemaining': 7.0}, {'offerFlowerTick': 30}, {'attackTicksRemaining': 0.0, 'offerFlowerTick': 0}],
'hoglin': [{'attackAnimationRemainingTicks': t} for t in (3, 7, 10)],
'zoglin': [{'attackAnimationRemainingTicks': t} for t in (3, 7, 10)],
'ravager': [{'stunnedTicksRemaining': 20.0}, {'attackTicksRemaining': 8.0}, {'attackTicksRemaining': 3.0},
{'roarAnimation': 0.5}],
'vex': [{'isCharging': T}, {'isCharging': F}],
'squid': [{'tentacleAngle': 0.7}], 'glow_squid': [{'tentacleAngle': 1.2}],
'dolphin': [{'isMoving': T}, {'isMoving': F}],
'cod': WATER, 'salmon': WATER, 'tropical_fish_small': WATER,
'allay': [{'isDancing': T, 'isSpinning': T, 'spinningProgress': 0.6}, {'isDancing': T, 'isSpinning': F, 'spinningProgress': 0.2},
{'holdingAnimationProgress': 0.8}, {'holdingAnimationProgress': 1.0}],
'camel': [{'sitAnimationState': 2}, {'sitPoseAnimationState': 3}, {'sitUpAnimationState': 1}, {'idleAnimationState': 4},
{'dashAnimationState': 2}, {'jumpCooldown': 20.0}],
'chicken': [{'flap': 2.0, 'flapSpeed': 0.7}, {'flap': 5.3, 'flapSpeed': 1.0}],
'wolf': [{'isSitting': T}, {'isAngry': T, 'tailAngle': 1.5393804}, {'shakeAnim': 0.9}, {'interest': 0.6},
{'isSitting': T, 'shakeAnim': 0.5, 'interest': 0.3}],
'zombie': ZOMBIE, 'husk': ZOMBIE, 'drowned': ZOMBIE, 'zombie_villager': ZOMBIE, 'zombified_piglin': ZOMBIE, 'giant': ZOMBIE,
'skeleton': SKELETON, 'stray': SKELETON, 'wither_skeleton': SKELETON, 'bogged': SKELETON,
'piglin': PIGLIN, 'piglin_brute': PIGLIN,
'cat': FELINE, 'ocelot': FELINE,
'creaking': [{'canMove': F}, {'attackAnimationState': 2}, {'invulnerabilityAnimationState': 3}, {'deathAnimationState': 1}],
'evoker': ILLAGER, 'pillager': ILLAGER, 'vindicator': ILLAGER, 'illusioner': ILLAGER,
'sheep': [{'eatTick': t} for t in (2, 10, 36, 39)],
'guardian': [{'tailAnimation': 4.0, 'spikesAnimation': 0.3}, {'tailAnimation': 0.0, 'spikesAnimation': 0.0}],
'elder_guardian': [{'tailAnimation': 4.0, 'spikesAnimation': 0.3}],
'shulker': [{'peekAmount': v} for v in (0.0, 0.2, 0.5, 1.0)],
'witch': [{'isHoldingItem': T}, {'isHoldingItem': F}],
'goat': [{'hasLeftHorn': T, 'hasRightHorn': T}, {'hasLeftHorn': F, 'hasRightHorn': T, 'rammingXHeadRot': 0.6},
{'hasLeftHorn': T, 'hasRightHorn': F}],
'polar_bear': [{'standScale': 0.0}, {'standScale': 0.5}, {'standScale': 1.0}],
'panda': [{'isUnhappy': T}, {'isSneezing': T, 'sneezeTime': 5}, {'isSneezing': T, 'sneezeTime': 16},
{'sitAmount': 0.6, 'isEating': T}, {'sitAmount': 1.0, 'isScared': T}, {'sitAmount': 0.3},
{'lieOnBackAmount': 0.5}, {'rollAmount': 0.7}],
'llama': [{'hasChest': T}, {'hasChest': F}],
'trader_llama': [{'hasChest': T}, {'hasChest': F}],
'turtle': [{'isOnLand': T}, {'isOnLand': T, 'isLayingEgg': T}, {'isOnLand': F, 'hasEgg': T}, {'isOnLand': T, 'hasEgg': T}],
'strider': [{'isRidden': T}, {'isRidden': F}],
'parrot': [{'pose': pose, 'flapAngle': 0.4} for pose in ['STANDING', 'FLYING', 'ON_SHOULDER', 'SITTING', 'PARTY']],
'bee': [{'hasStinger': T}, {'hasStinger': F, 'isOnGround': T}, {'isAngry': T}, {'rollAmount': 0.6, 'isAngry': T},
{'isOnGround': T, 'hasStinger': T}],
'bat': [{'isResting': T, 'restAnimationState': 3}, {'flyAnimationState': 2}, {'isResting': F, 'flyAnimationState': 0}],
'armadillo': [{'isHidingInShell': T}, {'isHidingInShell': T, 'rollUpAnimationState': 2},
{'rollOutAnimationState': 4}, {'peekAnimationState': 1}],
'axolotl': [{'playingDeadFactor': 0.0, 'inWaterFactor': 1.0, 'onGroundFactor': 0.0, 'movingFactor': 1.0},
{'inWaterFactor': 1.0, 'movingFactor': 0.0}, {'onGroundFactor': 1.0, 'movingFactor': 1.0},
{'onGroundFactor': 1.0, 'movingFactor': 0.0}, {'inWaterFactor': 0.5, 'onGroundFactor': 0.5, 'movingFactor': 0.5},
{'playingDeadFactor': 1.0, 'inWaterFactor': 1.0},
{'walkAnimationState': 1}, {'swimAnimation': 2}, {'idleOnGroundAnimationState': 3},
{'idleUnderWaterAnimationState': 2}, {'idleUnderWaterOnGroundAnimationState': 1}, {'playDeadAnimationState': 4}],
'frog': [{'isSwimming': T}, {'isSwimming': F}, {'jumpAnimationState': 2}, {'croakAnimationState': 1},
{'tongueAnimationState': 3}, {'swimIdleAnimationState': 2, 'isSwimming': T}],
'rabbit': [{'hopAnimationState': 2}, {'idleHeadTiltAnimationState': 1}],
'fox': FOX,
}
# Render-state defaults that are not zero/false in vanilla.
DEFAULTS = {'creaking': {'canMove': T}, 'wolf': {'tailAngle': 0.6283185307179586}, 'axolotl': {'inWaterFactor': 1.0}, 'bee': {'hasStinger': T}, 'sheep': {'eatTick': 0},
'goat': {'hasLeftHorn': T, 'hasRightHorn': T}, 'parrot': {'pose': 'STANDING'},
'phantom': {'flapOffset': 21.0}, 'guardian': {'tailAnimation': 1.3, 'spikesAnimation': 1.0},
'elder_guardian': {'tailAnimation': 1.3, 'spikesAnimation': 1.0}}
def species_cases(layer, base_cases):
name = layer.split(':')[1].split('#')[0].removesuffix('_baby')
for extra in SPECIES.get(name, []):
for index in (2, 3, 6):
yield {**base_cases[index], **extra}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--detail', default='')
parser.add_argument('--tolerance', type=float, default=5e-7)
args = parser.parse_args()
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
build = lab.LOCAL / 'rust-entity-model-parity'; build.mkdir(parents=True, exist_ok=True)
resources = lab.LOCAL / 'horizon-client' / '26.3'
models = json.loads((resources / 'entity-models.json').read_text())
chosen = {k: v for k, v in layers(models).items() if args.filter in k}
extra_cases = json.loads((Path(__file__).with_suffix('.cases.json')).read_text()) \
if Path(__file__).with_suffix('.cases.json').exists() else {}
lines = []
for layer, cls in sorted(chosen.items()):
for case in cases(layer):
lines.append(f'{layer} {cls}|{json.dumps(case)}')
for case in extra_cases.get(layer.split(':')[1].split('#')[0], []):
lines.append(f'{layer} {cls}|{json.dumps(case)}')
inputs = build / 'inputs.txt'; inputs.write_text('\n'.join(lines) + '\n')
classes = build / 'classes'; classes.mkdir(exist_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityAnimOracle.java'
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
def run(command, name):
with (build / (name + '.log')).open('w') as log:
result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
if result.returncode:
print((build / (name + '.log')).read_text()[-4000:]); sys.exit(f'{name} failed')
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-cp', dependencies, '-d', classes, oracle], 'compile-java')
run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaEntityAnimOracle',
inputs, build / 'vanilla.jsonl', jars['client']], 'vanilla')
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'entity_anim_fixture', '--',
resources, build / 'vanilla.jsonl', build / 'rust.jsonl'], 'rust')
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
assert len(vanilla) == len(rust), (len(vanilla), len(rust))
errors, skipped, mismatched, checked = {}, set(), {}, 0
for v, r in zip(vanilla, rust):
layer = v['layer']
if 'error' in v:
errors[layer] = v['error']; continue
if 'skip' in r:
skipped.add(layer); continue
checked += 1
bad = []
for path, expected in v['parts'].items():
got = r['parts'].get(path)
if got is None:
bad.append((path, 'missing in Horizon')); continue
names = ['x', 'y', 'z', 'xRot', 'yRot', 'zRot', 'xScale', 'yScale', 'zScale', 'visible', 'polys', 'vsum']
for i, (a, b) in enumerate(zip(expected, got)):
if i == 11 and abs(a - b) <= 1e-3 * max(1, abs(a)):
continue
if i == 9:
a, b = bool(a), bool(b)
if a != b:
bad.append((path, f'visible {a} != {b}'))
continue
if abs(a - b) > args.tolerance * (1.0 + abs(a)):
bad.append((path, f'{names[i]} {a!r} != {b!r}'))
for path in r['parts']:
if path not in v['parts']:
bad.append((path, 'extra in Horizon'))
if bad:
mismatched.setdefault(layer, []).append((v['case'], bad))
print(f'{checked} cases compared over {len(chosen) - len(skipped) - len(errors)} layers')
if errors:
print('oracle errors:'); [print(' ', k, v) for k, v in sorted(errors.items())]
if skipped:
print('layers without a Horizon species:', ', '.join(sorted(skipped)))
for layer, items in sorted(mismatched.items()):
parts = {}
for case, bad in items:
for path, why in bad:
parts.setdefault(path, (why, case))
total = sum(1 for x in vanilla if x['layer'] == layer)
print(f'MISMATCH {layer}: {len(items)}/{total} cases, {len(parts)} parts')
detailed = args.detail == layer
for path, (why, case) in list(parts.items())[:(10 ** 6 if detailed else 4)]:
print(f' {path or "<root>"}: {why}' + (f' case={json.dumps(case)}' if detailed else ''))
if mismatched or errors:
sys.exit(f'{len(mismatched)} layers differ from vanilla')
print('entity model parity ok')
if __name__ == '__main__':
main()