Files
horizon-airdef/tools/test_block_entity_render_parity.py
T
Ludovic Marie fda4d65a02 Give the block-entity render oracle an item resolver and an End gateway level
Campfires and brushable blocks resolve their items (empty here) through the
context's ItemModelResolver, which was null. End gateways read the level for
their beam (max y, game time) and visible faces, so they get a stub level
with the case's neighbours, light, build height and game time. New cases
cover gateways spawning, cooling down and settled against neighbours, and
End portals beside the same neighbours.

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

329 lines
17 KiB
Python

#!/usr/bin/env python3
"""Compare block-entity rendering with the vanilla 26.3 client renderers.
The Java oracle (VanillaBlockEntityRenderOracle) builds each case's vanilla
block entity, sets its animation fields, runs the real BlockEntityRenderer's
extractRenderState and submit, and captures what it submits: model vertices
after setupAnim and the pose stack (sprite-local UVs, tint, normal, light),
text calls and items. `examples/block_entity_render_fixture` replays the case
through Horizon's BlockEntityRenderer. Quads are matched by texture and
position; colours compare vanilla's tint times the entity shader's
`minecraft_mix_light` for the vanilla normal with Horizon's baked colour.
test_block_entity_render_parity.py [--filter SUBSTRING] [--detail N] [--tolerance EPS]
"""
import argparse
import json
import math
import os
from pathlib import Path
import random
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
PARITY = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity'
DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple',
'blue', 'brown', 'green', 'red', 'black']
HORIZONTAL = ['north', 'south', 'west', 'east']
ALL = HORIZONTAL + ['up', 'down']
NO_OVERLAY = 10 << 16
def cases():
rng = random.Random(0x42454e44)
out = []
def add(block, **extra):
case = {'block': block, 'pos': [rng.randint(-40, 40), rng.randint(-20, 90), rng.randint(-40, 40)],
'light': [rng.randint(0, 15), rng.randint(0, 15)], 'partial': round(rng.random(), 4),
'camera': [0.5, 70.0, 0.5]}
case.update(extra)
out.append(case)
for block in ['chest', 'trapped_chest', 'copper_chest', 'exposed_copper_chest', 'weathered_copper_chest',
'oxidized_copper_chest', 'waxed_copper_chest', 'waxed_oxidized_copper_chest']:
for facing in HORIZONTAL:
for kind in ['single', 'left', 'right']:
add(f'minecraft:{block}[facing={facing},type={kind}]', open=rng.choice([0.0, 0.25, 0.7, 1.0]))
for facing in HORIZONTAL:
add(f'minecraft:ender_chest[facing={facing}]', open=rng.choice([0.0, 0.5, 1.0]))
texts = [['Hello', '', 'World', ''], ['A much longer line that cannot fit', 'mid', 'iii', 'WWWWWWWWWWWWWWW'],
['', '', '', ''], ['x', 'Sign text', '#1 (test)', '!']]
for wood in ['oak', 'spruce', 'cherry', 'crimson', 'bamboo', 'pale_oak']:
signs = [f'minecraft:{wood}_sign[rotation={r}]' for r in (0, 3, 8, 13)] + \
[f'minecraft:{wood}_wall_sign[facing={f}]' for f in HORIZONTAL] + \
[f'minecraft:{wood}_hanging_sign[rotation={r},attached={a}]' for r, a in ((0, 'false'), (6, 'true'), (11, 'false'))] + \
[f'minecraft:{wood}_wall_hanging_sign[facing={f}]' for f in HORIZONTAL]
for block in signs:
side = lambda: {'lines': rng.choice(texts), 'color': rng.choice(DYES), 'glowing': rng.random() < 0.4}
add(block, sign={'front': side(), 'back': side(), 'near': rng.random() < 0.5})
patterns = ['minecraft:stripe_bottom', 'minecraft:creeper', 'minecraft:gradient', 'minecraft:flower',
'minecraft:border', 'minecraft:skull', 'minecraft:globe', 'minecraft:piglin']
for color in ['white', 'red', 'light_gray', 'black']:
for r in (0, 5, 12):
add(f'minecraft:{color}_banner[rotation={r}]',
patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(rng.randint(0, 3))])
for f in HORIZONTAL:
add(f'minecraft:{color}_wall_banner[facing={f}]',
patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(rng.randint(0, 2))])
add('minecraft:blue_banner[rotation=2]', patterns=[[rng.choice(patterns), rng.choice(DYES)] for _ in range(17)])
for skull in ['skeleton_skull', 'wither_skeleton_skull', 'zombie_head', 'creeper_head', 'dragon_head',
'piglin_head', 'player_head']:
wall = skull.replace('_skull', '_wall_skull').replace('_head', '_wall_head')
for r in (0, 4, 9):
add(f'minecraft:{skull}[rotation={r}]', skullAnimation=[rng.random() < 0.5, rng.randint(0, 80)])
for f in HORIZONTAL:
add(f'minecraft:{wall}[facing={f}]', skullAnimation=[rng.random() < 0.5, rng.randint(0, 80)])
for color in [None, 'white', 'light_gray', 'purple', 'black']:
name = 'shulker_box' if color is None else f'{color}_shulker_box'
for f in ALL:
p = rng.choice([0.0, 0.0, 0.3, 1.0])
add(f'minecraft:{name}[facing={f}]', progress=p, progressOld=max(0.0, p - 0.1))
for attach in ['floor', 'ceiling', 'single_wall', 'double_wall']:
for f in HORIZONTAL:
shake = rng.choice([None] + HORIZONTAL)
extra = {'bellTicks': rng.randint(0, 50)}
if shake:
extra['shake'] = shake
add(f'minecraft:bell[attachment={attach},facing={f}]', **extra)
sherds = ['', 'minecraft:brick', 'minecraft:angler_pottery_sherd', 'minecraft:skull_pottery_sherd',
'minecraft:heart_pottery_sherd', 'minecraft:flow_pottery_sherd']
for f in HORIZONTAL:
for wobble in [None, ['POSITIVE', 0.3], ['NEGATIVE', 0.6], ['POSITIVE', 1.2]]:
extra = {'sherds': [rng.choice(sherds) for _ in range(4)]}
if wobble:
extra['wobble'] = wobble
add(f'minecraft:decorated_pot[facing={f}]', **extra)
for active in (False, True):
for _ in range(4):
add('minecraft:conduit', conduit={'tickCount': rng.randint(0, 400), 'active': active,
'hunting': rng.random() < 0.5, 'activeRotation': rng.uniform(0, 300)},
yaw=round(rng.uniform(-180, 180), 2), pitch=round(rng.uniform(-90, 90), 2))
for _ in range(6):
o = rng.random()
add('minecraft:enchanting_table', book={'time': rng.randint(0, 2000), 'flip': rng.uniform(-3, 3),
'oFlip': rng.uniform(-3, 3), 'open': o, 'oOpen': max(0.0, o - 0.1),
'rot': rng.uniform(-3.1, 3.1), 'oRot': rng.uniform(-3.1, 3.1)})
for f in HORIZONTAL:
add(f'minecraft:lectern[facing={f},has_book=true]')
add(f'minecraft:lectern[facing={f},has_book=false]')
for prefix in ['', 'exposed_', 'weathered_', 'oxidized_', 'waxed_']:
for pose in ['standing', 'running', 'sitting', 'star']:
add(f'minecraft:{prefix}copper_golem_statue[facing={rng.choice(HORIZONTAL)},copper_golem_pose={pose}]')
for _ in range(8):
add('minecraft:beacon', gameTime=rng.randint(0, 10 ** 6),
camera=[rng.uniform(-300, 300), 70.0, rng.uniform(-300, 300)],
beam=[[0xFF000000 | rng.randint(0, 0xFFFFFF), rng.randint(1, 30)] for _ in range(rng.randint(1, 3))])
for block in ['end_portal', 'end_gateway', 'campfire[facing=north]', 'spawner',
'trial_spawner', 'vault', 'suspicious_sand', 'oak_shelf[facing=east]']:
add('minecraft:' + block)
# End gateways: the beam while spawning (age < 200) or cooling down, in End and
# Overworld build heights, and the faces Block.shouldRenderFace leaves against neighbours.
neighbours = [{}, {'up': 'minecraft:bedrock', 'down': 'minecraft:bedrock'},
{'north': 'minecraft:end_gateway', 'east': 'minecraft:glass', 'west': 'minecraft:oak_slab[type=top]',
'south': 'minecraft:end_stone', 'down': 'minecraft:oak_stairs[facing=east,half=top]'}]
for age, cooldown in [(0, 0), (37, 0), (150, 0), (199, 0), (120, 25), (5000, 1), (5000, 17), (5000, 40), (5000, 0)]:
add('minecraft:end_gateway', gateway={'age': age, 'cooldown': cooldown}, gameTime=rng.randint(0, 10 ** 6),
level=rng.choice([[0, 256], [-64, 384]]), neighbours=rng.choice(neighbours))
for n in neighbours:
add('minecraft:end_portal', neighbours=n)
return out
def light_coords(light):
"""Horizon light (sky*16 low byte, block*16 next) as vanilla light coords."""
return ((light & 0xFF) << 16) | ((light >> 8) & 0xFF)
def lights():
def norm(v):
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v]
return norm([0.2, 1.0, -0.7]), norm([-0.2, 1.0, 0.7])
L0, L1 = lights()
def mix(normal):
d = lambda l: max(0.0, sum(a * b for a, b in zip(l, normal)))
return min(1.0, (d(L0) + d(L1)) * 0.6 + 0.4)
def vanilla_quads(submits):
quads, other = [], []
for s in submits:
custom = s['type'] == 'submitCustomGeometry'
if s['type'] != 'submitModel' and not custom:
other.append(s)
continue
verts = s['vertices']
for i in range(0, len(verts), 4):
q = verts[i:i + 4]
tint = q[0][3] & 0xFFFFFFFF
# Custom geometry (beacon beams) is unlit; models get the entity shader's mix light.
b = 1.0 if custom else mix(q[0][8:11])
ch = lambda shift: int(((tint >> shift) & 255) * b)
quads.append({'texture': s['texture'], 'v': [v[:3] + v[4:6] for v in q],
'rgb': [ch(16), ch(8), ch(0)], 'alpha': tint >> 24, 'light': q[0][7],
'overlay': q[0][6], 'type': s['renderType'], 'custom': custom})
return quads, other
def area(q):
import itertools
p = [v[:3] for v in q]
a = [p[1][i] - p[0][i] for i in range(3)]
b = [p[2][i] - p[0][i] for i in range(3)]
c = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
a2 = [p[3][i] - p[0][i] for i in range(3)]
d = [b[1] * a2[2] - b[2] * a2[1], b[2] * a2[0] - b[0] * a2[2], b[0] * a2[1] - b[1] * a2[0]]
return math.sqrt(sum(x * x for x in c)) + math.sqrt(sum(x * x for x in d))
def pair(vq, rq):
"""Matches quads of the same texture globally by ascending vertex distance."""
candidates = []
for i, a in enumerate(vq):
for j, b in enumerate(rq):
if a['texture'] == b['texture']:
d = sum((a['v'][k][c] - b['v'][k][c]) ** 2 for k in range(4) for c in range(5))
candidates.append((d, i, j))
candidates.sort()
match, used = {}, set()
for d, i, j in candidates:
if i not in match and j not in used:
match[i] = j
used.add(j)
return [(a, rq[match[i]] if i in match else None) for i, a in enumerate(vq)], \
[b for j, b in enumerate(rq) if j not in used]
def compare_case(v, r, tol):
problems = []
vq, other = vanilla_quads(v['submits'])
for s in other:
if s['type'] == 'submitText':
continue
problems.append(f'unsupported vanilla submit {s["type"]}')
rq = r.get('quads', [])
if len(vq) != len(rq):
tex = lambda qs: sorted({q['texture'] for q in qs})
problems.append(f'{len(vq)} vanilla quads {tex(vq)} vs {len(rq)} Horizon quads {tex(rq)}')
pairs, extra = pair(vq, rq)
for b in extra[:1]:
problems.append(f'extra Horizon quad {b["texture"]} {b["v"]}')
for a, b in pairs:
if b is None:
problems.append(f'missing {a["texture"]} quad {a["v"]}')
break
bad = [(i, j) for i in range(4) for j in range(5) if abs(a['v'][i][j] - b['v'][i][j]) > tol * (1 + abs(a['v'][i][j]))]
if bad:
problems.append(f'{a["texture"]} vertices {a["v"]} != {b["v"]}')
break
if a['custom']:
# Horizon packs the beam sprite's v range into the light bytes (its shader wraps v).
got = b['color']
if [got & 255, (got >> 8) & 255, (got >> 16) & 255, got >> 24] != a['rgb'] + [a['alpha']]:
problems.append(f'{a["texture"]} colour {a["rgb"]}/{a["alpha"]} != {got:#x}')
break
continue
if a['overlay'] != NO_OVERLAY:
problems.append(f'overlay {a["overlay"]}')
got = b['color']
rgb = [got & 255, (got >> 8) & 255, (got >> 16) & 255]
# A zero-area quad shows nothing: vanilla shades it by its face normal, Horizon cannot derive one.
if area(a['v']) > 1e-9 and (any(abs(x - y) > 1 for x, y in zip(a['rgb'], rgb)) or (got >> 24) != a['alpha']):
problems.append(f'{a["texture"]} colour {a["rgb"]}/{a["alpha"]} != {rgb}/{got >> 24} at {a["v"]}')
break
if a['light'] != light_coords(b['light']):
problems.append(f'{a["texture"]} light {a["light"]:#x} != {light_coords(b["light"]):#x}')
break
vt = [s for s in v['submits'] if s['type'] == 'submitText' and s['text']]
rt = r.get('texts', [])
if len(vt) != len(rt):
problems.append(f'{len(vt)} vanilla texts {[t["text"] for t in vt]} vs {len(rt)} Horizon texts {[t["text"] for t in rt]}')
for a, b in zip(vt, rt):
for k in ('text', 'x', 'y', 'color', 'outline', 'light'):
av, bv = a[k], b[k]
if k in ('color', 'outline'):
av &= 0xFFFFFFFF
if (abs(av - bv) > 1e-4) if isinstance(av, float) else av != bv:
problems.append(f'text {a["text"]!r} {k} {av!r} != {bv!r}')
if any(abs(x - y) > tol * (1 + abs(x)) for x, y in zip(a['matrix'], b['matrix'])):
problems.append(f'text {a["text"]!r} matrix {a["matrix"]} != {b["matrix"]}')
return problems
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--detail', type=int, default=2)
parser.add_argument('--tolerance', type=float, default=2e-5)
args = parser.parse_args()
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
build = lab.LOCAL / 'rust-block-entity-render-parity'; build.mkdir(parents=True, exist_ok=True)
resources = lab.LOCAL / 'horizon-client' / '26.3'
chosen = [c for c in cases() if args.filter in c['block']]
inputs = build / 'inputs.jsonl'; inputs.write_text(''.join(json.dumps(c) + '\n' for c in chosen))
classes = build / 'classes'; classes.mkdir(exist_ok=True)
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', '-nowarn', '-cp', dependencies, '-d', classes,
*(PARITY / f'{n}.java' for n in ('ParityData', 'ParityFont', 'VanillaBlockEntityRenderOracle'))], 'compile-java')
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaBlockEntityRenderOracle',
inputs, build / 'vanilla.jsonl'], 'vanilla')
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
textures = sorted({s['texture'] for v in vanilla for s in v.get('submits', []) if 'texture' in s})
(build / 'textures.txt').write_text(''.join(t + '\n' for t in textures))
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'block_entity_render_fixture', '--',
resources, inputs, build / 'textures.txt', build / 'rust.jsonl'], 'rust')
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
assert len(vanilla) == len(rust), (len(vanilla), len(rust))
groups, errors, skipped, passed = {}, {}, {}, 0
for v, r in zip(vanilla, rust):
block = v['case']['block'].split('[')[0]
if 'error' in v:
errors.setdefault(block, v['error']); continue
if 'skip' in r:
if not v['submits']:
passed += 1 # vanilla submits nothing either (e.g. a lectern without a book)
else:
skipped.setdefault(r['skip'], set()).add(block)
continue
problems = compare_case(v, r, args.tolerance)
if problems:
groups.setdefault(block, []).append((v['case'], problems))
else:
passed += 1
for reason, blocks in sorted(skipped.items()):
print(f'NOT RENDERED by Horizon ({reason}): {", ".join(sorted(blocks))}')
for block, error in sorted(errors.items()):
print(f'ORACLE ERROR {block}: {error}')
for block, items in sorted(groups.items()):
print(f'MISMATCH {block}: {len(items)} cases')
for case, problems in items[:args.detail]:
print(' ', json.dumps(case))
for p in problems[:4]:
print(' ', p[:600])
total = len(vanilla)
print(f'{passed}/{total} cases match; {sum(len(b) for b in skipped.values())} not rendered; {len(errors)} oracle errors')
if groups or errors or skipped:
sys.exit(1)
print('block entity render parity ok')
if __name__ == '__main__':
main()