eec52f9055
horizon_client::recipe_book_screen lays out RecipeBookComponent's drawing (background, search EditBox, tab buttons, the filter CycleButton, the page of RecipeButtons with its number and arrows, the alternatives overlay, tooltips) and GhostSlots as vanilla's GUI calls with their poses, and the game paints them. The new harness builds vanilla's widgets for 600 random states (VanillaRecipeBookScreenOracle) and compares every call: 600/600. Ingredients keep their HolderSet order (tags in TagLoader order), which is how the overlay and ghost slots cycle; the book centres over the menu on narrow screens as widthTooNarrow does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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/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()
|