82dceec5e7
The old server jars and the 22 worlds made from them are inputs, but they lived in each worktree's .local and were lost when its outputs were cleared. They now sit beside the shared vanilla data, written atomically, and the harness generates any that are missing (under a lock, since several worktrees may run it at once). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
8.8 KiB
Python
223 lines
8.8 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 datafix/worlds beside the shared vanilla data (made by
|
|
tools/datafix_worlds.py with the official server jars of 1.18.2 to 26.2, and
|
|
generated automatically when missing), 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 = (ROOT / '.local' / 'minecraft').resolve().parent / 'datafix' / '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()
|
|
import datafix_worlds
|
|
import fcntl
|
|
WORLDS.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(WORLDS.parent / 'generate.lock', 'w') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX) # another worktree may be generating them
|
|
missing = [v for v in datafix_worlds.VERSIONS if not (WORLDS / v / 'level.dat').exists()]
|
|
if missing:
|
|
print('generating sample worlds:', ' '.join(missing), flush=True)
|
|
subprocess.run([sys.executable, str(ROOT / 'tools' / 'datafix_worlds.py'), *missing], cwd=ROOT, check=True)
|
|
worlds = sorted(p.name for p in WORLDS.iterdir() if p.is_dir())
|
|
versions = args.versions or worlds
|
|
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()
|