Files
horizon-airdef/tools/test_block_entity_render_parity.py
T
Ludovic Marie d088140c0d Compare beacon beams with BeaconRenderer
Move the beam geometry into block_entity_render::beacon_beam so the
block entity render harness can replay it against vanilla submit calls
(positions, UVs and colours of the solid beam and glow per section).

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

319 lines
16 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)
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()