19d4146f04
Profiles' textures properties are unpacked as authlib does and looked up as SkinManager does (signatures, the local player, allowed texture URLs, downloads), skins are fetched and processed as SkinTextureDownloader does and shown from atlas slots. The tab_list harness now runs a real SkinManager and compares processLegacySkin over 400 PNGs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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/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/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.LOCAL / 'horizon-client' / '26.3'
|
|
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'], 'vanilla')
|
|
run(['cargo', 'run', '--quiet', '--profile', 'fast', '-p', 'horizon-client', '--example', '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', '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()
|