874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
293 lines
15 KiB
Python
293 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare the player list (tab list) with the vanilla 26.3 client.
|
|
|
|
The Java oracle (VanillaTabListOracle) runs vanilla's own
|
|
PlayerTabOverlay.extractRenderState, frame after frame, on a stand-in client
|
|
whose connection lists the case's players (game mode, latency, display name,
|
|
list order, hat layer, online mode for heads, skins from each profile's signed or
|
|
unsigned `textures` property through a real SkinManager), whose level holds some of
|
|
them (upside-down Dinnerbone and Grumm) and whose real Scoreboard has the
|
|
case's teams (colour, prefix, suffix), `list` objective (integer or hearts,
|
|
number formats) and scores, with the header, footer, row background option
|
|
and Hud ticks. It records every fill, text (as styled runs), sprite and
|
|
face blit. `examples/probe/tab_list_fixture` lays out the same frames with
|
|
`horizon_client::tab_list`; the ordered call lists must agree.
|
|
|
|
Downloaded skin images are compared too: VanillaSkinImageOracle decodes
|
|
PNGs as NativeImage does and runs SkinTextureDownloader.processLegacySkin;
|
|
`examples/probe/skin_image_fixture` runs `horizon_client::skins`. Pixels must agree.
|
|
|
|
test_tab_list_parity.py [--count N] [--detail N]
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import struct
|
|
import zlib
|
|
import json
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
|
|
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'
|
|
COLORS = ['black', 'dark_blue', 'dark_green', 'dark_aqua', 'dark_red', 'dark_purple', 'gold', 'gray', 'dark_gray',
|
|
'blue', 'green', 'aqua', 'red', 'light_purple', 'yellow', 'white']
|
|
WORDS = ['Welcome', 'to', 'the', 'server', 'Horizon', 'players', 'online', 'mc.example.net', 'Have', 'fun!',
|
|
'Supercalifragilisticexpialidocious', 'a', 'é', 'Ünïcödé', '§aGreen', '§lBold', '[VIP]', '1234567890']
|
|
NAMES = ['Steve', 'Alex', 'Notch', 'jeb_', 'Dinnerbone', 'Grumm', 'xX_Pro_Xx', 'a', 'Zed', 'zebra', 'Émile', 'ÆON',
|
|
'bob', 'Carl', '_under', '123', 'MMMMMMMMMMMMMMMM', 'iiiiiiii', 'Wide_W', 'ßtraße', 'Ⅻ', 'Mix3d_Case']
|
|
|
|
|
|
def component(rng, words=3, depth=0):
|
|
text = ' '.join(rng.choice(WORDS) for _ in range(words))
|
|
kind = rng.random()
|
|
if kind < 0.3:
|
|
return text
|
|
c = {'text': text}
|
|
if rng.random() < 0.5:
|
|
c['color'] = rng.choice(COLORS + ['#12ABEF'])
|
|
for flag in ('bold', 'italic', 'underlined', 'strikethrough', 'obfuscated'):
|
|
if rng.random() < 0.15:
|
|
c[flag] = rng.random() < 0.8
|
|
if depth < 2 and rng.random() < 0.4:
|
|
c['extra'] = [component(rng, rng.randint(1, 3), depth + 1) for _ in range(rng.randint(1, 2))]
|
|
if rng.random() < 0.08:
|
|
c = {'translate': 'multiplayer.player.list.hp', 'with': [text], 'color': rng.choice(COLORS)}
|
|
return c
|
|
|
|
|
|
def number_format(rng):
|
|
kind = rng.random()
|
|
if kind < 0.4:
|
|
return None
|
|
if kind < 0.55:
|
|
return {'type': 'blank'}
|
|
if kind < 0.85:
|
|
style = {'color': rng.choice(COLORS)}
|
|
if rng.random() < 0.4:
|
|
style['bold'] = True
|
|
return {'type': 'styled', 'style': style}
|
|
return {'type': 'fixed', 'value': component(rng, 1)}
|
|
|
|
|
|
TEXTURE_URIS = ['https://textures.minecraft.net/texture/{textureId}', 'http://textures.minecraft.net/texture/{textureId}']
|
|
|
|
|
|
def textures_property(rng, name, hashes):
|
|
"""A `textures` profile property: base64 JSON naming skin, cape and
|
|
elytra URLs (some outside the allowed domains or malformed), signed
|
|
validly, invalidly or not at all."""
|
|
def url():
|
|
h = rng.choice(hashes)
|
|
return rng.choice([
|
|
'http://textures.minecraft.net/texture/' + h, 'https://textures.minecraft.net/texture/' + h,
|
|
'http://textures.minecraft.net/texture/' + h + '.png', 'https://textures.minecraft.net/texture/' + h + '?v=2',
|
|
'https://evil.example.net/texture/' + h, 'http://textures.minecraft.net.evil/texture/' + h,
|
|
'http://textures.minecraft.net/texture/a/b/' + h + '#frag'])
|
|
textures = {}
|
|
if rng.random() < 0.9:
|
|
textures['SKIN'] = {'url': url()}
|
|
if rng.random() < 0.4:
|
|
textures['SKIN']['metadata'] = {'model': 'slim'}
|
|
if rng.random() < 0.25:
|
|
textures['CAPE'] = {'url': url()}
|
|
if rng.random() < 0.08:
|
|
textures['ELYTRA'] = {'url': url()}
|
|
if rng.random() < 0.03:
|
|
textures['SKIN'] = {}
|
|
payload = {'timestamp': rng.randint(0, 2**41), 'profileId': uuid.UUID(int=rng.getrandbits(128)).hex,
|
|
'profileName': name, 'textures': textures}
|
|
if rng.random() < 0.2:
|
|
payload['signatureRequired'] = True
|
|
text = rng.choice([json.dumps(payload)] * 12 + ['not json', 'null', '', '{"textures": {}}', '[1, 2]'])
|
|
value = base64.b64encode(text.encode()).decode()
|
|
kind = rng.random()
|
|
if kind < 0.05:
|
|
value = value.rstrip('=')
|
|
elif kind < 0.08:
|
|
value = rng.choice(['%%%', 'abc', 'YQ==YQ==', value + '!'])
|
|
return {'value': value, 'signature': rng.choice([None, 'valid', 'valid', 'invalid'])}
|
|
|
|
|
|
def cases(count):
|
|
rng = random.Random(0x544142)
|
|
out = []
|
|
for n in range(count):
|
|
size = rng.choice([1, 1, 2, 3, 5, 8, 12, 19, 20, 21, 25, 40, 41, 60, 79, 80, 85, 100])
|
|
names = []
|
|
while len(names) < size:
|
|
name = rng.choice(NAMES) if rng.random() < 0.5 else ''.join(
|
|
rng.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_') for _ in range(rng.randint(1, 16)))
|
|
if name.lower() not in {x.lower() for x in names}:
|
|
names.append(name)
|
|
hashes = [''.join(rng.choice('0123456789abcdef') for _ in range(64)) for _ in range(rng.randint(1, 12))]
|
|
players = []
|
|
for name in names:
|
|
players.append({
|
|
'uuid': str(uuid.UUID(int=rng.getrandbits(128))), 'name': name,
|
|
'gameMode': rng.choice([0, 0, 0, 1, 2, 3]),
|
|
'latency': rng.choice([-1, 0, 1, 149, 150, 299, 300, 599, 600, 999, 1000, 5000, rng.randint(0, 2000)]),
|
|
'displayName': component(rng, rng.randint(1, 2)) if rng.random() < 0.2 else None,
|
|
'listOrder': rng.choice([0, 0, 0, 1, -1, 5, 2147483647, -2147483648]),
|
|
'showHat': rng.random() < 0.8, 'inLevel': rng.random() < 0.5,
|
|
'textures': textures_property(rng, name, hashes) if rng.random() < 0.75 else None,
|
|
})
|
|
downloads = {h: rng.choice(['ok'] * 20 + ['fail'] * 3 + ['pending']) for h in hashes if rng.random() < 0.9}
|
|
teams = []
|
|
for t in range(rng.choice([0, 0, 1, 2, 4])):
|
|
members = [p['name'] for p in players if rng.random() < 0.3]
|
|
teams.append({'name': rng.choice(['red', 'Blue', 'team' + str(t), 'ä', 'z', 'A']) + str(t),
|
|
'color': rng.choice([None] + COLORS),
|
|
'prefix': rng.choice(['', '[R] ', {'text': '★ ', 'color': 'gold', 'bold': True}, component(rng, 1)]),
|
|
'suffix': rng.choice(['', ' ✔', {'text': ' (afk)', 'italic': True}]), 'members': members})
|
|
objective = None
|
|
if rng.random() < 0.7:
|
|
objective = {'name': 'obj', 'renderType': rng.choice(['integer', 'hearts', 'hearts']), 'numberFormat': number_format(rng)}
|
|
frames = []
|
|
tick = rng.randint(0, 100000)
|
|
values = {}
|
|
for f in range(rng.randint(1, 6)):
|
|
scores = {}
|
|
for p in players:
|
|
if objective and (f == 0 and rng.random() < 0.85 or rng.random() < 0.3):
|
|
v = rng.choice([0, 1, 2, 7, 19, 20, 21, 40, 99, 100, -3, 1234567, rng.randint(-50, 300)])
|
|
if p['name'] in values and rng.random() < 0.5:
|
|
v = values[p['name']] + rng.choice([-3, -1, 1, 2, 10])
|
|
values[p['name']] = v
|
|
scores[p['name']] = {'value': v, 'format': number_format(rng) if rng.random() < 0.2 else None}
|
|
reset = [p['name'] for p in players if objective and rng.random() < 0.03]
|
|
for r in reset:
|
|
values.pop(r, None)
|
|
tick += rng.choice([1, 1, 2, 3, 5, 10, 21, 40])
|
|
frames.append({'tick': tick, 'visible': rng.random() < 0.85, 'scores': scores, 'reset': reset})
|
|
header = footer = None
|
|
if rng.random() < 0.6:
|
|
header = rng.choice([component(rng, rng.randint(1, 30)), 'Line one\nLine two', '\n', 'trailing\n', ' two spaces ',
|
|
{'text': 'Header', 'extra': ['\n', component(rng, 12)]}])
|
|
if rng.random() < 0.5:
|
|
footer = rng.choice([component(rng, rng.randint(1, 30)), 'Footer', 'x\n\ny'])
|
|
out.append({'width': rng.choice([427, 320, 480, 640, 961, 160, 90, 300]), 'onlineMode': rng.random() < 0.7,
|
|
'backgroundForChatOnly': rng.random() < 0.8, 'opacity': rng.choice([0.5, 0.0, 1.0, 0.37]),
|
|
'header': header, 'footer': footer, 'teams': teams, 'objective': objective, 'players': players,
|
|
'frames': frames, 'downloads': downloads,
|
|
'local': rng.choice(players)['uuid'] if rng.random() < 0.3 else None,
|
|
'validUris': rng.choice([TEXTURE_URIS] * 6 + [TEXTURE_URIS[:1], []])})
|
|
return out
|
|
|
|
|
|
def png(width, height, color_type, depth, rows):
|
|
"""A PNG of the given colour type (0 grey, 2 RGB, 4 grey+alpha, 6 RGBA)."""
|
|
def chunk(kind, data):
|
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
|
raw = b''.join(b'\0' + row for row in rows)
|
|
return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, depth, color_type, 0, 0, 0))
|
|
+ chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b''))
|
|
|
|
|
|
def skin_images(count):
|
|
"""Downloaded skins: 64x32 legacy and 64x64 ones in every 8-bit colour
|
|
type (and 16-bit RGBA), with opaque, clear and mixed hat layers, and a
|
|
few sizes vanilla refuses."""
|
|
rng = random.Random(0x534B494E)
|
|
out = []
|
|
for _ in range(count):
|
|
width, height = rng.choice([(64, 32)] * 5 + [(64, 64)] * 5 + [(32, 32), (64, 48), (128, 64), (64, 128), (1, 1)])
|
|
color_type, depth = rng.choice([(6, 8)] * 6 + [(2, 8), (0, 8), (4, 8), (6, 16)])
|
|
channels = {0: 1, 2: 3, 4: 2, 6: 4}[color_type]
|
|
alpha = rng.choice(['opaque', 'clear', 'mixed', 'random'])
|
|
rows = []
|
|
for y in range(height):
|
|
row = bytearray()
|
|
for x in range(width):
|
|
px = [rng.randrange(256) for _ in range(channels)]
|
|
if color_type in (4, 6):
|
|
a = {'opaque': rng.randint(128, 255), 'clear': 0, 'mixed': rng.choice([0, 255, 127, 128]),
|
|
'random': rng.randrange(256)}[alpha]
|
|
px[-1] = a
|
|
for v in px:
|
|
row += bytes([v, rng.randrange(256)]) if depth == 16 else bytes([v])
|
|
rows.append(bytes(row))
|
|
out.append(base64.b64encode(png(width, height, color_type, depth, rows)).decode())
|
|
return out
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--count', type=int, default=1000)
|
|
parser.add_argument('--detail', type=int, default=3)
|
|
parser.add_argument('--images', type=int, default=400)
|
|
args = parser.parse_args()
|
|
lock, jars, _ = base.verify_artifacts()
|
|
libraries = baking.client_libraries()
|
|
build = lab.LOCAL / 'rust-tab-list-parity'; build.mkdir(parents=True, exist_ok=True)
|
|
resources = lab.RESOURCES
|
|
chosen = cases(args.count)
|
|
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', 'ParityFont', 'VanillaTabListOracle', 'VanillaSkinImageOracle'))], 'compile-java')
|
|
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaTabListOracle',
|
|
inputs, build / 'vanilla.jsonl', resources / 'assets'], 'vanilla')
|
|
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'client_probe', '--',
|
|
'tab_list_fixture', resources, inputs, build / 'rust.jsonl'], 'rust')
|
|
images = build / 'skins.txt'; images.write_text(''.join(i + '\n' for i in skin_images(args.images)))
|
|
run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaSkinImageOracle',
|
|
images, build / 'vanilla-skins.txt'], 'vanilla-skins')
|
|
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', 'client_probe', '--',
|
|
'skin_image_fixture', images, build / 'rust-skins.txt'], 'rust-skins')
|
|
vanilla_skins = (build / 'vanilla-skins.txt').read_text().splitlines()
|
|
rust_skins = (build / 'rust-skins.txt').read_text().splitlines()
|
|
assert len(vanilla_skins) == len(rust_skins) == args.images
|
|
skin_bad = [i for i, (a, b) in enumerate(zip(vanilla_skins, rust_skins)) if a != b]
|
|
for i in skin_bad[:args.detail]:
|
|
a, b = vanilla_skins[i], rust_skins[i]
|
|
if 'refused' in (a, b):
|
|
print(f'SKIN MISMATCH image {i}: vanilla {a[:20]}, horizon {b[:20]}')
|
|
else:
|
|
a, b = base64.b64decode(a), base64.b64decode(b)
|
|
k = next(k for k in range(min(len(a), len(b))) if a[k] != b[k]) // 4
|
|
print(f'SKIN MISMATCH image {i}: pixel ({k % 64}, {k // 64}) vanilla {a[k*4:k*4+4].hex()} horizon {b[k*4:k*4+4].hex()}')
|
|
print(f'{args.images - len(skin_bad)}/{args.images} processed skin images match '
|
|
f'({sum(v == "refused" for v in vanilla_skins)} refused)')
|
|
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text().splitlines()]
|
|
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text().splitlines()]
|
|
assert len(vanilla) == len(rust)
|
|
errors, bad, frames, calls = [], [], 0, 0
|
|
for v, r in zip(vanilla, rust):
|
|
if 'error' in v:
|
|
errors.append(v['error']); continue
|
|
for i, (a, b) in enumerate(zip(v['frames'], r['frames'])):
|
|
frames += 1
|
|
calls += len(a)
|
|
if a != b:
|
|
first = next((k for k, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b)))
|
|
bad.append((v['case'], i, first, a[first:first + 2], b[first:first + 2], len(a), len(b)))
|
|
break
|
|
for e in sorted(set(errors))[:5]:
|
|
print('ORACLE ERROR', e)
|
|
for case, i, k, a, b, la, lb in bad[:args.detail]:
|
|
print('MISMATCH', json.dumps(case)[:3000])
|
|
print(f' frame {i}, call {k} ({la} vanilla, {lb} Horizon calls)\n vanilla {a}\n horizon {b}')
|
|
print(f'{len(vanilla) - len(bad) - len(errors)}/{len(vanilla)} player lists match ({frames} frames, {calls} calls); '
|
|
f'{len(errors)} oracle errors')
|
|
if bad or errors or skin_bad:
|
|
sys.exit(1)
|
|
print('tab list parity ok')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|