#!/usr/bin/env python3 """The dedicated server against vanilla's, seen from a vanilla client. The pinned server JAR first generates a world (offline mode, a fixed seed, respawn_radius 0) and stops; a vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) then serve copies of it with the same server.properties, server-icon.png and lists. VanillaProtocolClient, a headless client made of vanilla's own codecs (any byte vanilla would not read is an error), pings each server, joins it in offline mode through login, configuration and play, accepts the spawn teleport, receives the chunks around the spawn (their bytes are compared: the world is loaded from the same files), chats, runs /list and commands that fail (`/random value 7..7`, `/random value x`, `/trigger nope`), trades, is made an operator by the console, runs /kill, respawns (as vanilla's LevelLoadTracker waits: until the "level chunks load start" event and the chunk under the player), sets a furnace beside itself, gives itself raw iron and coal, opens the furnace, shift-clicks the ore and the coal in and the ingot out, and leaves. The console then runs `list` and `say` and `stop`. Compared, all of them exactly: - the status response (MOTD, players, version, favicon) and the ping; - the legacy (pre-1.7, 0xFE) ping reply; - the login and configuration packets in order (compression, profile, brand, features, known packs, every registry, tags); - the play packets up to the first chunk batch, in order, with entity ids, keep-alive ids and times left out (the recipe property sets and advancements, which vanilla keeps in hash maps, sorted); the command tree node by node; the chunks sent and their bytes; the replies to the chat message, /list and the failing commands, as text components; - after /kill, the packets from the death screen to the respawned inventory (PlayerList.respawn; chat, chunk data, light and keep-alives left out, runs of a packet counted), then that the level loads where the player respawned (guests' death messages aren't Horizon's yet: left out); - the furnace menu: its type and title, its contents, the slots each shift-click changes and its lit and cooking totals; - the console lines about the player, the chat, `list`, `say` and `stop`; - the files the servers leave: server.properties (but the date line), eula.txt, ops.json, whitelist.json, banned-players.json, banned-ips.json. Online mode needs Mojang's session servers and is not tested. """ import json import os from pathlib import Path import platform import re import shutil import socket import struct import subprocess import sys import threading import time import zlib try: from . import lab, oracle_harness as oh, test_rust_parity as base except ImportError: import lab import oracle_harness as oh import test_rust_parity as base NAME = 'dedicated-server-parity' SEED = 12345 PLAYER = 'Tester' PROPERTIES = { 'online-mode': 'false', 'white-list': 'false', 'level-seed': str(SEED), 'view-distance': '4', 'simulation-distance': '4', 'spawn-protection': '0', 'max-players': '5', 'motd': '\\u00a7aHorizon \\u00a7rparity \\u00e9t\\u00e9', 'pause-when-empty-seconds': '-1', } def free_port(): with socket.socket() as s: s.bind(('127.0.0.1', 0)) return s.getsockname()[1] def icon_png(): """A 64x64 RGBA PNG (server-icon.png).""" rows = b''.join(b'\0' + b''.join(bytes((x * 4, y * 4, (x ^ y) * 4, 255)) for x in range(64)) for y in range(64)) def chunk(kind, data): return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 64, 64, 8, 6, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')) def write_server_dir(directory, port, world=None): if directory.exists(): shutil.rmtree(directory) directory.mkdir(parents=True) props = dict(PROPERTIES, **{'server-port': str(port)}) (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 / 'server-icon.png').write_bytes(icon_png()) if world is not None: shutil.copytree(world, directory / 'world') for lock in (directory / 'world').glob('session.lock'): lock.unlink() class Server: """A server process with its console on stdin and its log collected.""" def __init__(self, command, directory, log_name): self.log_path = directory.parent / f'{log_name}.log' self.log = self.log_path.open('w', encoding='utf-8') self.lines = [] self.process = subprocess.Popen(list(map(str, command)), cwd=directory, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace') self.reader = threading.Thread(target=self._read, daemon=True) self.reader.start() def _read(self): for line in self.process.stdout: self.lines.append(line.rstrip('\n')) self.log.write(line) self.log.flush() def wait_for(self, pattern, timeout=300): deadline = time.time() + timeout regex = re.compile(pattern) while time.time() < deadline: if any(regex.search(l) for l in self.lines): return if self.process.poll() is not None: time.sleep(0.5) raise AssertionError(f'{self.log_path.name}: server exited ({self.process.returncode}):\n' + '\n'.join(self.lines[-30:])) time.sleep(0.2) raise AssertionError(f'{self.log_path.name}: no line matching {pattern!r} in {timeout} s:\n' + '\n'.join(self.lines[-30:])) def console(self, command): self.process.stdin.write(command + '\n') self.process.stdin.flush() def stop(self, timeout=120): if self.process.poll() is None: try: self.console('stop') self.process.wait(timeout=timeout) except (BrokenPipeError, subprocess.TimeoutExpired): self.process.kill() self.process.wait() self.reader.join(timeout=10) self.log.close() def settle(server, ticks=600, timeout=600): """Waits until every force-loaded chunk is loaded, then for `ticks` game ticks, so their fluids have finished flowing however slow the machine.""" deadline = time.time() + timeout offsets = range(-80, 81, 16) for poll in range(1_000_000): start = len(server.lines) for dx in offsets: for dz in offsets: server.console(f'execute unless loaded ~{dx} ~ ~{dz} run say unloaded') server.console(f'say poll {poll}') server.wait_for(rf'\[Server\] poll {poll}$', timeout=max(1, deadline - time.time())) if not any(l.endswith('[Server] unloaded') for l in server.lines[start:]): break time.sleep(2) def game_time(): start = len(server.lines) server.console('time query gametime') while time.time() < deadline: for line in server.lines[start:]: if m := re.search(r'The (?:game )?time is (\d+)', line): return int(m.group(1)) time.sleep(0.2) raise AssertionError(f'{server.log_path.name}: no game time in {timeout} s') until = game_time() + ticks while game_time() < until: time.sleep(2) def legacy_ping(port): """The reply to a 1.6 client's 0xFE 0x01 0xFA MC|PingHost query (LegacyQueryHandler).""" host = 'localhost' data = (b'\xfe\x01\xfa' + struct.pack('>H', 11) + 'MC|PingHost'.encode('utf-16-be') + struct.pack('>H', 7 + 2 * len(host)) + bytes([78]) + struct.pack('>H', len(host)) + host.encode('utf-16-be') + struct.pack('>I', port)) with socket.create_connection(('127.0.0.1', port), timeout=10) as s: s.sendall(data) reply = b'' while True: try: chunk = s.recv(4096) except TimeoutError: return reply.hex() + ' ' if not chunk: break reply += chunk return reply.hex() CONSOLE_LINE = re.compile(r'^\[\d\d:\d\d:\d\d\] \[([^\]]+)\]: (.*)$') KEPT = re.compile(r'Tester|\[Server\]|\[Not Secure\]|There are|Stopping|Saving|ThreadedAnvilChunkStorage|All dimensions|Unknown or incomplete') # Guests' death messages, the op's command feedback broadcast to the console and guests' # advancement announcements aren't Horizon's yet (the client's own transcript leaves the # death message out as well). NOT_YET = re.compile(r'Tester was killed|Killed Tester|System chat: \[Tester: |Tester has made the advancement') def console_lines(lines): """The console lines the comparison covers, timestamps and ports aside.""" out = [] for line in lines: m = CONSOLE_LINE.match(line) if not m or not KEPT.search(m.group(2)) or NOT_YET.search(m.group(2)): continue text = re.sub(r'/127\.0\.0\.1:\d+', '/127.0.0.1:_', m.group(2)) text = re.sub(r'entity id \d+', 'entity id _', text) out.append(f'[{m.group(1)}] {text}') # Vanilla saves its levels in the order of an identity-hashed map, which # changes between runs: each block of saves is compared sorted. i = 0 while i < len(out): j = i while j < len(out) and 'Saving chunks for level' in out[j]: j += 1 out[i:j] = sorted(out[i:j]) i = j + 1 if j == i else j return out def server_files(directory): out = [] for name in ['server.properties', 'eula.txt', 'ops.json', 'whitelist.json', 'banned-players.json', 'banned-ips.json']: path = directory / name if not path.exists(): out.append(f'{name}: missing') continue text = path.read_bytes().decode('iso-8859-1') if name in ('server.properties', 'eula.txt'): # The second comment line is the date Properties.store wrote. text = re.sub(r'^(#[^\n]*\n)#[^\n]*\n', r'\1#\n', text) # Random per server, and each side has its own port. text = re.sub(r'\n(management-server-secret|server-port)=[^\n]*', r'\n\1=', text) out.append(f'{name}: {text!r}') return out def run_side(label, command, directory, client): server = Server(command, directory, label) try: server.wait_for(r'Done \(') # A librarian beside the spawn for the client to trade with (no AI: # it stays put; experience keeps its profession). server.console('summon minecraft:villager ~1 ~ ~ {NoAI:1b,Xp:1,VillagerData:' '{profession:"minecraft:librarian",level:2,type:"minecraft:plains"}}') port = int(PROPERTIES_PORTS[label]) legacy = legacy_ping(port) # The client asks to be made an operator once it has traded, runs /kill and respawns. failure = [] def join(): try: client(port, directory.parent / f'{label}-client.txt') except BaseException as error: # noqa: BLE001 - reported on the main thread failure.append(error) joined = threading.Thread(target=join) joined.start() deadline = time.time() + 300 while joined.is_alive() and not any(' ready to respawn' in l for l in server.lines): if time.time() > deadline: break time.sleep(0.2) if joined.is_alive(): server.console(f'op {PLAYER}') joined.join() if failure: raise failure[0] time.sleep(1) server.console('list') server.console('say console says hi') server.wait_for(r'\[Server\] console says hi', timeout=30) finally: server.stop() transcript = (directory.parent / f'{label}-client.txt').read_text(encoding='utf-8').splitlines() return (['legacy-ping ' + legacy] + transcript + ['console ' + l for l in console_lines(server.lines)] + ['file ' + l for l in server_files(directory)]) PROPERTIES_PORTS = {} def horizon_client_joins(vanilla_server, binary, world, build, reference): """Horizon's client joins a vanilla dedicated server headless: it must land where the vanilla client did, receive as many chunks, see its chat and /list reply, and leave as a vanilla client does (the server's log).""" label = 'vanilla-for-horizon-client' PROPERTIES_PORTS[label] = port = free_port() directory = build / label write_server_dir(directory, port, world) client_dir = build / 'horizon-client' shutil.rmtree(client_dir, ignore_errors=True) client_dir.mkdir() report = build / 'horizon-client-report.json' report.unlink(missing_ok=True) server = Server(vanilla_server, directory, label) try: server.wait_for(r'Done \(') env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_NET_REPORT=str(report)) with (build / 'horizon-client-run.log').open('w') as log: subprocess.run([str(c) for c in [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--render-distance', 2, '--server', f'127.0.0.1:{port}', '--name', PLAYER]], cwd=client_dir, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=300, check=True) server.wait_for(r'left the game', timeout=30) finally: server.stop() got = json.loads(report.read_text()) move = next(l for l in reference if l.startswith('play-move')) want_position = [float(v) for v in re.match(r'play-move \(([^,]+), ([^,]+), ([^)]+)\)', move).groups()] want_chunks = int(next(l for l in reference if l.startswith('play-counts')).split()[-1]) expected = { 'connected throughout': None, 'spawn position': want_position, 'chunks received': want_chunks, 'own chat shown': True, '/list reply shown': True, 'server log': ['logged in', 'Tester joined the game', '[Not Secure] hello from the horizon client', 'Tester lost connection: Disconnected', 'Tester left the game'], } log = '\n'.join(server.lines) actual = { 'connected throughout': got['error'], 'spawn position': got['position'], 'chunks received': got['chunks'], 'own chat shown': any(' hello from the horizon client' in c for c in got['chat']), '/list reply shown': any('There are 1 of a max of 5 players online: Tester' in c for c in got['chat']), 'server log': [k for k in expected['server log'] if k in log], } return [f'horizon-client {k}: {"ok" if actual[k] == v else f"expected {v!r}, got {actual[k]!r}"}' for k, v in expected.items()] 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])) oh.run_logged(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-sourcepath', lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], build, 'compile-java') oh.run_logged(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], build, 'compile-rust') metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True)) binary = Path(metadata['target_directory']) / 'release' / ('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'] # The world, generated once by vanilla. generate = build / 'generate' write_server_dir(generate, free_port()) server = Server(vanilla_server, generate, 'generate') try: server.wait_for(r'Done \(') server.console('gamerule respawn_radius 0') server.wait_for(r'respawn_radius', timeout=30) # Vanilla keeps no spawn chunks: force-load the area the client is # sent and its neighbours (the console stands at the world spawn) so # both servers load full chunks instead of finishing proto-chunks. # Random ticks change blocks while the client is being sent its # chunks, more of them the busier the machine: none, so both servers # send the world as it was saved. server.console('gamerule random_tick_speed 0') server.wait_for(r'random_tick_speed', timeout=30) server.console('forceload add ~-80 ~-80 ~80 ~80') server.wait_for(r'chunks in the Overworld are now force loaded|forceloaded|force loaded', timeout=60) settle(server) server.console('forceload remove all') server.console('save-all flush') server.wait_for(r'Saved the game', timeout=120) finally: server.stop() world = generate / 'world' (world / 'playerdata').mkdir(exist_ok=True) def client(port, out): oh.run_logged(['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaProtocolClient', '127.0.0.1', port, PLAYER, out], build, out.stem, timeout=300) results = {} for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]: PROPERTIES_PORTS[label] = free_port() directory = build / label write_server_dir(directory, PROPERTIES_PORTS[label], world) results[label] = run_side(label, command, directory, client) (build / f'{label}.txt').write_text('\n'.join(results[label]) + '\n', encoding='utf-8') client_checks = horizon_client_joins(vanilla_server, binary, world, build, results['vanilla']) vanilla, horizon = results['vanilla'], results['horizon'] errors = [l for l in vanilla + horizon if l.startswith('error')] + [l for l in client_checks if not l.endswith(': ok')] for line in client_checks: print(line) differences = [] import difflib for line in difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=0, lineterm=''): differences.append(line[:400]) # Name the chunks that differ instead of the whole line. chunk_lines = [dict(re.findall(r'(-?\d+,-?\d+)=(\w+)', next((l for l in side if l.startswith('play-chunks')), ''))) for side in (vanilla, horizon)] for key in sorted(set(chunk_lines[0]) | set(chunk_lines[1])): a, b = chunk_lines[0].get(key), chunk_lines[1].get(key) if a != b: differences.append(f'chunk {key}: vanilla {a} horizon {b}') if differences or errors: raise AssertionError(f'{len([d for d in differences if d[:1] in "+-" and d[:3] not in ("+++", "---")])} differing lines ' f'(see {build}/vanilla.txt and horizon.txt):\n' + '\n'.join(differences[:80])) lab.write_json(report, dict( schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), scope='Dedicated server seen by a vanilla-codec client: status, legacy ping, login, configuration, play ' 'start (recipes, advancements, command tree), chunks, chat, /list, failing commands\' replies, ' 'console list/say/stop, files written. Offline mode.')) print(f'dedicated server: {len(vanilla)} transcript lines identical') if __name__ == '__main__': try: main() except AssertionError as error: sys.exit(f'{NAME}: {error}')