Files
RustNotMC/tools/server_pack_e2e.py
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

516 lines
24 KiB
Python

#!/usr/bin/env python3
"""Server resource packs end to end: a real Horizon client joins a real dedicated server (vanilla 26.3's, or
Horizon's with --horizon-server BIN) that offers a test pack from a local HTTP server, through tools/net_capture.py,
which records the statuses the client sends back.
The pack is built here: dirt recoloured blue, and three lang strings (dirt's name, the pause menu's "Back to Game",
the server list's "Join Server"). Each scenario sets the server list entry's Server Resource Packs setting in
servers.dat, joins, answers the prompt the way a player would (horizon-ctl clicks its buttons), and checks the
statuses against what vanilla's client sends (ServerPackManager):
- required, Prompt, Disconnect: no status, the disconnected screen says "This server requires a custom resource
pack", the setting stays Prompt;
- required, Prompt, Proceed: ACCEPTED, DOWNLOADED, SUCCESSFULLY_LOADED; the setting becomes Enabled, the file is in
downloads/ under its SHA-1 with a log.json entry, the HTTP request carries vanilla's X-Minecraft-* headers, the
blue dirt is on screen (screenshot) and the pause menu says the pack's "Back to Game"; after leaving, the server
list says "Join Server" again (the pack is gone);
- required, Enabled: no prompt, the same three statuses, and no second download (the cache);
- required with a wrong SHA-1: ACCEPTED, FAILED_DOWNLOAD, and the player still joins;
- optional, Prompt, No: DECLINED, joins, the setting becomes Disabled; then joining again sends DECLINED without
asking; then Prompt and Yes: the pack (no hash given) is downloaded and loaded;
- optional on a dead URL: ACCEPTED, FAILED_DOWNLOAD, joins.
python3 tools/server_pack_e2e.py # vanilla's server
python3 tools/server_pack_e2e.py --horizon-server target/release/horizon-client
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 target/fast).
"""
import argparse
import hashlib
import http.server
import json
import os
from pathlib import Path
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import zipfile
import zlib
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
from test_dedicated_server_parity import Server, free_port # noqa: E402
# ServerboundResourcePackPacket.Action, by ordinal.
ACTIONS = ['successfully_loaded', 'declined', 'failed_download', 'accepted', 'downloaded', 'invalid_url',
'failed_reload', 'discarded']
LOADED = ['accepted', 'downloaded', 'successfully_loaded']
FAILED = ['accepted', 'failed_download']
REQUIRED_REASON = 'This server requires a custom resource pack'
PLAYER = 'Steve'
PROPERTIES = {
'online-mode': 'false', 'white-list': 'false', 'level-seed': '12345', 'level-type': 'minecraft\\:flat',
'generate-structures': 'false', 'view-distance': '3', 'simulation-distance': '3', 'spawn-protection': '0',
'pause-when-empty-seconds': '-1', 'gamemode': 'creative',
}
class Failed(Exception):
pass
def check(condition, what):
if not condition:
raise Failed(what)
print(f' ok: {what}')
# The pack.
def png(width, height, pixel):
rows = b''.join(b'\0' + b''.join(bytes(pixel(x, y)) for x in range(width)) for y in range(height))
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
header = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', header) + chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')
def build_pack(path):
"""The test pack, byte for byte the same every time (fixed dates), and its SHA-1."""
def blue(x, y):
return (40, 90, 230, 255) if (x // 4 + y // 4) % 2 else (20, 50, 170, 255)
files = {
'pack.mcmeta': json.dumps({'pack': {'description': 'Horizon server pack test',
'min_format': 97, 'max_format': 97}}).encode(),
'assets/minecraft/textures/block/dirt.png': png(16, 16, blue),
'assets/minecraft/lang/en_us.json': json.dumps({
'block.minecraft.dirt': 'Blue Test Dirt',
'menu.returnToGame': 'Back to Game (server pack)',
'selectServer.select': 'Join Server (server pack)',
}).encode(),
}
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
for name, data in files.items():
z.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data)
return hashlib.sha1(path.read_bytes()).hexdigest()
def decode_png(data):
"""RGB(A) pixels of an 8-bit PNG: (width, height, rows of (r, g, b))."""
pos, idat, width = 8, b'', 0
while pos < len(data):
length, kind = struct.unpack('>I4s', data[pos:pos + 8])
body = data[pos + 8:pos + 8 + length]
if kind == b'IHDR':
width, height, depth, colour = struct.unpack('>IIBB', body[:10])
assert depth == 8 and colour in (2, 6), (depth, colour)
channels = 4 if colour == 6 else 3
elif kind == b'IDAT':
idat += body
pos += 12 + length
raw, stride, rows, previous = zlib.decompress(idat), width * channels, [], bytearray(width * channels)
for y in range(height):
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
for i in range(stride):
left = line[i - channels] if i >= channels else 0
up, corner = previous[i], previous[i - channels] if i >= channels else 0
if kind == 1:
line[i] = (line[i] + left) & 255
elif kind == 2:
line[i] = (line[i] + up) & 255
elif kind == 3:
line[i] = (line[i] + (left + up) // 2) & 255
elif kind == 4:
p = left + up - corner
pa, pb, pc = abs(p - left), abs(p - up), abs(p - corner)
line[i] = (line[i] + (left if pa <= pb and pa <= pc else up if pb <= pc else corner)) & 255
rows.append([tuple(line[x * channels:x * channels + 3]) for x in range(width)])
previous = line
return width, height, rows
def blue_share(path):
"""How much of the picture's lower middle (the ground under a player looking down) is blue."""
width, height, rows = decode_png(path.read_bytes())
samples = [rows[y][x] for y in range(height // 2 + height // 8, height - height // 8, 4)
for x in range(width // 4, 3 * width // 4, 4)]
return sum(b > r + 60 and b > g + 30 for r, g, b in samples) / len(samples)
# The HTTP server the pack comes from.
class Files(http.server.ThreadingHTTPServer):
def __init__(self, files):
self.files, self.requests = files, []
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(handler):
headers = {k: v for k, v in handler.headers.items() if k.lower().startswith(('x-minecraft', 'user-agent'))}
self.requests.append((handler.path, headers))
data = self.files.get(handler.path)
if data is None:
handler.send_error(404)
return
handler.send_response(200)
handler.send_header('Content-Type', 'application/zip')
handler.send_header('Content-Length', str(len(data)))
handler.end_headers()
handler.wfile.write(data)
def log_message(handler, *args):
pass
super().__init__(('127.0.0.1', 0), Handler)
threading.Thread(target=self.serve_forever, daemon=True).start()
def url(self, path):
return f'http://127.0.0.1:{self.server_address[1]}{path}'
# servers.dat (NbtIo.write: uncompressed, a named root compound).
def nbt_string(s):
data = s.encode()
return struct.pack('>H', len(data)) + data
def write_servers(path, ip, setting):
"""One entry for ip; setting True (Enabled), False (Disabled) or None (Prompt: no acceptTextures)."""
entry = b'\x08' + nbt_string('name') + nbt_string('Pack test') + b'\x08' + nbt_string('ip') + nbt_string(ip)
if setting is not None:
entry += b'\x01' + nbt_string('acceptTextures') + bytes([int(setting)])
root = b'\x09' + nbt_string('servers') + b'\x0a' + struct.pack('>i', 1) + entry + b'\x00'
path.write_bytes(b'\x0a' + nbt_string('') + root + b'\x00')
def read_setting(path):
data = path.read_bytes()
key = b'\x01' + nbt_string('acceptTextures')
at = data.find(key)
return None if at < 0 else bool(data[at + len(key)])
# The game.
class Game:
def __init__(self, bins, resources, work):
self.home = work / 'home'
for d in ('home', 'launcher', 'data', 'config'):
(work / d).mkdir(parents=True)
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')
self.base = [str(bins / 'horizon-ctl'), '--socket', str(work / 'c.sock')]
self.log = work / 'c.log'
reply = self('launch', '--game', bins / 'horizon-client', '--offscreen', '--resources', resources)
self.pid = reply.get('pid')
def __call__(self, *words, ok=True, timeout=300):
result = subprocess.run(self.base + [str(w) for w in words], cwd=self.home, env=self.env,
capture_output=True, text=True, timeout=timeout)
lines = result.stdout.strip().splitlines()
reply = json.loads(lines[-1]) if lines else {'error': result.stderr.strip()}
print(f' {" ".join(map(str, words))[:80]} -> {json.dumps(reply)[:200]}')
if ok and (result.returncode or 'error' in reply):
raise Failed(f'{" ".join(map(str, words))}: {reply}')
return reply
def labels(self):
return [w.get('label') for w in self('ui').get('widgets', [])]
def wait_widget(self, widget, timeout=60):
return self('wait-for', 'widget', widget, '--timeout', timeout, ok=False)
def quit(self):
try:
self('quit', ok=False, timeout=60)
except subprocess.TimeoutExpired:
pass
if self.pid:
for _ in range(40):
try:
os.kill(self.pid, 0)
except OSError:
return
time.sleep(0.5)
os.kill(self.pid, 9)
def statuses(capture, conn):
"""The resource pack statuses the client sent on one connection: (id, action), in order."""
out = []
for e in net_capture.load(capture):
if e.get('conn') == conn and e.get('dir') == 'c2s' and e.get('name') == 'resource_pack':
data = bytes.fromhex(e['data'])
out.append((data[:16].hex(), ACTIONS[data[16]]))
return out
def connections(capture):
return max([e['conn'] for e in net_capture.load(capture) if 'conn' in e], default=0)
class Run:
def __init__(self, args, out):
self.args, self.out = args, out
lock, jars, libraries = base.verify_artifacts()
deps = os.pathsep.join(map(str, [jars['server'], *libraries]))
self.vanilla = ['java', '-Xmx2g', '-cp', deps, 'net.minecraft.server.Main', 'nogui']
self.resources = lab.LOCAL / 'horizon-client' / lab.VERSION
self.files = {}
self.http = Files(self.files)
self.sha1 = build_pack(out / 'test-pack.zip')
self.files['/test-pack.zip'] = (out / 'test-pack.zip').read_bytes()
self.files['/optional-pack.zip'] = self.files['/test-pack.zip']
dead = free_port() # nothing listens there
self.packs = {
'required': {'resource-pack': self.http.url('/test-pack.zip'), 'resource-pack-sha1': self.sha1,
'require-resource-pack': 'true', 'resource-pack-id': '5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a',
'resource-pack-prompt': '["Blue dirt for ",{"text":"this test","color":"aqua"}]'},
'bad-sha1': {'resource-pack': self.http.url('/test-pack.zip'), 'resource-pack-sha1': '0' * 40,
'require-resource-pack': 'true'},
'optional': {'resource-pack': self.http.url('/optional-pack.zip'), 'require-resource-pack': 'false'},
'dead-url': {'resource-pack': f'http://127.0.0.1:{dead}/gone.zip', 'require-resource-pack': 'false'},
}
self.results = []
def start_server(self, kind, work):
directory = work / f'server-{kind}'
directory.mkdir()
port = free_port()
props = dict(PROPERTIES, **self.packs[kind], **{'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')
if self.args.horizon_server:
command = [self.args.horizon_server, '--resources', self.resources, '--dedicated', '--nogui']
else:
command = self.vanilla
server = Server(command, directory, f'server-{kind}')
server.wait_for(r'Done \(', timeout=300)
return server, port
def join(self, game, address, scenario):
"""Connects; returns the capture's connection number."""
game('connect', address, PLAYER)
deadline = time.time() + 30
while connections(self.capture) <= self.conn and time.time() < deadline:
time.sleep(0.2)
self.conn = connections(self.capture)
print(f' [{scenario}] connection {self.conn}')
return self.conn
def scenario(self, name, fn):
print(f'- {name}')
try:
fn()
self.results.append((name, 'pass', ''))
except Exception as e: # noqa: BLE001: report every scenario
print(f' FAILED: {e}')
self.results.append((name, 'FAIL', str(e)[:300]))
if self.game('state', ok=False).get('screen') not in (None, 'title'):
self.leave(self.game)
def wait_statuses(self, conn, want, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
got = [a for _, a in statuses(self.capture, conn)]
if len(got) >= len(want) and got[:len(want)] == want or got[:1] and got != want[:len(got)]:
break
time.sleep(0.3)
time.sleep(1.5) # nothing more comes after the last one
got = [a for _, a in statuses(self.capture, conn)]
check(got == want, f'statuses {got} == {want}')
def leave(self, game):
game('disconnect', ok=False)
time.sleep(1)
def run(self):
work = Path(tempfile.mkdtemp(prefix='hz-server-packs-'))
print(f'scratch: {work}')
# The servers too get scratch homes (the game sets its own below).
for var, sub in (('HORIZON_HOME', 'home'), ('HORIZON_LAUNCHER_HOME', 'launcher'), ('XDG_DATA_HOME', 'data'),
('XDG_CONFIG_HOME', 'config')):
(work / 'servers' / sub).mkdir(parents=True)
os.environ[var] = str(work / 'servers' / sub)
os.environ.update(lab.game_env(HORIZON_TELEMETRY_URL='off'))
game = self.game = Game(Path(self.args.bin), self.resources, work / 'game')
servers_dat = game.home / 'servers.dat'
downloads = game.home / 'downloads'
try:
for kind, scenarios in self.plan(game, servers_dat, downloads):
server, port = self.start_server(kind, work)
# One capture per server: its connections are numbered from 1.
self.capture, self.conn = self.out / f'capture-{kind}.jsonl', 0
self.capture.touch()
listen = free_port()
stop = net_capture.start_background(listen, f'127.0.0.1:{port}', self.capture)
self.address = f'127.0.0.1:{listen}'
try:
self.server = server
for name, fn in scenarios:
self.scenario(f'{kind}: {name}', fn)
finally:
stop.set()
server.stop()
shutil.copy(server.log_path, self.out / server.log_path.name)
finally:
game.quit()
if game.log.exists():
shutil.copy(game.log, self.out / 'client.log')
if downloads.exists():
shutil.copytree(downloads, self.out / 'downloads')
return work
def plan(self, game, servers_dat, downloads):
sha1 = self.sha1
def required_disconnect():
write_servers(servers_dat, self.address, None)
conn = self.join(game, self.address, 'decline')
check(game.wait_widget('disconnect').get('ok'), 'the required prompt opened')
labels = game.labels()
check('Proceed' in labels and 'Disconnect' in labels, f'Proceed and Disconnect buttons: {labels}')
game('screenshot', self.out / 'prompt-required.png')
game('click', 'disconnect')
self.wait_statuses(conn, [])
screen = json.dumps(game('screen'))
check(REQUIRED_REASON in screen, 'the disconnected screen says "This server requires a custom resource pack"')
check(read_setting(servers_dat) is None, 'the setting stays Prompt')
check(not any(p == '/test-pack.zip' for p, _ in self.http.requests), 'nothing was downloaded')
def required_proceed():
conn = self.join(game, self.address, 'proceed')
check(game.wait_widget('proceed').get('ok'), 'the required prompt opened')
game('click', 'proceed')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, LOADED)
check(read_setting(servers_dat) is True, 'the setting became Enabled (acceptTextures 1)')
pack_id = self.packs['required']['resource-pack-id']
check((downloads / pack_id / sha1).exists(), f'downloads/{pack_id}/{sha1} is cached')
log = json.loads((downloads / 'log.json').read_text().splitlines()[-1])
check(log.get('id') == pack_id and log.get('hash') == sha1
and log.get('file', {}).get('name') == f'{pack_id}/{sha1}', f'log.json entry {log}')
path, headers = [r for r in self.http.requests if r[0] == '/test-pack.zip'][-1]
want = {'X-Minecraft-Username': PLAYER, 'X-Minecraft-Version': lab.VERSION,
'X-Minecraft-Version-ID': lab.VERSION, 'X-Minecraft-Pack-Format': '97.1',
'User-Agent': f'Minecraft Java/{lab.VERSION}'}
check(all(headers.get(k) == v for k, v in want.items()) and len(headers.get('X-Minecraft-UUID', '')) == 32,
f'download headers {headers}')
self.server.console(f'execute at {PLAYER} run fill ~-6 ~-1 ~-6 ~6 ~-1 ~6 minecraft:dirt')
game('look', 0, 90)
game('wait', 40)
shot = self.out / 'applied-blue-dirt.png'
game('screenshot', shot)
share = blue_share(shot)
check(share > 0.5, f'the ground is the pack\'s blue dirt ({share:.0%} of the samples)')
game('key', 'esc', 'tap')
game('wait', 5)
labels = game.labels()
check('Back to Game (server pack)' in labels, f'the pause menu uses the pack\'s lang: {labels}')
game('key', 'esc', 'tap')
self.leave(game)
labels = game.labels()
check('Join Server' in labels and 'Join Server (server pack)' not in labels,
f'after leaving, the pack is gone: {labels}')
def required_enabled():
before = len(self.http.requests)
conn = self.join(game, self.address, 'enabled')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, LOADED)
check(len(self.http.requests) == before, 'no second download: the cached file is used')
game('key', 'esc', 'tap')
game('wait', 5)
check('Back to Game (server pack)' in game.labels(), 'the pack is applied without a prompt')
game('key', 'esc', 'tap')
self.leave(game)
def bad_sha1():
write_servers(servers_dat, self.address, True)
conn = self.join(game, self.address, 'bad-sha1')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, FAILED)
entries = [json.loads(line) for line in (downloads / 'log.json').read_text().splitlines()]
failed = [e for e in entries if e.get('hash') == '0' * 40]
check(failed and failed[-1].get('error') == 'download_failed' and 'file' not in failed[-1],
f'log.json records the failure: {failed[-1:]}')
self.leave(game)
def optional_no():
write_servers(servers_dat, self.address, None)
conn = self.join(game, self.address, 'optional-no')
check(game.wait_widget('no').get('ok'), 'the optional prompt opened')
labels = game.labels()
check('Yes' in labels and 'No' in labels, f'Yes and No buttons: {labels}')
game('screenshot', self.out / 'prompt-optional.png')
game('click', 'no')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, ['declined'])
check(read_setting(servers_dat) is False, 'the setting became Disabled (acceptTextures 0)')
self.leave(game)
def optional_disabled():
conn = self.join(game, self.address, 'optional-disabled')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, ['declined'])
self.leave(game)
def optional_yes():
write_servers(servers_dat, self.address, None)
conn = self.join(game, self.address, 'optional-yes')
check(game.wait_widget('yes').get('ok'), 'the optional prompt opened')
game('click', 'yes')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, LOADED)
check(any(r[0] == '/optional-pack.zip' for r in self.http.requests), 'downloaded without a hash')
self.leave(game)
def dead_url():
write_servers(servers_dat, self.address, True)
conn = self.join(game, self.address, 'dead-url')
game('wait-for', 'loaded', '--timeout', 180)
self.wait_statuses(conn, FAILED)
self.leave(game)
return [
('required', [('Disconnect at the prompt', required_disconnect), ('Proceed', required_proceed),
('Enabled, cached', required_enabled)]),
('bad-sha1', [('wrong SHA-1', bad_sha1)]),
('optional', [('No at the prompt', optional_no), ('Disabled', optional_disabled),
('Yes at the prompt, no hash', optional_yes)]),
('dead-url', [('dead URL', dead_url)]),
]
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('--horizon-server', help='a horizon-client binary to run as the dedicated server')
parser.add_argument('--out', default=str(lab.LOCAL / 'server-pack-e2e'))
args = parser.parse_args()
out = Path(args.out) / ('horizon' if args.horizon_server else 'vanilla')
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
run = Run(args, out)
work = run.run()
print(f'\nserver: {"horizon" if args.horizon_server else "vanilla 26.3"}; evidence in {out}')
for name, result, detail in run.results:
print(f' {result:4} {name}' + (f': {detail}' if detail else ''))
shutil.rmtree(work, ignore_errors=True)
if any(r != 'pass' for _, r, _ in run.results):
sys.exit(1)
if __name__ == '__main__':
main()