874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
204 lines
11 KiB
Python
204 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Entities other than mobs and items, tracked to a vanilla client, against vanilla's dedicated server.
|
|
|
|
A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat
|
|
world vanilla generated first, with the same server.properties (offline, view distance 4). A vanilla-codec
|
|
client (VanillaProtocolClient in its entity mode) joins, and once its chunks are in it asks for the scene: the
|
|
console freezes the ticks, builds a wall and a rail and summons, around the spawn, every kind of minecart (one
|
|
set rolling along the rail), boats and a raft (one falling onto the ground), armor stands (posed, small, with
|
|
arms, wearing a helmet, one falling), a chicken jockey, a slime, a pig whose scale `/attribute` sets, item
|
|
frames (one holding a turned diamond), paintings, primed TNT (one falling), a floating falling block, sand
|
|
falling into a cobweb, an arrow that lands and an end crystal. The client records for 20 seconds, the ticks
|
|
running for 200 more after 3, what ClientPacketListener would make of the packets (EntityTranscript): each
|
|
entity's spawn
|
|
packet (position, rotations, data, motion), the entity data it was paired with and all it was sent since, its
|
|
equipment, its attribute instances (ClientboundUpdateAttributesPacket: the ones its first tick creates, the
|
|
type's set by finalizeSpawn, the baby speed modifier, effects', equipment's and /attribute's) and passengers, and
|
|
where its position syncs leave it; and the attribute instances the player itself is sent from its login on. Both
|
|
transcripts must match exactly, entity by entity (named by type and the block it was added in), once the amounts
|
|
of vanilla's random spawn modifiers are normalised (only client-syncable attributes are sent, so follow range and
|
|
knockback resistance never show).
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
try:
|
|
from . import lab, test_rust_parity as base
|
|
from .test_dedicated_server_parity import Server
|
|
from .test_locator_bar_parity import generate_world, server_dir
|
|
except ImportError:
|
|
import lab
|
|
import test_rust_parity as base
|
|
from test_dedicated_server_parity import Server
|
|
from test_locator_bar_parity import generate_world, server_dir
|
|
|
|
NAME = 'server-entities-parity'
|
|
# Ticks the scene runs for once it is summoned: 10 seconds, within the client's 20.
|
|
STEPS = 200
|
|
PLAYER = 'Tester'
|
|
|
|
# Around the spawn at 0 -60 0 on the flat world's grass: a wall to the north for the frames and paintings,
|
|
# a rail to the south.
|
|
SCENE = [
|
|
'fill -7 -60 -6 7 -57 -6 minecraft:stone',
|
|
'fill -7 -60 9 7 -60 9 minecraft:rail',
|
|
# Minecarts.
|
|
'summon minecraft:minecart -5.5 -60 3.5',
|
|
'summon minecraft:chest_minecart -3.5 -60 3.5',
|
|
'summon minecraft:furnace_minecart -1.5 -60 3.5',
|
|
'summon minecraft:hopper_minecart 1.5 -60 3.5',
|
|
'summon minecraft:tnt_minecart 3.5 -60 3.5',
|
|
'summon minecraft:command_block_minecart 5.5 -60 1.5 {Command:"say hi"}',
|
|
'summon minecraft:minecart -6.5 -60 9.5 {Motion:[0.5d,0d,0d]}',
|
|
# Boats.
|
|
'summon minecraft:oak_boat -5.5 -60 6.5 {Rotation:[45f,0f]}',
|
|
'summon minecraft:birch_chest_boat -2.5 -60 6.5',
|
|
'summon minecraft:bamboo_raft 0.5 -60 6.5',
|
|
'summon minecraft:oak_boat 4.5 -56 6.5',
|
|
# Armor stands.
|
|
'summon minecraft:armor_stand -2.5 -60 -2.5 {Rotation:[90f,0f]}',
|
|
'summon minecraft:armor_stand 6.5 -57 -2.5',
|
|
'summon minecraft:armor_stand -4.5 -60 -2.5 {ShowArms:1b,Small:1b,NoBasePlate:1b,'
|
|
'Pose:{Head:[10f,20f,0f],RightArm:[-30f,0f,15f]},equipment:{head:{id:"minecraft:diamond_helmet",count:1}}}',
|
|
# A chicken jockey (passengers).
|
|
'summon minecraft:chicken -6.5 -60 -2.5 {NoAI:1b,Passengers:[{id:"minecraft:husk",IsBaby:1b,NoAI:1b}]}',
|
|
# Attributes: a slime's size sets its bases, /attribute a pig's scale.
|
|
'summon minecraft:slime 2.5 -60 1.5 {Size:1,NoAI:1b}',
|
|
'summon minecraft:pig -0.5 -60 1.5 {NoAI:1b}',
|
|
'attribute @e[type=minecraft:pig,limit=1,x=-0.5,y=-60,z=1.5,distance=..1] minecraft:scale base set 1.5',
|
|
# Frames and paintings on the wall's south face.
|
|
'summon minecraft:item_frame -3.5 -59 -4.97 {block_pos:[I;-4,-59,-5],Facing:3b,'
|
|
'Item:{id:"minecraft:diamond",count:1},ItemRotation:3b}',
|
|
'summon minecraft:glow_item_frame -1.5 -59 -4.97 {block_pos:[I;-2,-59,-5],Facing:3b}',
|
|
'summon minecraft:painting 1.5 -59 -4.97 {block_pos:[I;1,-59,-5],facing:0b,variant:"minecraft:kebab"}',
|
|
'summon minecraft:painting 4 -59 -4.97 {block_pos:[I;4,-59,-5],facing:0b,variant:"minecraft:courbet"}',
|
|
# Primed TNT that won't go off (one falling), a floating falling block, an arrow that lands, an end crystal.
|
|
'summon minecraft:tnt 6.5 -60 6.5 {fuse:2000s}',
|
|
'summon minecraft:tnt 6.5 -56 4.5 {fuse:2000s}',
|
|
'summon minecraft:falling_block 2.5 -58 -0.5 {BlockState:{Name:"minecraft:sand"},NoGravity:1b}',
|
|
# Sand dropped into a cobweb, which holds it (WebBlock.entityInside).
|
|
'setblock -7 -58 0 minecraft:cobweb',
|
|
'summon minecraft:falling_block -6.5 -54 0.5 {BlockState:{Name:"minecraft:sand"}}',
|
|
'summon minecraft:arrow 0.5 -55 -1.5 {Motion:[0.3d,0d,0d]}',
|
|
'summon minecraft:end_crystal -6.5 -60 6.5 {ShowBottom:0b}',
|
|
]
|
|
|
|
|
|
# finalizeSpawn's random modifiers on client-syncable attributes (a leader zombie's max health); their amounts
|
|
# differ from one server to the other.
|
|
RANDOM_MODIFIERS = re.compile(r'(id=minecraft:(?:random_spawn_bonus|leader_zombie_bonus), amount=)[^,]+')
|
|
|
|
|
|
def scene_lines(lines):
|
|
"""The transcript's entities in the scene's area (the world's own animals and slimes wander elsewhere), the
|
|
amounts of random spawn modifiers normalised."""
|
|
out = []
|
|
for line in lines:
|
|
m = re.match(r'play-entities entity \S+ (-?\d+),-?\d+,(-?\d+)', line)
|
|
if m and -8 <= int(m.group(1)) <= 8 and -7 <= int(m.group(2)) <= 10 or line.startswith('play-entities self '):
|
|
out.append(RANDOM_MODIFIERS.sub(r'\1~', line))
|
|
return out
|
|
|
|
|
|
def run_side(label, server_command, client_command, build, world):
|
|
directory = build / label
|
|
port = server_dir(directory, world)
|
|
out = build / f'{label}-client.txt'
|
|
out.unlink(missing_ok=True)
|
|
server = Server(server_command, directory, label)
|
|
try:
|
|
server.wait_for(r'Done \(')
|
|
with (build / f'{label}-client.log').open('w') as log:
|
|
client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), PLAYER, str(out), 'entities'],
|
|
cwd=lab.ROOT, stdout=log, stderr=subprocess.STDOUT, text=True)
|
|
try:
|
|
server.wait_for(rf'<{PLAYER}> summon the entities', timeout=180)
|
|
# Frozen, so that every entity is paired as it was summoned whenever each server tracks it in
|
|
# its tick; then they fall and roll.
|
|
server.console('tick freeze')
|
|
for command in SCENE:
|
|
server.console(command)
|
|
time.sleep(3)
|
|
# A fixed number of ticks, so that what still changes (a TNT fuse) ends the same.
|
|
server.console(f'tick step {STEPS}')
|
|
client.wait(timeout=120)
|
|
finally:
|
|
if client.poll() is None:
|
|
client.kill()
|
|
finally:
|
|
server.stop()
|
|
if not out.exists():
|
|
raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)')
|
|
lines = out.read_text(encoding='utf-8').splitlines()
|
|
(build / f'{label}.txt').write_text('\n'.join(lines) + '\n', encoding='utf-8')
|
|
return lines
|
|
|
|
|
|
def main():
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
build = lab.LOCAL / NAME
|
|
build.mkdir(parents=True, exist_ok=True)
|
|
report = build / 'report.json'
|
|
report.unlink(missing_ok=True)
|
|
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java'
|
|
classes = build / 'classes'
|
|
shutil.rmtree(classes, ignore_errors=True)
|
|
classes.mkdir()
|
|
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
|
|
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
|
|
cwd=lab.ROOT, check=True, capture_output=True)
|
|
# SERVER_ENTITIES_SIDES=vanilla (or horizon) runs one side and reads the other's last transcript.
|
|
sides = [s for s in os.environ.get('SERVER_ENTITIES_SIDES', 'vanilla,horizon').split(',') if s]
|
|
if 'horizon' in sides:
|
|
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
|
check=True, capture_output=True)
|
|
import json
|
|
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
|
|
cwd=lab.ROOT, text=True))
|
|
binary = Path(metadata['target_directory']) / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
|
|
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
|
|
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
|
|
client = ['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
|
|
'dev.horizonlab.parity.VanillaProtocolClient']
|
|
|
|
world = generate_world(vanilla_server, build)
|
|
results = {}
|
|
for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]:
|
|
if label in sides:
|
|
results[label] = run_side(label, command, client, build, world)
|
|
else:
|
|
results[label] = (build / f'{label}.txt').read_text(encoding='utf-8').splitlines()
|
|
vanilla, horizon = scene_lines(results['vanilla']), scene_lines(results['horizon'])
|
|
errors = [l for l in results['vanilla'] + results['horizon'] if l.startswith('error')]
|
|
if errors or vanilla != horizon:
|
|
import difflib
|
|
diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=0, lineterm=''))
|
|
raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n'
|
|
+ '\n'.join(errors[:10]) + '\n' + diff[:8000])
|
|
if len(vanilla) < len(SCENE) - 2:
|
|
raise AssertionError(f'only {len(vanilla)} entities tracked: the scene did not run (see {build})')
|
|
lab.write_json(report, dict(
|
|
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
|
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
|
entities=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
|
|
scope='Minecarts (one rolling), boats (one falling), armor stands, a chicken jockey, a slime, a pig with a '
|
|
'scale, item frames, paintings, primed TNT, falling blocks (one held by a cobweb), an arrow and an '
|
|
'end crystal '
|
|
'tracked to a vanilla-codec client by a dedicated server: spawn packets, entity data, equipment, '
|
|
'attributes, passengers and the positions the syncs leave them at.'))
|
|
print(f'server entities: {len(vanilla)} entities identical')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except AssertionError as error:
|
|
sys.exit(f'{NAME}: {error}')
|