874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
166 lines
8.6 KiB
Python
166 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare the recipe book's drawing with the vanilla client's.
|
|
|
|
Each case opens a book type (the inventory's or a crafting table's crafting
|
|
book, the furnace, blast furnace or smoker) at a GUI size, with its visible
|
|
tabs (one selected, some in their new-recipe squeeze), the search box (text,
|
|
cursor, selection, focus and cursor blink), the craftable filter, a page of
|
|
recipe collections (real recipes, some craftable, some buttons squeezing),
|
|
the alternatives overlay of one button, ghost slots, the cycling index and
|
|
the mouse. The Java oracle (VanillaRecipeBookScreenOracle) builds vanilla's
|
|
widgets for that state as RecipeBookComponent.initVisuals and
|
|
RecipeBookPage.init do and records every GuiGraphicsExtractor call of their
|
|
extractRenderState, the tooltips and GhostSlots.extractRenderState, with its
|
|
pose; examples/probe/recipe_book_render_fixture lays out the same state with
|
|
horizon_client::recipe_book_screen, which the game paints. The call lists
|
|
must agree.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
try:
|
|
from . import interaction_lab as lib, test_model_baking_parity as baking, test_rust_parity as base
|
|
except ImportError:
|
|
import interaction_lab as lib
|
|
import test_model_baking_parity as baking
|
|
import test_rust_parity as base
|
|
|
|
TABS = {'crafting': 5, 'furnace': 4, 'blast_furnace': 3, 'smoker': 2}
|
|
TYPES = {'crafting': ('minecraft:crafting_shaped', 'minecraft:crafting_shapeless'), 'furnace': ('minecraft:smelting',),
|
|
'blast_furnace': ('minecraft:blasting',), 'smoker': ('minecraft:smoking',)}
|
|
WORDS = ['', '', 'a', 'stone', 'iron ingot', 'oak', 'zzz', 'red', 'a very long search text that overflows']
|
|
|
|
|
|
def recipes_by_type():
|
|
out = {k: [] for k in TYPES}
|
|
for path in sorted((lib.RESOURCES / 'data/minecraft/recipe').glob('*.json')):
|
|
kind = json.loads(path.read_text()).get('type')
|
|
for k, types in TYPES.items():
|
|
if kind in types:
|
|
out[k].append('minecraft:' + path.stem)
|
|
return out
|
|
|
|
|
|
def cases(count, seed):
|
|
rng = random.Random(seed)
|
|
by_type = recipes_by_type()
|
|
items = sorted(i for i in json.loads((lib.RESOURCES / 'items.json').read_text()) if i != 'minecraft:air')
|
|
out = []
|
|
for n in range(count):
|
|
kind = ['crafting', 'crafting', 'furnace', 'blast_furnace', 'smoker'][n % 5]
|
|
width, height = rng.choice([(427, 240), (480, 270), (640, 360), (320, 240), (378, 260), (854, 480)])
|
|
narrow = width < 379
|
|
xo = (width - 147) // 2 - (0 if narrow else 86)
|
|
yo = (height - 166) // 2
|
|
tabs = [0] + sorted(rng.sample(range(1, TABS[kind]), rng.randint(0, TABS[kind] - 1)))
|
|
selected = rng.choice(tabs)
|
|
tab_list = [{'tab': t, 'selected': t == selected, 'anim': rng.choice([0.0, 0.0, 0.0, round(rng.uniform(0.1, 15), 3)])}
|
|
for t in tabs]
|
|
total = rng.choice([0, 1, 5, 19, 20, 21, 47, 80])
|
|
pages = (total + 19) // 20
|
|
page = rng.randrange(pages) if pages else 0
|
|
filtering = rng.random() < 0.4
|
|
collections = []
|
|
for _ in range(total):
|
|
group = rng.sample(by_type[kind], rng.choice([1, 1, 1, 2, 3, 6]))
|
|
recipes = [[r, rng.random() < 0.4] for r in group]
|
|
if filtering and not any(c for _, c in recipes):
|
|
recipes[0][1] = True
|
|
collections.append({'recipes': recipes})
|
|
on_page = max(0, min(20, total - 20 * page))
|
|
value = rng.choice(WORDS)
|
|
cursor = rng.randint(0, len(value))
|
|
highlight = rng.choice([cursor, rng.randint(0, len(value))])
|
|
search = {'value': value, 'cursor': cursor, 'highlight': highlight,
|
|
'display': rng.choice([0, 0, min(cursor, rng.randint(0, len(value)))]), 'focused': rng.random() < 0.5,
|
|
'blink': rng.choice([0, 150, 450, 700])}
|
|
targets = [(xo + 11 + 25 * (i % 5) + rng.randint(0, 24), yo + 31 + 25 * (i // 5) + rng.randint(0, 24)) for i in range(on_page)]
|
|
targets += [(xo + 115, yo + 20), (xo + 95, yo + 140), (xo + 40, yo + 140), (xo + 30, yo + 18), (xo - 20, yo + 40),
|
|
(rng.randint(0, width), rng.randint(0, height))]
|
|
case = {'type': kind, 'grid': 2 if rng.random() < 0.5 else 3, 'width': width, 'height': height, 'narrow': narrow,
|
|
'mouse': list(rng.choice(targets)), 'index': rng.choice([0, 0, 1, 2, 5, 13, 60]), 'filtering': filtering,
|
|
'tabs': tab_list, 'search': search, 'collections': collections, 'page': page,
|
|
'buttonAnims': [rng.choice([0.0, 0.0, 0.0, round(rng.uniform(0.1, 15), 3)]) for _ in range(on_page)]}
|
|
multi = [i for i in range(on_page) if sum(1 for _, c in collections[20 * page + i]['recipes'] if c or not filtering) > 1]
|
|
if multi and rng.random() < 0.4:
|
|
case['overlay'] = rng.choice(multi)
|
|
if rng.random() < 0.4:
|
|
left, top = (width - 176) // 2, (height - 166) // 2
|
|
slots = [(124, 35, True)] + [(30 + 18 * (i % 3), 17 + 18 * (i // 3), False) for i in range(9)]
|
|
if kind != 'crafting':
|
|
slots = [(116, 35, True), (56, 17, False)]
|
|
case.update({'left': left, 'top': top, 'big': rng.random() < 0.7,
|
|
'ghost': [{'x': x, 'y': y, 'result': r, 'items': rng.sample(items, rng.choice([1, 1, 2, 4]))}
|
|
for x, y, r in rng.sample(slots, rng.randint(1, len(slots)))]})
|
|
out.append(case)
|
|
return out
|
|
|
|
|
|
def same(a, b):
|
|
"""Calls agree: every field exactly, poses within float printing."""
|
|
if a is None or b is None or set(a) != set(b):
|
|
return False
|
|
return all(all(abs(x - y) <= 1e-3 for x, y in zip(a[k], b[k])) if k == 'm' else a[k] == b[k] for k in a)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--seed', type=int, default=29)
|
|
parser.add_argument('--cases', type=int, default=600)
|
|
parser.add_argument('--limit', type=int, default=3)
|
|
args = parser.parse_args()
|
|
lock, jars, _ = base.verify_artifacts()
|
|
work = lib.work_dir('recipe-book-render-parity')
|
|
classes = work / 'classes'
|
|
classes.mkdir(exist_ok=True)
|
|
deps = os.pathsep.join(map(str, [jars['client'], *baking.client_libraries()]))
|
|
done = subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-cp', deps,
|
|
'-d', str(classes), *(str(lib.PARITY / f'{n}.java') for n in ('ParityData', 'ParityFont', 'VanillaRecipeBookScreenOracle'))],
|
|
capture_output=True, text=True)
|
|
if done.returncode:
|
|
print(done.stderr[-4000:])
|
|
sys.exit('javac failed')
|
|
chosen = cases(args.cases, args.seed)
|
|
inputs = work / 'cases.jsonl'
|
|
inputs.write_text(''.join(json.dumps(c) + '\n' for c in chosen))
|
|
run = subprocess.run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + deps, 'dev.horizonlab.parity.VanillaRecipeBookScreenOracle',
|
|
str(inputs), str(work / 'vanilla.jsonl')], capture_output=True, text=True)
|
|
if run.returncode:
|
|
print(run.stderr[-4000:])
|
|
sys.exit('oracle failed')
|
|
lib.cargo_example('horizon-client', 'recipe_book_render_fixture', lib.RESOURCES, inputs, work / 'rust.jsonl')
|
|
vanilla = [json.loads(l) for l in (work / 'vanilla.jsonl').read_text().splitlines()]
|
|
rust = [json.loads(l) for l in (work / 'rust.jsonl').read_text().splitlines()]
|
|
assert len(vanilla) == len(rust) == len(chosen), (len(vanilla), len(rust), len(chosen))
|
|
errors, failures, passed = {}, {}, 0
|
|
for case, v, r in zip(chosen, vanilla, rust):
|
|
if 'error' in v:
|
|
errors.setdefault(case['type'], v['error'])
|
|
continue
|
|
a, b = v['draws'], r['draws']
|
|
bad = next((i for i in range(max(len(a), len(b))) if i >= len(a) or i >= len(b) or not same(a[i], b[i])), None)
|
|
if bad is None:
|
|
passed += 1
|
|
else:
|
|
failures.setdefault(case['type'], []).append((case, bad, a[bad] if bad < len(a) else None, b[bad] if bad < len(b) else None))
|
|
for kind, error in sorted(errors.items()):
|
|
print(f'ORACLE ERROR {kind}: {error[:1500]}')
|
|
for kind, items in sorted(failures.items()):
|
|
print(f'MISMATCH {kind}: {len(items)} cases')
|
|
for case, i, x, y in items[:args.limit]:
|
|
brief = {k: v for k, v in case.items() if k not in ('collections',)}
|
|
print(f' {json.dumps(brief)[:700]}')
|
|
print(f' call {i}: vanilla {json.dumps(x)}')
|
|
print(f' call {i}: horizon {json.dumps(y)}')
|
|
print(f'{passed}/{len(chosen)} cases match')
|
|
if errors or failures:
|
|
sys.exit(1)
|
|
print('recipe book render parity ok')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|