Files
horizon-airdef/tools/test_command_exec_parity.py
T
Ludovic Marie 2ea1c273dd Run /item modify, /summon stacks and generic attributes as vanilla
Inline and registered item modifiers now apply (26.3's type-dispatched
functions, set_lore, set_custom_data, set_components, toggle_tooltips,
filtered), counting every set slot like CountingModifier. Item stacks
keep components Horizon does not model, display names follow
ItemStack.getDisplayName, /data prints compounds in CompoundTag's
HashMap order, summoned passengers snap to the position, and living
entities other than the player get vanilla attribute maps (waypoint
transmit range included, tracked as ServerWaypointManager does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:52:59 +02:00

159 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Compare Horizon's command execution with vanilla's real command implementations.
Java (VanillaCommandExecOracle) starts a stand-in integrated server: a real
MinecraftServer with a generated world, Commands.CommandSelection.INTEGRATED
and one single-player owner named Player. Rust runs the same script through
the Horizon client headless (HORIZON_COMMAND_SCRIPT). Both run each command as
the player through Commands.performPrefixedCommand and record every system or
chat message the player receives (text and resolved style: colour, bold,
italic, underlined, strikethrough, obfuscated) together with the results the
command reported to the player's CommandResultCallback. World, entity and item
changes are observed through probe commands whose feedback reports them
(execute if, clear with a zero count, data get, scoreboard queries...).
Messages that depend on time or a random UUID are compared after masking.
"""
import argparse
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
try:
from . import lab, test_rust_parity as base
from . import command_exec_corpus as corpus_module
except ImportError:
import lab
import test_rust_parity as base
import command_exec_corpus as corpus_module
SEED = 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true')
parser.add_argument('--limit', type=int, default=40, help='print at most this many mismatches')
parser.add_argument('--allow-gaps', action='store_true', help='report mismatches without failing')
parser.add_argument('--only', help='run only corpus groups whose name contains this')
args = parser.parse_args()
build = lab.LOCAL / 'command-exec-parity'
build.mkdir(parents=True, exist_ok=True)
report_path = build / 'report.json'
report_path.unlink(missing_ok=True)
if args.prepare:
lab.prepare()
lock, jars, libraries = base.verify_artifacts()
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCommandExecOracle.java'
classes = build / 'classes'
classes.mkdir(exist_ok=True)
dependency_path = os.pathsep.join(map(str, libraries))
def run(command, name, **kw):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
result = subprocess.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT, **kw)
if result.returncode:
tail = (build / (name + '.log')).read_text(errors='replace')[-3000:]
sys.exit(f'{name} failed ({result.returncode}):\n{tail}')
groups = corpus_module.groups()
if args.only:
groups = [g for g in groups if args.only in g[0] or g[0] == 'setup']
script = []
for _, lines in groups:
script.extend(lines)
script_path = build / 'script.jsonl'
script_path.write_text(''.join(json.dumps(op, ensure_ascii=False) + '\n' for op in script), encoding='utf-8')
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-cp', str(jars['server']) + os.pathsep + dependency_path, '-d', classes, oracle], 'compile-java')
vanilla_dir = build / 'vanilla'
shutil.rmtree(vanilla_dir, ignore_errors=True)
vanilla_dir.mkdir()
run(['java', '-Xmx3G', '-Duser.timezone=UTC', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
'dev.horizonlab.parity.VanillaCommandExecOracle', vanilla_dir / 'universe', script_path, build / 'vanilla.jsonl', SEED], 'java', cwd=vanilla_dir)
run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], 'compile-rust', cwd=lab.ROOT)
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
rust_dir = build / 'rust'
shutil.rmtree(rust_dir, ignore_errors=True)
rust_dir.mkdir()
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_COMMAND_SCRIPT=str(script_path), HORIZON_COMMAND_OUTPUT=str(build / 'rust.jsonl'))
run([binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--world', 'harness', '--seed', SEED, '--radius', 4], 'rust', cwd=rust_dir, env=env)
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').open(encoding='utf-8')]
rust = [json.loads(l) for l in (build / 'rust.jsonl').open(encoding='utf-8')]
if len(vanilla) != len(rust):
sys.exit(f'record count differs: vanilla {len(vanilla)} rust {len(rust)}')
commands_by_group = []
index = 0
for name, lines in groups:
n = sum(1 for op in lines if (op['op'] == 'cmd' and op.get('record', True)) or (op['op'] == 'tick' and op.get('record')))
commands_by_group.append((name, index, index + n))
index += n
mismatches = []
for v, r in zip(vanilla, rust):
assert v['cmd'] == r['cmd'], (v['cmd'], r['cmd'])
kind = compare(v, r)
if kind:
mismatches.append({'kind': kind, 'cmd': v['cmd'], 'vanilla': v, 'rust': r})
(build / 'mismatches.jsonl').write_text(''.join(json.dumps(m, ensure_ascii=False) + '\n' for m in mismatches), encoding='utf-8')
summary = []
bad = {m['cmd'] for m in mismatches}
for name, lo, hi in commands_by_group:
failed = sum(1 for rec in vanilla[lo:hi] if rec['cmd'] in bad)
summary.append({'group': name, 'commands': hi - lo, 'mismatches': failed})
print(f'{name}: {hi - lo - failed}/{hi - lo} commands match', flush=True)
for m in mismatches[:args.limit]:
print(m['kind'], repr(m['cmd']))
print(' vanilla', render(m['vanilla'])[:400])
print(' rust ', render(m['rust'])[:400])
total = len(vanilla)
print(f'total: {total - len(mismatches)}/{total} commands match', flush=True)
report = {'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'seed': SEED,
'commands': total, 'mismatches': len(mismatches), 'groups': summary,
'scope': 'Feedback and failure messages (text and resolved style), chat lines, and CommandResultCallback results of commands run by the single-player owner of an integrated server; world, entity and item effects through probe commands.'}
lab.write_json(report_path, report)
if mismatches and not args.allow_gaps:
sys.exit(f'{len(mismatches)} command execution mismatches; see {build}')
print(f'Command execution parity report: {report_path}')
MASKS = [
(re.compile(r'debug-trace-\d{4}-\d\d-\d\d_\d\d\.\d\d\.\d\d\.txt'), 'debug-trace-<time>.txt'),
(re.compile(r'after [0-9.]+ second\(s\) and \d+ tick\(s\) \([0-9.,NaInfity]+ tick\(s\) per second\)'), 'after <time>'),
(re.compile(r'Average time per tick: [0-9.]+ms'), 'Average time per tick: <ms>'),
(re.compile(r'P50: [0-9.]+ms P95: [0-9.]+ms P99: [0-9.]+ms'), 'P50: <ms> P95: <ms> P99: <ms>'),
(re.compile(r'debug/client-[0-9_.-]+\.(jfr|json)'), 'debug/client-<time>'),
]
def masked(record):
text = json.dumps(record['messages'], ensure_ascii=False)
for pattern, replacement in MASKS:
text = pattern.sub(replacement, text)
return text
def compare(v, r):
if masked(v) != masked(r):
return 'messages'
if v['results'] != r['results']:
if v['cmd'].startswith('/debug stop'):
return None
return 'results'
return None
def render(record):
lines = [''.join(span[0] for span in m) for m in record['messages']]
return json.dumps({'messages': lines, 'styles': [[span[1] for span in m] for m in record['messages']], 'results': record['results']}, ensure_ascii=False)
if __name__ == '__main__':
main()