Files
horizon-airdef/tools/test_datafix_parity.py
T
Ludovic Marie 68092c4c7f WIP: unfinished saves parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:24:03 +02:00

215 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""DataFixer differential test: worlds saved by older game versions, upgraded by
vanilla 26.3 and by Horizon, must give the same data.
For every sample world in .local/datafix/worlds (made by tools/datafix_worlds.py
with the official server jars of 1.20.1 to 26.2), two upgrades run on a copy:
vanilla VanillaDataFixOracle: FileFixerUpper, then DataFixers on level.dat, every
chunk, entity chunk and POI chunk, player, saved data, advancements and
statistics file, exactly as 26.3 does when it opens the world
horizon the `datafix_world` example of horizon-world, which does the same with
horizon_world::datafix
Then every file is compared: region files chunk by chunk, NBT files and JSON
files value by value (compound and object key order does not matter). The only
tolerated differences are listed in IGNORED (wall-clock time).
test_datafix_parity.py [--keep] [--skip-build] [VERSION...]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import anvil_dump # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
LOCAL = ROOT / '.local' / 'datafix'
WORLDS = LOCAL / 'worlds'
RUN = LOCAL / 'run'
IGNORED = {
r'^level\.dat\.Data\.LastPlayed$': 'wall-clock time of the upgrade',
}
def java_classpath():
import oracle_harness as harness
import test_rust_parity as base
import test_model_baking_parity as baking
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
build = LOCAL / 'build'
build.mkdir(parents=True, exist_ok=True)
classes, dependencies, _ = harness.compile_oracle(build, 'VanillaDataFixOracle', jars, libraries,
lock['minecraft']['java_major'])
return str(classes) + os.pathsep + dependencies
def build_rust():
subprocess.run(['cargo', 'build', '--locked', '--release', '-p', 'horizon-world', '--example', 'datafix_world'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.check_output(['cargo', 'metadata', '--locked', '--no-deps', '--format-version', '1'],
cwd=ROOT))
return Path(meta['target_directory']) / 'release' / 'examples' / 'datafix_world'
def run_vanilla(classpath, world, out):
shutil.rmtree(out, ignore_errors=True)
log = out.parent / (out.name + '.log')
with log.open('w') as f:
subprocess.run(['java', '-Xmx3g', '-Djava.awt.headless=true', '-cp', classpath,
'dev.horizonlab.parity.VanillaDataFixOracle', str(world), str(out)],
check=True, stdout=f, stderr=subprocess.STDOUT)
def run_horizon(binary, world, out):
shutil.rmtree(out, ignore_errors=True)
log = out.parent / (out.name + '.log')
with log.open('w') as f:
subprocess.run([str(binary), str(world), str(out)], check=True, stdout=f, stderr=subprocess.STDOUT)
# ---------------------------------------------------------------- comparison
def norm(v):
"""Python values from anvil_dump, with 26.3 mixed-list wrappers removed."""
if isinstance(v, dict):
return {k: norm(x) for k, x in v.items()}
if isinstance(v, list):
return [norm(x[''] if isinstance(x, dict) and list(x) == [''] else x) for x in v]
return v
def diff(a, b, path, out):
if type(a) is not type(b):
out.append((path, a, b))
elif isinstance(a, dict):
for k in sorted(set(a) | set(b), key=str):
if k not in a:
out.append((f'{path}.{k}', None, b[k]))
elif k not in b:
out.append((f'{path}.{k}', a[k], None))
else:
diff(a[k], b[k], f'{path}.{k}', out)
elif isinstance(a, list):
if len(a) != len(b):
out.append((f'{path}[len]', len(a), len(b)))
for i, (x, y) in enumerate(zip(a, b)):
diff(x, y, f'{path}[{i}]', out)
elif a != b:
out.append((path, a, b))
def read_json(path):
return json.loads(path.read_text(encoding='utf-8'))
def files(world):
out = set()
for p in world.rglob('*'):
if p.is_file() and p.name not in ('session.lock', 'datafix-source.json') and not p.name.endswith('_old'):
out.add(p.relative_to(world))
return out
def compare(vanilla, horizon):
report = []
a, b = files(vanilla), files(horizon)
for rel in sorted(a | b, key=str):
name = str(rel)
if rel not in b:
report.append((name, 'present', 'missing from Horizon'))
continue
if rel not in a:
report.append((name, 'missing from vanilla', 'present'))
continue
va, ha = vanilla / rel, horizon / rel
if name.endswith('.mca'):
vc, hc = anvil_dump.region_chunks(str(va)), anvil_dump.region_chunks(str(ha))
for key in sorted(set(vc) | set(hc)):
label = f'{name}{list(key)}'
if key not in hc:
report.append((label, 'present', 'missing from Horizon'))
elif key not in vc:
report.append((label, 'missing from vanilla', 'present'))
else:
out = []
diff(norm(vc[key]), norm(hc[key]), 'chunk', out)
report.extend((f'{p} @{label}', x, y) for p, x, y in out)
elif name.endswith('.dat'):
out = []
diff(norm(anvil_dump.read_dat(str(va))), norm(anvil_dump.read_dat(str(ha))), name, out)
report.extend(out)
elif name.endswith('.json'):
out = []
diff(read_json(va), read_json(ha), name, out)
report.extend(out)
elif va.read_bytes() != ha.read_bytes():
report.append((name, 'bytes differ', ''))
return report
def pattern_of(path):
return re.sub(r'\[\d+\]', '[]', path.split(' @')[0])
def summarize(report):
groups = {}
for path, a, b in report:
groups.setdefault(pattern_of(path), []).append((path, a, b))
failures = 0
for pattern, items in groups.items():
why = next((w for p, w in IGNORED.items() if re.search(p, pattern)), None)
if why:
print(f' ignored {pattern} x{len(items)} ({why})')
continue
failures += len(items)
path, a, b = items[0]
short = lambda v: (s := json.dumps(v, default=str))[:200] + ('...' if len(s) > 200 else '')
print(f' DIFF {pattern} x{len(items)}\n e.g. {path}\n vanilla {short(a)}\n horizon {short(b)}')
return failures
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('versions', nargs='*')
p.add_argument('--keep', action='store_true')
p.add_argument('--vanilla-only', action='store_true', help='only run the oracle')
p.add_argument('--binary', help='a prebuilt datafix_world example')
args = p.parse_args()
worlds = sorted(p.name for p in WORLDS.iterdir()) if WORLDS.is_dir() else []
versions = args.versions or worlds
if not versions:
sys.exit('no sample worlds: run tools/datafix_worlds.py first')
classpath = java_classpath()
binary = None if args.vanilla_only else Path(args.binary) if args.binary else build_rust()
total = 0
for version in versions:
world = WORLDS / version
source = json.loads((world / 'datafix-source.json').read_text())
print(f'{version} (DataVersion {source["data_version"]})')
RUN.mkdir(parents=True, exist_ok=True)
run_vanilla(classpath, world, RUN / f'{version}-vanilla')
if binary is None:
continue
run_horizon(binary, world, RUN / f'{version}-horizon')
failures = summarize(compare(RUN / f'{version}-vanilla', RUN / f'{version}-horizon'))
print(f' {version}: {"ok" if not failures else f"{failures} differences"}')
total += failures
if binary is not None:
print('datafix parity', 'FAILED' if total else 'ok', f'({total} differences over {len(versions)} worlds)')
if not total and not args.keep:
shutil.rmtree(RUN, ignore_errors=True)
sys.exit(1 if total else 0)
if __name__ == '__main__':
main()