mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
524 lines
27 KiB
Python
524 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""The packets a Horizon client sends, tick by tick and field by field, against a real vanilla 26.3 client's
|
|
for the same scripted play: an anticheat (Grim, Vulcan, Polar, Watchdog, Intave...) judges the serverbound
|
|
stream, also after ViaVersion translated it for an older server, so Horizon's must be vanilla's.
|
|
|
|
A vanilla 26.3 dedicated server (flat world, offline) runs behind tools/net_capture.py. Each client joins it in
|
|
turn as Steve and plays every scenario below: the harness resets the place and the player from the server's
|
|
console, then hands the client the scenario, which it plays tick by tick from its own tick loop (Horizon:
|
|
HORIZON_TICK_SCRIPT, game/tick_script.rs; vanilla: VanillaTickScript.java, the unchanged client with its
|
|
keys pressed through KeyMapping). Both send /wire-mark on the scenario's first tick. tools/wire_stream.py
|
|
cuts each scenario's stream into client ticks (ClientTickEnd ends one) and decodes the packets; every tick
|
|
must hold the same packets, in the same order, with the same fields (doubles and floats exactly). Packets
|
|
the server's timing decides (keep-alive, pong, chunk batch, teleport acceptance) are compared as a sequence
|
|
over the scenario, and must never fall between two packets of one tick.
|
|
|
|
python3 tools/test_wire_stream_parity.py # every scenario
|
|
python3 tools/test_wire_stream_parity.py --scenarios walk,bow --keep
|
|
python3 tools/test_wire_stream_parity.py --clients horizon,horizon-legacy # Legacy (1.8.9) combat feel
|
|
|
|
The second form plays the same scenarios with two Horizon clients, the second set to the Legacy combat feel with
|
|
every combat extra on (horizon-extras.txt: `LEGACY_EXTRAS`), and compares their streams the same way: the feel
|
|
changes what the player sees and hears, never what the client sends, and the 1.8 rules ("in worlds you host")
|
|
never reach a server that isn't the player's own.
|
|
|
|
Horizon runs `--headless` (no window or GPU: the ticks are what it sends from); the vanilla client runs on the
|
|
virtual screen (`vscreen`) on Linux, from the logged-in session on macOS. Every game and server here runs in fresh temporary folders (HORIZON_HOME,
|
|
HORIZON_LAUNCHER_HOME, XDG_*), with no audio and no self-update. Needs .local (the vanilla jars and prepared
|
|
resources) and a built horizon-client and horizon-ctl (--bin DIR, default the parity target's fast build).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import lab # noqa: E402
|
|
import net_capture # noqa: E402
|
|
import test_rust_parity as base # noqa: E402
|
|
import wire_stream # noqa: E402
|
|
from server_pack_e2e import Game # noqa: E402
|
|
from test_dedicated_server_parity import Server, free_port # noqa: E402
|
|
|
|
PLAYER = 'Steve'
|
|
PROPERTIES = {
|
|
'online-mode': 'false', 'white-list': 'false', 'level-seed': '12345', 'level-type': 'minecraft\\:flat',
|
|
'generate-structures': 'false', 'view-distance': '4', 'simulation-distance': '4', 'spawn-protection': '0',
|
|
'pause-when-empty-seconds': '-1', 'gamemode': 'survival', 'difficulty': 'easy', 'spawn-monsters': 'false',
|
|
'spawn-animals': 'false', 'enable-command-block': 'false',
|
|
}
|
|
# The flat world's grass is at y -61: the player stands at -60.
|
|
FLOOR = -61
|
|
RESET = [
|
|
'gamerule spawn_mobs false', 'gamerule advance_time false', 'gamerule advance_weather false',
|
|
f'fill -12 {FLOOR + 1} -12 12 {FLOOR + 12} 28 minecraft:air',
|
|
f'fill -12 {FLOOR} -12 12 {FLOOR} 28 minecraft:grass_block',
|
|
'kill @e[type=!minecraft:player]', f'clear {PLAYER}', f'effect clear {PLAYER}', f'gamemode survival {PLAYER}',
|
|
f'effect give {PLAYER} minecraft:saturation 1 20 true', f'effect give {PLAYER} minecraft:instant_health 1 5 true',
|
|
f'tp {PLAYER} 0.5 {FLOOR + 1} 0.5 0 0',
|
|
]
|
|
# A target that takes hits without moving: no AI, no knockback.
|
|
TARGET = ('summon minecraft:husk {x} {y} {z} {{NoAI:1b,Silent:1b,PersistenceRequired:1b,Health:1000f,'
|
|
'attributes:[{{id:"minecraft:max_health",base:1000.0}},{{id:"minecraft:knockback_resistance",base:1.0}}]}}')
|
|
# Every scenario starts with a short step forward: it moves the player, which starts LocalPlayer.sendPosition's
|
|
# 20-tick reminder over on both clients alike, and selects the first slot.
|
|
PRELUDE = 10
|
|
# Client ticks between a reset's last teleport and the scenario: the player lands and stands still.
|
|
SETTLE = 40
|
|
# The Legacy client's horizon-extras.txt (crates/horizon-client-render/src/pvp.rs): the 1.8.9 feel, its
|
|
# indicator and sounds, and the 1.8 rules, which only a world the player hosts may use.
|
|
LEGACY_EXTRAS = 'combatFeel:legacy\ncombatIndicator:true\ncombatSounds:true\ncombatRules:true\n'
|
|
# The clients --clients may name: the first is the reference, the second must send what it sends.
|
|
CLIENTS = {'vanilla': 'vanilla', 'horizon': 'Horizon', 'horizon-legacy': 'Horizon Legacy'}
|
|
|
|
|
|
def taps(key, ticks):
|
|
return {t: [f'key {key} tap'] for t in ticks}
|
|
|
|
|
|
def merge(*parts):
|
|
out = {}
|
|
for part in parts:
|
|
for tick, lines in part.items():
|
|
out.setdefault(tick, []).extend(lines)
|
|
return out
|
|
|
|
|
|
def every(start, stop, step, *lines):
|
|
return {t: list(lines) for t in range(start, stop, step)}
|
|
|
|
|
|
SCENARIOS = {
|
|
'walk': dict(ticks=70, steps=merge(
|
|
{0: ['key w down'], 20: ['look 30 0'], 30: ['look 30 15'], 40: ['key w up'], 50: ['look 0 0']})),
|
|
'idle': dict(ticks=60, steps={}),
|
|
'sprint_jump': dict(ticks=70, steps={
|
|
0: ['key ctrl down', 'key w down'], 5: ['key space down'], 45: ['key space up'],
|
|
50: ['key w up', 'key ctrl up']}),
|
|
'strafe': dict(ticks=70, steps={
|
|
0: ['key w down', 'key a down'], 12: ['key a up', 'key d down'], 24: ['key d up', 'key a down'],
|
|
36: ['key a up', 'key ctrl down'], 48: ['key w up', 'key ctrl up', 'key s down'], 56: ['key s up']}),
|
|
'w_tap': dict(ticks=70, steps=merge(
|
|
{0: ['key ctrl down', 'key w down']},
|
|
every(10, 50, 8, 'key w up'), every(12, 52, 8, 'key w down'),
|
|
{55: ['key w up', 'key ctrl up']})),
|
|
'sneak': dict(ticks=60, steps={
|
|
0: ['key shift down'], 5: ['key w down'], 25: ['key shift up'], 30: ['key shift down'],
|
|
40: ['key w up'], 45: ['key shift up']}),
|
|
'hit_target': dict(
|
|
setup=[TARGET.format(x=0.5, y=FLOOR + 1, z=3.0), f'item replace entity {PLAYER} weapon.mainhand with '
|
|
'minecraft:diamond_sword'],
|
|
ticks=70, steps=merge({0: ['look 0 20']}, every(5, 65, 12, 'mouse left click'))),
|
|
'sprint_combo': dict(
|
|
setup=[TARGET.format(x=0.5, y=FLOOR + 1, z=7.0), f'item replace entity {PLAYER} weapon.mainhand with '
|
|
'minecraft:diamond_sword'],
|
|
ticks=60, steps=merge(
|
|
{0: ['look 0 10', 'key ctrl down', 'key w down']}, every(12, 52, 10, 'mouse left click'),
|
|
every(14, 54, 10, 'key w up'), every(16, 56, 10, 'key w down'), {56: ['key w up', 'key ctrl up']})),
|
|
'block_hit': dict(
|
|
setup=[TARGET.format(x=0.5, y=FLOOR + 1, z=3.0),
|
|
f'item replace entity {PLAYER} weapon.mainhand with minecraft:diamond_sword',
|
|
f'item replace entity {PLAYER} weapon.offhand with minecraft:shield'],
|
|
ticks=70, steps={0: ['look 0 20'], 5: ['mouse left click'], 8: ['mouse right down'], 20: ['mouse right up'],
|
|
22: ['mouse left click'], 25: ['mouse right down'], 30: ['mouse left click'],
|
|
40: ['mouse right up'], 50: ['mouse left click']}),
|
|
'rod': dict(
|
|
setup=[TARGET.format(x=0.5, y=FLOOR + 1, z=6.0),
|
|
f'item replace entity {PLAYER} weapon.mainhand with minecraft:fishing_rod'],
|
|
ticks=80, steps={0: ['look 0 -5'], 5: ['mouse right click'], 35: ['mouse right click'],
|
|
45: ['mouse right click'], 70: ['mouse right click']}),
|
|
'bow': dict(
|
|
setup=[f'item replace entity {PLAYER} weapon.mainhand with minecraft:bow',
|
|
f'give {PLAYER} minecraft:arrow 16'],
|
|
ticks=80, steps={0: ['look 0 -10'], 5: ['mouse right down'], 30: ['mouse right up'],
|
|
40: ['mouse right down'], 45: ['mouse right up'], 55: ['mouse right down'],
|
|
75: ['mouse right up']}),
|
|
'eat': dict(
|
|
setup=[f'item replace entity {PLAYER} weapon.mainhand with minecraft:golden_apple 4'],
|
|
# Two bites of 10 ticks, a third of the 32 a golden apple takes: when a full one ends is the server's to
|
|
# say (its entity event), and a loaded server that catches up runs its ticks in a burst.
|
|
ticks=50, steps={5: ['mouse right down'], 15: ['mouse right up'], 25: ['mouse right down'],
|
|
35: ['mouse right up']}),
|
|
'bridge': dict(
|
|
setup=[f'fill -2 {FLOOR + 1} -2 2 {FLOOR + 2} 2 minecraft:stone', f'tp {PLAYER} 0.5 {FLOOR + 3} 2.2 180 78',
|
|
f'item replace entity {PLAYER} weapon.mainhand with minecraft:stone 64'],
|
|
ticks=90, steps=merge({0: ['look 0 78', 'key shift down', 'key s down']}, every(4, 80, 4, 'mouse right click'),
|
|
{82: ['key s up', 'key shift up']})),
|
|
'inventory': dict(
|
|
setup=[f'item replace entity {PLAYER} hotbar.0 with minecraft:diamond_sword',
|
|
f'item replace entity {PLAYER} hotbar.1 with minecraft:cooked_beef 16',
|
|
f'item replace entity {PLAYER} inventory.0 with minecraft:golden_apple 8'],
|
|
ticks=60, steps={5: ['key e tap'], 10: ['click-slot 36'], 14: ['click-slot 9'], 18: ['click-slot 36'],
|
|
22: ['click-slot 37 shift'], 26: ['click-slot 10 right'], 30: ['key e tap'],
|
|
40: ['key e tap'], 45: ['key esc tap']}),
|
|
'hotbar_swap': dict(
|
|
setup=[TARGET.format(x=0.5, y=FLOOR + 1, z=3.0)] +
|
|
[f'item replace entity {PLAYER} hotbar.{i} with minecraft:{item}' for i, item in
|
|
enumerate(['diamond_sword', 'stone_axe', 'golden_apple', 'stone', 'bow'])],
|
|
# 52-54: a swing at the sky with a new slot sends no slot before its Punch (no ensureHasSentCarriedItem
|
|
# on a miss); the next tick's MultiPlayerGameMode.tick does.
|
|
ticks=60, steps=merge({0: ['look 0 20']}, {t: [f'key {t % 5 + 1} tap'] for t in range(5, 15)},
|
|
{16: ['key 2 tap', 'mouse left click'], 20: ['key 1 tap'], 21: ['key 3 tap'],
|
|
30: ['key 5 tap', 'mouse left click'], 35: ['key q tap'], 40: ['key f tap'],
|
|
52: ['look 0 -80'], 54: ['key 3 tap', 'mouse left click']})),
|
|
'dig_teleport': dict(
|
|
setup=[f'setblock 0 {FLOOR + 1} 2 minecraft:stone'],
|
|
ticks=60, anchor='accept_teleportation',
|
|
steps={0: ['look 0 45'], 3: ['mouse left down'], 15: ['command tp @s ~ ~ ~'], 40: ['mouse left up']}),
|
|
}
|
|
|
|
|
|
def horizon_lines(lines):
|
|
"""A scenario's lines for horizon-ctl: menu slot ids become its inventory indices (hotbar 0-8, main 9-35)."""
|
|
out = []
|
|
for line in lines:
|
|
w = line.split()
|
|
if w[0] == 'click-slot':
|
|
slot = int(w[1])
|
|
w[1] = str(slot - 36 if 36 <= slot <= 44 else slot)
|
|
out.append(' '.join(w))
|
|
return out
|
|
|
|
|
|
def script(name, spec, horizon):
|
|
steps = {0: ['key 1 tap', 'key w down'], 2: ['key w up']}
|
|
for tick, lines in spec['steps'].items():
|
|
steps.setdefault(tick + PRELUDE, []).extend(horizon_lines(lines) if horizon else lines)
|
|
return {'name': name, 'ticks': spec['ticks'] + PRELUDE, 'steps': {str(k): v for k, v in sorted(steps.items())}}
|
|
|
|
|
|
class Failed(Exception):
|
|
pass
|
|
|
|
|
|
MESA_EGL = Path('/run/opengl-driver/share/glvnd/egl_vendor.d/50_mesa.json')
|
|
|
|
|
|
def screen_env(env):
|
|
"""Where the vanilla client's window goes. On Linux, the virtual screen (`vscreen`), never a real monitor,
|
|
and Mesa's EGL: NVIDIA's crashed the client there (libEGL_nvidia). On macOS, the logged-in session."""
|
|
if sys.platform == 'darwin' or not shutil.which('vscreen'):
|
|
return env
|
|
subprocess.run(['vscreen', 'up'], check=False, capture_output=True)
|
|
text = subprocess.run(['vscreen', 'env'], capture_output=True, text=True, check=True).stdout
|
|
env = dict(env)
|
|
env.pop('DISPLAY', None)
|
|
for match in re.finditer(r'(\w+)=(\S+)', text.split('unset')[0]):
|
|
env[match.group(1)] = match.group(2).strip('"\'')
|
|
if MESA_EGL.exists():
|
|
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = str(MESA_EGL)
|
|
return env
|
|
|
|
|
|
class VanillaClient:
|
|
"""The vanilla 26.3 client, VanillaTickScript as its main class, joined to `address`."""
|
|
|
|
def __init__(self, address, work, log):
|
|
import vanilla_client
|
|
classes = work / 'classes'
|
|
classes.mkdir(parents=True)
|
|
jars = os.pathsep.join(map(str, vanilla_client.classpath()))
|
|
release = str(lab.config()[0]['minecraft']['java_major'])
|
|
source = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaTickScript.java'
|
|
subprocess.run(['javac', '--release', release, '-proc:none', '-nowarn', '-cp', jars, '-d', classes, source],
|
|
check=True, capture_output=True)
|
|
args = argparse.Namespace(name=PLAYER, join=address, world=None, game_dir=work / 'client', assets=None,
|
|
heap='2G', width=854, height=480, lan_port=None, backend=None)
|
|
args.game_dir.mkdir()
|
|
# 20 frames a second at least: a tick's lines then act in their own frame, as Horizon's do.
|
|
vanilla_client.write_options(args.game_dir, {'maxFps': '60'})
|
|
command = vanilla_client.command(args)
|
|
cp = command.index('-cp') + 1
|
|
command[cp] = os.pathsep.join([str(classes), command[cp]])
|
|
command[command.index('net.minecraft.client.main.Main')] = 'dev.horizonlab.parity.VanillaTickScript'
|
|
self.log = open(log, 'w')
|
|
self.ready, self.done = threading.Event(), {}
|
|
self.process = subprocess.Popen(command, cwd=args.game_dir, env=screen_env(vanilla_client.environment()), text=True,
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
threading.Thread(target=self.pump, daemon=True).start()
|
|
|
|
def pump(self):
|
|
for line in self.process.stdout:
|
|
self.log.write(line)
|
|
self.log.flush()
|
|
if '[VanillaTickScript] ready' in line:
|
|
self.ready.set()
|
|
elif '[VanillaTickScript] done ' in line:
|
|
self.done[line.split('[VanillaTickScript] done ')[1].strip()] = time.time()
|
|
|
|
def play(self, path, name, timeout):
|
|
self.process.stdin.write(f'run {path}\n')
|
|
self.process.stdin.flush()
|
|
deadline = time.time() + timeout
|
|
while name not in self.done:
|
|
if time.time() > deadline or self.process.poll() is not None:
|
|
raise Failed(f'vanilla: scenario {name} never finished (see vanilla-client.log)')
|
|
time.sleep(0.2)
|
|
|
|
def close(self):
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(20)
|
|
except subprocess.TimeoutExpired:
|
|
self.process.kill()
|
|
self.log.close()
|
|
|
|
|
|
class HeadlessGame(Game):
|
|
"""server_pack_e2e's game, `--headless`: no display, window or GPU; the frame and the ticks run as a
|
|
window's do (game/headless.rs), and only the ticks' packets matter here."""
|
|
|
|
def __init__(self, bins, resources, work, scripts, extras=None):
|
|
self.home = work / 'home'
|
|
for d in ('home', 'launcher', 'data', 'config'):
|
|
(work / d).mkdir(parents=True)
|
|
if extras:
|
|
# The game folder is the home here (Game runs horizon-ctl there): extras sit next to options.txt.
|
|
(self.home / 'horizon-extras.txt').write_text(extras)
|
|
self.env = lab.game_env(HORIZON_HOME=self.home, HORIZON_LAUNCHER_HOME=work / 'launcher',
|
|
XDG_DATA_HOME=work / 'data', XDG_CONFIG_HOME=work / 'config',
|
|
HORIZON_TELEMETRY_URL='off', HORIZON_TICK_SCRIPT=scripts / 'scenario.json')
|
|
self.base = [str(bins / 'horizon-ctl'), '--socket', str(work / 'c.sock')]
|
|
self.log = self.home / 'logs' / 'latest.log'
|
|
reply = self('launch', '--game', bins / 'horizon-client', '--headless', '--resources', resources)
|
|
self.pid = reply.get('pid')
|
|
|
|
|
|
class HorizonClient:
|
|
def __init__(self, bins, resources, work, address, scripts, extras=None):
|
|
self.scripts = scripts
|
|
self.game = HeadlessGame(bins, resources, work, scripts, extras)
|
|
self.game('connect', address, PLAYER)
|
|
self.game('wait-for', 'loaded', '--timeout', 180)
|
|
|
|
def play(self, path, name, timeout):
|
|
target = self.scripts / 'scenario.json'
|
|
done = target.with_suffix('.done')
|
|
done.unlink(missing_ok=True)
|
|
shutil.copy(path, target)
|
|
target.with_suffix('.go').touch()
|
|
deadline = time.time() + timeout
|
|
while not done.exists():
|
|
if time.time() > deadline:
|
|
raise Failed(f'Horizon: scenario {name} never finished (see horizon-client.log)')
|
|
time.sleep(0.2)
|
|
|
|
def close(self):
|
|
self.game('disconnect', ok=False)
|
|
self.game.quit()
|
|
|
|
|
|
class ClientCounts:
|
|
"""How many teleports the client has accepted, and how many ticks it has ended, read from its capture as it
|
|
grows (complete lines only)."""
|
|
|
|
def __init__(self, capture):
|
|
self.capture, self.offset, self.accepted, self.ticks = capture, 0, 0, 0
|
|
|
|
def update(self):
|
|
with open(self.capture, 'rb') as f:
|
|
f.seek(self.offset)
|
|
data = f.read()
|
|
data = data[:data.rfind(b'\n') + 1]
|
|
self.offset += len(data)
|
|
for line in data.splitlines():
|
|
if b'"c2s"' in line:
|
|
e = json.loads(line)
|
|
self.accepted += e.get('name') == 'accept_teleportation'
|
|
self.ticks += e.get('name') == 'client_tick_end'
|
|
return self
|
|
|
|
|
|
def settle(counts, accepted, timeout=120):
|
|
"""Wait until the client has accepted `accepted` teleports in all, then for SETTLE more of its ticks. The
|
|
last command of a reset is a teleport, so the server has run them all by then; a busy server can take
|
|
seconds to, and a teleport landing in a scenario's first ticks would change all of its moves."""
|
|
deadline = time.time() + timeout
|
|
while counts.update().accepted < accepted:
|
|
if time.time() > deadline:
|
|
raise Failed(f'{counts.capture.stem}: the client never accepted the reset\'s teleports')
|
|
time.sleep(0.2)
|
|
ticks = counts.ticks
|
|
while counts.update().ticks < ticks + SETTLE:
|
|
if time.time() > deadline:
|
|
raise Failed(f'{counts.capture.stem}: the client stopped ticking')
|
|
time.sleep(0.2)
|
|
|
|
|
|
def play_all(client, server, scenarios, scripts, horizon, capture):
|
|
counts = ClientCounts(capture)
|
|
for name in scenarios:
|
|
spec = SCENARIOS[name]
|
|
# The place settles: blocks and items reach the client, the player stands still on the ground.
|
|
# From the console `~` is the world spawn: the player teleports onto itself.
|
|
commands = RESET + spec.get('setup', []) + [f'tp {PLAYER} {PLAYER}']
|
|
accepted = counts.update().accepted + sum(c.startswith('tp ') for c in commands)
|
|
for command in commands:
|
|
server.console(command)
|
|
settle(counts, accepted)
|
|
path = scripts / f'{name}-{"horizon" if horizon else "vanilla"}.json'
|
|
path.write_text(json.dumps(script(name, spec, horizon)))
|
|
client.play(path, name, timeout=60)
|
|
time.sleep(0.5)
|
|
|
|
|
|
def run(args, out):
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
deps = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
vanilla = ['java', '-Xmx1g', '-cp', deps, 'net.minecraft.server.Main', 'nogui']
|
|
resources = lab.LOCAL / 'horizon-client' / lab.VERSION
|
|
work = Path(tempfile.mkdtemp(prefix='hz-wire-stream-'))
|
|
print(f'scratch: {work}')
|
|
for var, sub in (('HORIZON_HOME', 'home'), ('HORIZON_LAUNCHER_HOME', 'launcher'), ('XDG_DATA_HOME', 'data'),
|
|
('XDG_CONFIG_HOME', 'config')):
|
|
(work / 'env' / sub).mkdir(parents=True)
|
|
os.environ[var] = str(work / 'env' / sub)
|
|
os.environ.update(lab.game_env(HORIZON_TELEMETRY_URL='off'))
|
|
directory = work / 'server'
|
|
directory.mkdir()
|
|
port = free_port()
|
|
props = dict(PROPERTIES, **{'server-port': str(port), 'server-ip': '127.0.0.1'})
|
|
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
|
|
encoding='iso-8859-1')
|
|
(directory / 'eula.txt').write_text('eula=true\n')
|
|
(directory / 'ops.json').write_text(json.dumps([{'uuid': str(lab.offline_uuid(PLAYER)), 'name': PLAYER,
|
|
'level': 4, 'bypassesPlayerLimit': False}]))
|
|
scripts = work / 'scripts'
|
|
scripts.mkdir()
|
|
server = Server(vanilla, directory, 'server')
|
|
captures = {}
|
|
try:
|
|
server.wait_for(r'Done \(', timeout=900)
|
|
for who in args.clients:
|
|
capture = out / f'{who}.jsonl'
|
|
capture.touch()
|
|
listen = free_port()
|
|
stop = net_capture.start_background(listen, f'127.0.0.1:{port}', capture, full=True)
|
|
client = None
|
|
try:
|
|
if who != 'vanilla':
|
|
client = HorizonClient(Path(args.bin).resolve(), resources, work / who, f'127.0.0.1:{listen}',
|
|
scripts, LEGACY_EXTRAS if who == 'horizon-legacy' else None)
|
|
else:
|
|
client = VanillaClient(f'127.0.0.1:{listen}', work / 'vanilla', out / 'vanilla-client.log')
|
|
if not client.ready.wait(900):
|
|
raise Failed('the vanilla client never got into the world')
|
|
time.sleep(3)
|
|
play_all(client, server, args.scenarios, scripts, who != 'vanilla', capture)
|
|
finally:
|
|
if client:
|
|
if who != 'vanilla' and client.game.log.exists():
|
|
shutil.copy(client.game.log, out / f'{who}-client.log')
|
|
client.close()
|
|
time.sleep(2)
|
|
stop.set()
|
|
captures[who] = capture
|
|
finally:
|
|
server.stop()
|
|
shutil.copy(server.log_path, out / server.log_path.name)
|
|
if not args.keep:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
return captures
|
|
|
|
|
|
def anchored_diffs(v, h, ticks, anchor, problems, names=('vanilla', 'Horizon')):
|
|
"""wire_stream.compare over the scenario's ticks. With an anchor (a frame packet the server's answer brings,
|
|
as AcceptTeleportation), the two ticks that answer arrived in compare with each other
|
|
(wire_stream.compare_answered). Numbers are vanilla's ticks."""
|
|
if not anchor:
|
|
return wire_stream.compare(v, h, ticks)
|
|
av, ah = wire_stream.first_frame(v, anchor), wire_stream.first_frame(h, anchor)
|
|
if av is None or ah is None:
|
|
problems.append(f' no {anchor}: {names[0]} at tick {av}, {names[1]} at tick {ah}')
|
|
return wire_stream.compare(v, h, ticks)
|
|
return wire_stream.compare_answered(v, h, ticks, av, ah)
|
|
|
|
|
|
def compare(captures, scenarios):
|
|
"""Each scenario's problems, the second client's stream against the first's (`captures` in that order)."""
|
|
(ref, ref_name), (new, new_name) = [(who, CLIENTS[who]) for who in captures]
|
|
runs = {}
|
|
for who, capture in captures.items():
|
|
entries = [e for e in net_capture.load(capture) if e.get('conn') == 1 and 'name' in e]
|
|
found = wire_stream.scenarios(entries)
|
|
if len(found) != len(scenarios):
|
|
raise Failed(f'{who}: {len(found)} marked scenarios in the capture, expected {len(scenarios)}')
|
|
runs[who] = dict(zip(scenarios, found))
|
|
results = []
|
|
for name in scenarios:
|
|
v, h = runs[ref][name], runs[new][name]
|
|
ticks = SCENARIOS[name]['ticks'] + PRELUDE
|
|
problems = []
|
|
for i, vt, ht in anchored_diffs(v, h, ticks, SCENARIOS[name].get('anchor'), problems, (ref_name, new_name)):
|
|
problems.append(f' tick {i}:\n {ref_name}:\n{wire_stream.describe(vt)}\n {new_name}:\n'
|
|
f'{wire_stream.describe(ht)}')
|
|
problems += wire_stream.field_diff(vt, ht, (ref_name, new_name))
|
|
# The server decides when keep-alives and chunk batches come; the others answer what the scenario did.
|
|
frame = lambda run: [n for t in run[:ticks + 1] for n, _ in t['frame'] # noqa: E731
|
|
if n not in ('keep_alive', 'chunk_batch_received')]
|
|
if frame(v) != frame(h):
|
|
problems.append(f' frame packets: {ref_name} {frame(v)}, {new_name} {frame(h)}')
|
|
misplaced = [(i, t['misplaced']) for i, t in enumerate(h[:ticks + 1]) if t['misplaced']]
|
|
if misplaced:
|
|
problems.append(f' {new_name} sent the server\'s answers inside a tick: {misplaced}')
|
|
results.append((name, problems))
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--bin', default=str(lab.cargo_target() / 'fast'), help='horizon-client and horizon-ctl')
|
|
parser.add_argument('--out', default=str(lab.LOCAL / 'wire-stream-parity'))
|
|
parser.add_argument('--scenarios', default=','.join(SCENARIOS), type=lambda s: s.split(','))
|
|
parser.add_argument('--clients', default='vanilla,horizon', type=lambda s: s.split(','),
|
|
help='the reference client and the one compared with it, of vanilla, horizon and '
|
|
'horizon-legacy; one alone is only captured (a loaded machine whose vanilla client fails '
|
|
'to log in: rerun horizon alone, copy an earlier vanilla.jsonl into --out, then '
|
|
'--compare-only)')
|
|
parser.add_argument('--compare-only', action='store_true', help='compare the captures already in --out')
|
|
parser.add_argument('--keep', action='store_true', help='keep the scratch folders')
|
|
args = parser.parse_args()
|
|
unknown = [s for s in args.scenarios if s not in SCENARIOS]
|
|
if unknown:
|
|
parser.error(f'unknown scenarios {unknown}; known: {", ".join(SCENARIOS)}')
|
|
if len(args.clients) not in (1, 2) or len(set(args.clients)) != len(args.clients) \
|
|
or not set(args.clients) <= set(CLIENTS):
|
|
parser.error(f'--clients takes one or two of {", ".join(CLIENTS)}')
|
|
if args.compare_only and len(args.clients) != 2:
|
|
parser.error('--compare-only compares two clients: name both in --clients')
|
|
out = Path(args.out)
|
|
if args.compare_only:
|
|
captures = {who: out / f'{who}.jsonl' for who in args.clients}
|
|
else:
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
out.mkdir(parents=True)
|
|
try:
|
|
captures = run(args, out)
|
|
except Failed as e:
|
|
sys.exit(f'FAIL: {e}')
|
|
if len(captures) < 2:
|
|
print(f'captured {", ".join(captures)} in {out}; nothing to compare it with')
|
|
return
|
|
try:
|
|
results = compare(captures, args.scenarios)
|
|
except Failed as e:
|
|
sys.exit(f'FAIL: {e}')
|
|
print(f'\nserverbound play streams, {CLIENTS[args.clients[0]]} vs {CLIENTS[args.clients[1]]}; captures in {out}')
|
|
for name, problems in results:
|
|
print(f' {"pass" if not problems else "FAIL"} {name}')
|
|
for p in problems:
|
|
print(p)
|
|
if any(p for _, p in results):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|