874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
332 lines
16 KiB
Python
Executable File
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()
|