#!/usr/bin/env python3 """Build native menu layouts and the runtime palette from ui/admin_menu sources.""" import argparse import json from pathlib import Path import re import sys ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / 'ui/admin_menu' LAYOUTS = Path('addons/core/UI/layouts/ERR') PALETTE = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_MenuTheme.c') PAGES = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuPages.c') TOKEN = re.compile(r'\{\{([^{}]+)\}\}') def navigation(source, tokens): config = json.loads((source / 'navigation.json').read_text(encoding='utf-8')) pages, groups = config['pages'], config['groups'] if [p['id'] for p in pages] != list(range(len(pages))) or not 1 <= len(groups) <= 8: raise ValueError('Page IDs must be consecutive and main navigation supports 1-8 groups') for field in ('key', 'widget', 'button'): values = [p[field] for p in pages] if len(set(values)) != len(values) or any(not re.fullmatch(r'[A-Za-z][A-Za-z_0-9]*', v) for v in values): raise ValueError(f'Duplicate or invalid navigation {field}') keys = {p['key']: p for p in pages} members = [key for group in groups for key in group['pages']] names = [g['name'] for g in groups] if (len(members) != len(set(members)) or set(members) != keys.keys() or len(set(names)) != len(names) or any(not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', n) for n in names) or any(not 1 <= len(g['pages']) <= 8 for g in groups)): raise ValueError('Every page must belong to exactly one valid sidebar group') for page in pages: if not isinstance(page['label'], str) or any(c in page['label'] for c in '\n\r"\\'): raise ValueError('Invalid navigation label') def slot(x, y, w, h): return f'Slot FrameWidgetSlot {{ Anchor 0 0 0 0 PositionX {x} OffsetLeft {x} PositionY {y} OffsetTop {y} SizeX {w} OffsetRight {-x-w} SizeY {h} OffsetBottom {-y-h} }}' def button(name, label, x, y, w, selected=False): return (f'ButtonWidgetClass : "{{75C912A1C89BE6C2}}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {{ Name "{name}" {slot(x,y,w,42)} ' f'components {{ SCR_ButtonTextComponent "{{5548F0143AE99095}}" {{ m_sText "{label}" }} }} }}\n' f'ImageWidgetClass {{ "Is Visible" {int(selected)} Name "Selected_{name}" {slot(x,y+44,w,3)} Color {tokens["COLOR_ACCENT"]} }}\n') layout = '' assignments = {} for index, group in enumerate(groups): name = group['name'] initial = 'MISSION' in group['pages'] layout += button('Main_' + name, name, 28 + index * 146, 106, 138, initial) layout += f'FrameWidgetClass {{ "Is Visible" {int(initial)} Name "Sidebar_{name}" {slot(28,166,180,480)} {{\n' for row, key in enumerate(group['pages']): page = keys[key] assignments[key] = index layout += button(page['button'], page['label'], 0, row * 54, 180, key == 'MISSION') layout += '} }\n' def strings(method, values): return f' static array {method}() {{ return {{' + ', '.join('"' + v + '"' for v in values) + '}; }\n' script = '// Generated by tools/build_admin_menu.py; edit ui/admin_menu/navigation.json.\n' script += 'enum ERR_AdminPage\n{\n' + ''.join(f' {p["key"]} = {p["id"]},\n' for p in pages) + '}\n\n' script += 'class ERR_AdminMenuPages\n{\n' script += strings('Pages', [p['widget'] for p in pages]) script += strings('Navigation', [p['button'] for p in pages]) script += strings('MainButtons', ['Main_' + n for n in names]) script += strings('Sidebars', ['Sidebar_' + n for n in names]) script += ' static int Group(int page)\n {\n array groups = {' + ', '.join(str(assignments[p['key']]) for p in pages) + '};\n if (page < 0 || page >= groups.Count()) return -1;\n return groups[page];\n }\n' script += ' static int MainPage(int group)\n {\n array pages = {' + ', '.join('ERR_AdminPage.' + g['pages'][0] for g in groups) + '};\n if (group < 0 || group >= pages.Count()) return -1;\n return pages[group];\n }\n}\n' visibility = {'VISIBLE_' + p['widget']: str(int(p['key'] == 'MISSION')) for p in pages} return layout, script, visibility def source_file(source, name): path = (source / name).resolve() if not path.is_relative_to(source.resolve()) or not path.is_file(): raise ValueError(f'Invalid menu source: {name}') return path def expand(source, name, tokens, stack=()): path = source_file(source, name) if path in stack: raise ValueError(f'Cyclic layout include: {name}') def substitute(match): key = match[1] if key.startswith('include:'): return expand(source, key[len('include:'):], tokens, (*stack, path)).rstrip('\n') if key not in tokens: raise ValueError(f'Unknown theme token {key} in {name}') return tokens[key] return TOKEN.sub(substitute, path.read_text(encoding='utf-8')) def render(source=SOURCE): theme = json.loads((source / 'theme.json').read_text(encoding='utf-8')) tokens, colors = theme['layout'], theme['buttons'] for name, value in tokens.items(): if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str): raise ValueError(f'Invalid theme token: {name}') if name == 'FONT': valid = re.fullmatch(r'\{[A-Fa-f0-9]{16}\}[A-Za-z0-9_/.-]+\.fnt', value) elif name.startswith('SIZE_'): valid = value.isascii() and value.isdigit() and 8 <= int(value) <= 96 elif name.startswith('COLOR_'): parts = value.split() valid = len(parts) == 4 and all(re.fullmatch(r'\d+(?:\.\d+)?', p) and 0 <= float(p) <= 1 for p in parts) else: valid = False if not valid: raise ValueError(f'Invalid theme value: {name}') for name, value in colors.items(): if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str) or not re.fullmatch(r'0x[0-9A-F]{8}', value): raise ValueError(f'Invalid runtime color: {name}') required = {'NEUTRAL', 'NEUTRAL_HOVER', 'POSITIVE', 'POSITIVE_HOVER', 'NEGATIVE', 'NEGATIVE_HOVER', 'MIXED', 'READY_TEXT', 'UNAVAILABLE_TEXT'} if not required <= colors.keys(): raise ValueError('Missing runtime palette colors') nav_layout, nav_script, visibility = navigation(source, tokens) tokens = {**tokens, **visibility, 'NAVIGATION': nav_layout} manifest = json.loads((source / 'layouts.json').read_text(encoding='utf-8')) outputs = {} for output, template in manifest.items(): if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*\.layout', output): raise ValueError(f'Invalid layout output: {output}') outputs[LAYOUTS / output] = expand(source, template, tokens) outputs[PALETTE] = ('// Generated by tools/build_admin_menu.py; edit ui/admin_menu/theme.json.\n' 'class ERR_MenuTheme\n{\n' + ''.join( f' static const int {name} = {value};\n' for name, value in colors.items()) + '}\n') outputs[PAGES] = nav_script return outputs def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--check', action='store_true', help='Fail on stale output without changing files') args = parser.parse_args() try: outputs = render() # Validate everything before writing any output. stale = [path for path, content in outputs.items() if not (ROOT / path).exists() or (ROOT / path).read_text(encoding='utf-8') != content] if args.check: if stale: print('Stale generated menu files; run python3 tools/build_admin_menu.py:', file=sys.stderr) for path in stale: print(path, file=sys.stderr) return 1 print('Menu layouts and palette are up to date.') else: for path in stale: (ROOT / path).write_text(outputs[path], encoding='utf-8') print(f'Updated {len(stale)} menu files.') except (OSError, ValueError, KeyError, TypeError) as error: print(f'Cannot build admin menu: {error}', file=sys.stderr) return 1 return 0 if __name__ == '__main__': sys.exit(main())