Files
horizon-airdef/tools/test_block_entity_anim_parity.py
T
Ludovic Marie 6bc85d48c6 Tick chest lids and enchanting books as vanilla and drive the renderer with it
block_entity_anim now also ports ChestLidController (chests, trapped and
copper chests, ender chests: triggerEvent(1, openCount), tickLid,
getOpenness), ChestBlock.opennessCombiner and ChestRenderer's lid curve,
and EnchantingTableBlockEntity.bookAnimationTick with its render state
(nearest non-spectator player by feet position within 3 blocks, Mth.atan2,
the class's shared RANDOM). The renderer pairs a double chest half with its
neighbour only as DoubleBlockCombiner does (same block, other half, same
facing) and reads the book from BlockAnim instead of ticking its own copy.

The game keeps a BlockEntityAnims and runs the client tickers of every
loaded animated block entity each tick: chest lids, books, shulker boxes,
bells (resonance sound, entity-effect particles, raider glow), conduits
(nautilus particles) and dragon/piglin heads. Opening and closing chests,
ender chests and shulker boxes sends the opener-count block event (both
halves of a double chest); a ring's block event reaches the bell through a
new LevelEvent::BlockEntityEvent. The renderer now gets this state instead
of BlockAnim::default(), so shulker lids, bells, conduits and heads move.

block_entity_anim harness: 256/256 cases (new: 48 chest, 36 book).

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

317 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compare client-ticked block-entity animation with the vanilla 26.3 block entities.
The Java oracle (VanillaBlockEntityAnimOracle) builds each case's real vanilla
block entity (chest, trapped/copper chest and the other half of a double chest,
ender chest, enchanting table, shulker box, bell, conduit, dragon/piglin head,
decorated pot)
on a stand-in client level, then per scripted step applies the block event
(triggerEvent), runs the entity's own client ticker and the renderer's
extractRenderState, printing the fields, the level calls the tick made
(neighbour updates, entity pushes/queries, sounds, particles) and the render
state. `examples/block_entity_anim_fixture` replays the steps through
`horizon_client::block_entity_anim` and derives the render state from the
`BlockAnim` Horizon's renderer reads. Lines must match exactly (floats as bits).
test_block_entity_anim_parity.py [--filter SUBSTRING] [--detail N]
"""
import argparse
import json
import os
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'
HORIZONTAL = ['north', 'south', 'west', 'east']
ALL = ['down', 'up'] + HORIZONTAL
FRAME = ['minecraft:prismarine', 'minecraft:prismarine_bricks', 'minecraft:sea_lantern', 'minecraft:dark_prismarine']
WATERY = ['minecraft:water', 'minecraft:water[level=3]', 'minecraft:bubble_column', 'minecraft:seagrass',
'minecraft:kelp_plant', 'minecraft:oak_slab[waterlogged=true]', 'minecraft:oak_stairs[waterlogged=true]']
CHESTS = ['chest', 'trapped_chest', 'copper_chest', 'exposed_copper_chest', 'waxed_oxidized_copper_chest']
CLOCKWISE = {'north': 'east', 'east': 'south', 'south': 'west', 'west': 'north'}
STEP = {'north': (0, 0, -1), 'south': (0, 0, 1), 'west': (-1, 0, 0), 'east': (1, 0, 0)}
DRY = ['minecraft:air', 'minecraft:stone', 'minecraft:lava', 'minecraft:oak_slab', 'minecraft:ice']
def frame_offsets():
out = []
for ox in range(-2, 3):
for oy in range(-2, 3):
for oz in range(-2, 3):
ax, ay, az = abs(ox), abs(oy), abs(oz)
if (ax > 1 or ay > 1 or az > 1) and (ox == 0 and (ay == 2 or az == 2) or oy == 0 and (ax == 2 or az == 2)
or oz == 0 and (ax == 2 or ay == 2)):
out.append((ox, oy, oz))
return out
def cases():
rng = random.Random(0x414e494d)
out = []
def pos():
return [rng.randint(-3000, 3000), rng.randint(-60, 300), rng.randint(-3000, 3000)]
def add(kind, block, steps, **extra):
case = {'name': f'{kind}-{len(out)}', 'block': block, 'pos': pos(), 'steps': steps}
case.update(extra)
out.append(case)
def times(n, start=None, gaps=(1,)):
t = rng.randint(-100, 10 ** 7) if start is None else start
result = []
for _ in range(n):
result.append(t)
t += rng.choice(gaps)
return result
# Shulker boxes: players open and close (open counts from startOpen/stopOpen), other events ignored.
for i in range(48):
color = rng.choice(['', 'white_', 'purple_', 'black_'])
block = f'minecraft:{color}shulker_box[facing={rng.choice(ALL)}]'
steps, count = [], 0
for t in times(rng.randint(20, 70)):
step = {'time': t, 'partial': round(rng.random(), 4)}
r = rng.random()
if r < 0.12:
count = max(count, 0) + 1
step['event'] = [1, count]
elif r < 0.22:
count -= 1
step['event'] = [1, count]
elif r < 0.25:
step['event'] = [rng.choice([0, 2, 1]), rng.randint(-2, 3)]
steps.append(step)
add('shulker', block, steps)
# Bells: rings from any side (and odd data values), raiders and other mobs nearby.
for i in range(40):
attach = rng.choice(['floor', 'ceiling', 'single_wall', 'double_wall'])
block = f'minecraft:bell[attachment={attach},facing={rng.choice(HORIZONTAL)}]'
p = pos()
entities = []
for _ in range(rng.choice([0, 1, 2, 5, 12, 25])):
# Multiples of 1/1024: serde_json parses other decimals approximately (1 ulp off).
spread = rng.choice([10, 33, 50, 70])
entities.append({'pos': [p[k] + 0.5 + round(rng.uniform(-spread, spread) * 1024) / 1024 for k in range(3)],
'raider': rng.random() < 0.5, 'alive': rng.random() < 0.85, 'removed': rng.random() < 0.08})
steps = []
start = rng.randint(-50, 10 ** 6)
for k, t in enumerate(times(rng.randint(60, 180), start)):
step = {'time': t, 'partial': round(rng.random(), 4)}
if (k == 2 or rng.random() < 0.05) and k < 150:
step['event'] = [1 if rng.random() < 0.93 else 2, rng.choice([0, 1, 2, 3, 4, 5, 2, 3, 4, 5, 7, -3, 11])]
steps.append(step)
case = {'name': f'bell-{len(out)}', 'block': block, 'pos': p, 'steps': steps, 'entities': entities}
out.append(case)
# Conduits: water around, prismarine frames of various sizes, frames changing over time.
rings = frame_offsets()
for i in range(36):
world = {}
water = rng.random()
for ox in range(-1, 2):
for oy in range(-1, 2):
for oz in range(-1, 2):
if (ox, oy, oz) != (0, 0, 0):
world[f'{ox},{oy},{oz}'] = rng.choice(WATERY) if rng.random() < 0.97 or water < 0.5 else rng.choice(DRY)
size = rng.choice([0, 10, 15, 16, 20, 30, 41, 42, 42])
for o in rng.sample(rings, size):
world['%d,%d,%d' % o] = rng.choice(FRAME)
steps = []
for t in times(rng.randint(60, 140), gaps=(1, 1, 1, 2, 7)):
step = {'time': t, 'partial': round(rng.random(), 4)}
if rng.random() < 0.04:
o = rng.choice(rings)
step['set'] = {'%d,%d,%d' % o: rng.choice(FRAME + DRY)}
steps.append(step)
waterlogged = 'true' if rng.random() < 0.9 else 'false'
extra = {'world': world, 'seed': rng.randint(0, 2 ** 40)}
if rng.random() < 0.3:
extra['target'] = {'pos': [round(rng.uniform(-8, 8) * 1024) / 1024 for _ in range(3)], 'width': round(rng.uniform(0.3, 2), 3),
'height': round(rng.uniform(0.5, 3), 3), 'eye': round(rng.uniform(0.2, 2.5), 3)}
add('conduit', f'minecraft:conduit[waterlogged={waterlogged}]', steps, **extra)
# Dragon and piglin heads powered on and off.
for i in range(24):
head = rng.choice(['dragon', 'piglin'])
wall = rng.random() < 0.5
prop = f'facing={rng.choice(HORIZONTAL)}' if wall else f'rotation={rng.randint(0, 15)}'
name = f'minecraft:{head}_wall_head' if wall else f'minecraft:{head}_head'
powered = rng.random() < 0.5
steps = []
for t in times(rng.randint(20, 90)):
if rng.random() < 0.1:
powered = not powered
steps.append({'time': t, 'partial': round(rng.random(), 4),
'state': f'{name}[{prop},powered={str(powered).lower()}]'})
add('skull', f'{name}[{prop},powered=false]', steps)
# Decorated pots wobbling (insert: POSITIVE, fail: NEGATIVE) and invalid events.
for i in range(24):
steps = []
for t in times(rng.randint(20, 60), gaps=(0, 1, 1, 1, 3)):
step = {'time': t, 'partial': round(rng.random(), 4)}
if rng.random() < 0.15:
step['event'] = [rng.choice([1, 1, 1, 2]), rng.choice([0, 1, 0, 1, 2, -1])]
steps.append(step)
add('pot', f'minecraft:decorated_pot[facing={rng.choice(HORIZONTAL)}]', steps)
# Chests and ender chests: open counts from block event 1 (both halves of
# a double chest), other events ignored; the neighbour a double half
# connects to may be its other half or not (wrong block, type, facing).
for i in range(48):
facing = rng.choice(HORIZONTAL)
partner = None
world = {}
if rng.random() < 0.2:
block = f'minecraft:ender_chest[facing={facing}]'
else:
name = rng.choice(CHESTS)
kind = rng.choice(['single', 'left', 'right', 'left', 'right'])
block = f'minecraft:{name}[facing={facing},type={kind}]'
if kind != 'single':
connected = CLOCKWISE[facing] if kind == 'left' else [d for d, c in CLOCKWISE.items() if c == facing][0]
other_kind = 'right' if kind == 'left' else 'left'
other_name, other_facing, r = name, facing, rng.random()
if r < 0.1:
other_kind = kind
elif r < 0.2:
other_facing = rng.choice([d for d in HORIZONTAL if d != facing])
elif r < 0.3:
other_name = rng.choice([c for c in CHESTS if c != name])
elif r < 0.35:
other_kind = 'single'
if r < 0.95:
partner = '%d,%d,%d' % STEP[connected]
world[partner] = f'minecraft:{other_name}[facing={other_facing},type={other_kind}]'
steps, count, other = [], 0, 0
for t in times(rng.randint(20, 70)):
step = {'time': t, 'partial': round(rng.random(), 4)}
r = rng.random()
if r < 0.12:
count = max(count, 0) + 1
step['event'] = [1, count]
elif r < 0.22:
count -= 1
step['event'] = [1, count]
elif r < 0.25:
step['event'] = [rng.choice([0, 2, 1]), rng.randint(-2, 3)]
if partner and rng.random() < 0.15:
other = other + 1 if rng.random() < 0.55 else other - 1
step['partnerEvent'] = [1, other]
steps.append(step)
extra = {'world': world}
if partner:
extra['partner'] = partner
add('chest', block, steps, **extra)
# Enchanting tables: players (and spectators) walking in and out of the
# 3-block reach; the class's shared RANDOM seeded per case.
for i in range(36):
p = pos()
centre = [p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]
def players():
out = []
for _ in range(rng.choice([0, 1, 1, 1, 2, 3])):
spread = rng.choice([1.5, 3, 5])
out.append({'pos': [centre[k] + round(rng.uniform(-spread, spread) * 1024) / 1024 for k in range(3)],
'spectator': rng.random() < 0.15})
return out
steps = []
for t in times(rng.randint(40, 160)):
step = {'time': t, 'partial': round(rng.random(), 4)}
if rng.random() < 0.08:
step['players'] = players()
steps.append(step)
case = {'name': f'book-{len(out)}', 'block': 'minecraft:enchanting_table', 'pos': p, 'steps': steps,
'players': players(), 'seed': rng.randint(0, 2 ** 40)}
out.append(case)
return out
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--filter', default='')
parser.add_argument('--detail', type=int, default=3)
args = parser.parse_args()
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
build = lab.LOCAL / 'rust-block-entity-anim-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['name']]
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', 'VanillaBlockEntityAnimOracle'))], 'compile-java')
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaBlockEntityAnimOracle',
inputs, build / 'vanilla.txt'], 'vanilla')
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'block_entity_anim_fixture', '--',
resources, inputs, build / 'rust.txt'], 'rust')
def split(path):
result, current = {}, None
for line in path.read_text().splitlines():
if line.startswith('case '):
current = line[5:]
result[current] = []
else:
result[current].append(line)
return result
vanilla, rust = split(build / 'vanilla.txt'), split(build / 'rust.txt')
kinds, failures, errors, steps = {}, {}, {}, 0
for case in chosen:
name = case['name']
kind = name.split('-')[0]
v, r = vanilla.get(name, []), rust.get(name, [])
total = kinds.setdefault(kind, [0, 0])
total[1] += 1
if v and v[0].startswith('error'):
errors.setdefault(kind, v[0])
continue
steps += len(v)
bad = next((i for i, (a, b) in enumerate(zip(v, r)) if a != b), None)
if bad is None and len(v) != len(r):
bad = min(len(v), len(r))
if bad is None:
total[0] += 1
else:
failures.setdefault(kind, []).append((case, bad, v[bad] if bad < len(v) else None, r[bad] if bad < len(r) else None))
for kind, error in sorted(errors.items()):
print(f'ORACLE ERROR {kind}: {error[:600]}')
for kind, items in sorted(failures.items()):
print(f'MISMATCH {kind}: {len(items)} cases')
for case, step, a, b in items[:args.detail]:
print(f' {case["name"]} {case["block"]} step {step}: {json.dumps(case["steps"][step] if step < len(case["steps"]) else None)}')
print(f' vanilla: {(a or "")[:500]}')
print(f' horizon: {(b or "")[:500]}')
for kind, (ok, n) in sorted(kinds.items()):
print(f'{kind}: {ok}/{n} cases match')
passed = sum(ok for ok, _ in kinds.values())
print(f'{passed}/{len(chosen)} cases match ({steps} ticked steps)')
if failures or errors:
sys.exit(1)
print('block entity animation parity ok')
if __name__ == '__main__':
main()