2ea1c273dd
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>
159 lines
7.6 KiB
Python
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()
|