Files
horizon-airdef/tools/test_block_entity_anim_parity.py
T
Ludovic Marie 0b581f9ffe Give each crate's probe example its own target name
horizon-client, horizon-world and horizon-protocol each had an example
named `probe`. Cargo copies every crate's examples into one
<profile>/examples/ directory, so parallel parity harnesses sharing a
target dir could run another crate's probe (CI parity run 40055656303:
command_parse ran the horizon-world dispatcher). The examples are now
client_probe, world_probe and protocol_probe ([[example]] in each
Cargo.toml; the files stay where they are), and every harness builds
and runs its own crate's name.

tools/parity.py recognises the new names when it counts the probes a
harness ran, and renames the old `probe` in recorded artifacts to the
crate's target (from the record's -p, else its probe sources), so the
prebuild still builds them instead of dropping them as unknown.

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

332 lines
16 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, End gateway with the integrated server's copy ticking beside it)
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/probe/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)
# End gateways: the server copy's portalTick (triggerCooldown every 2400
# ticks, entities entering) sends block event 1 to the client copy, whose
# beamAnimationTick and TheEndGatewayRenderer draw the beam; saved ages
# from new to long settled, both level heights.
for i in range(40):
age = rng.choice([0, 0, 150, 2390, 4790, rng.randint(200, 10 ** 6), 2399 + 2400 * rng.randint(1, 50)])
steps = []
for t in times(rng.randint(20, 90), gaps=(1, 1, 1, 2)):
step = {'time': t, 'partial': round(rng.random(), 4)}
if rng.random() < 0.06:
step['enter'] = True
if rng.random() < 0.04:
step['event'] = [rng.choice([1, 2, 0]), rng.choice([0, 3])]
steps.append(step)
add('gateway', 'minecraft:end_gateway', steps, age=age, level=rng.choice([[0, 256], [-64, 384]]))
# 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.RESOURCES
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', 'client_probe', '--',
'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()