Files
horizon-airdef/tools/test_world_spawn_parity.py
T
Ludovic Marie 0ae1ab2e70 world_spawn harness: vanilla's server creates the world on one worker
Trees of neighbouring chunks overlap on chunk corners, and the one
decorated first wins. With several worker threads vanilla decorates in a
different order each run: seed -2080727421395798566 spawned at Y 64, 66
or 72 over 10 runs. The harness retried vanilla up to 3 times and passed
if any run matched, which still failed when none of them gave 72.

With -Dmax.bg.threads=1 (Util.maxAllowedExecutorThreads) vanilla
decorates in its scheduler's order, the one Horizon follows: that seed
gave 72 in 10 of 10 runs, as Horizon does. The other seeds that varied
are fixed too (10 of 10 each: -8171837717659076940 at 0 67 0,
-5296387317982839690 at -16 73 16, -211473008665507967 at 0 66 112).
The retries go, and vanilla's one spawn is compared exactly.

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

163 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Compare a new world's spawn with vanilla's, seed by seed.
A new overworld's spawn comes from two steps (MinecraftServer.setInitialSpawn):
1. the origin: NoiseBasedChunkGenerator.getOrigin runs NoiseSpawnFinder over
the noise settings' `spawn_target` points (a coarse then a fine radial
search of the climate fields) and takes the chunk of the best column;
2. the spawn: an 11 by 11 chunk spiral around that chunk, the first chunk
with a column PlayerSpawnFinder.getSpawnPosInChunk accepts (a full-topped
block under open sky, no liquid on top) gives the world spawn.
Origins: VanillaWorldSpawnOracle calls NoiseSpawnFinder.findSpawnPosition and
getOrigin for every seed; the spawn_origin example asks Horizon's generator.
World spawns: the pinned vanilla dedicated server creates each world and
stops; Horizon's (`horizon-client --dedicated --nogui`) creates its own from
the same server.properties and stops; the `spawn` position of both
level.dat files is compared.
Trees of neighbouring chunks overlap on chunk corners, and which one is
placed first depends on the order the chunks are decorated. With several
worker threads vanilla's order changes from run to run (seed
-2080727421395798566 spawned at Y 64, 66 or 72), so its server runs with one
(`-Dmax.bg.threads`, Util.maxAllowedExecutorThreads): the chunks are then
decorated in its scheduler's order, the one Horizon follows
(decoration_order::vanilla_order), and every run gives the same spawn.
Seeds: the player report's seed (1891623539983467248: Horizon spawned at
-48 -48, vanilla at 152 145), the chunk generation harness's seeds and
--random seeds from a fixed generator.
usage: test_world_spawn_parity.py [--random N] [--jobs N] [--origins-only]
"""
import argparse
import json
import os
import random
import shutil
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
try:
from . import anvil_dump, lab, oracle_harness as oh, test_rust_parity as base
from .test_dedicated_server_parity import Server, free_port
except ImportError:
import anvil_dump
import lab
import oracle_harness as oh
import test_rust_parity as base
from test_dedicated_server_parity import Server, free_port
NAME = 'world-spawn-parity'
REPORT = 1891623539983467248
SEEDS = [REPORT, 12345, -7046029254386353131, 987654321987, 4242, -1, 0x5DEECE66D, 20260607, 777, 0]
RESOURCES = lab.RESOURCES
def seeds(count):
rng = random.Random(20261014)
return SEEDS + [rng.randrange(-2**63, 2**63) for _ in range(count)]
def target_dir():
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
return Path(metadata['target_directory']) / 'release'
def origins(build, classes, dependencies, all_seeds):
vanilla_out = build / 'origins-vanilla.txt'
oh.run_logged(['java', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaWorldSpawnOracle',
vanilla_out, *all_seeds], build, 'origins-vanilla')
rust = subprocess.run([target_dir() / 'examples/spawn_origin', RESOURCES, *map(str, all_seeds)],
capture_output=True, text=True, check=True, cwd=lab.ROOT).stdout
vanilla = vanilla_out.read_text()
parse = lambda text: {int(l.split()[0]): tuple(map(int, l.split()[1:])) for l in text.splitlines() if l}
return parse(vanilla), parse(rust)
def world_spawn(command, directory, seed, log_name):
"""Creates a world with `seed` in `directory`, stops, returns its level.dat spawn position."""
if directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)
props = {'online-mode': 'false', 'level-seed': str(seed), 'server-port': str(free_port()),
'view-distance': '4', 'simulation-distance': '4', 'pause-when-empty-seconds': '-1'}
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()), encoding='iso-8859-1')
(directory / 'eula.txt').write_text('eula=true\n')
server = Server(command, directory, log_name)
try:
server.wait_for(r'Done \(', timeout=900)
finally:
server.stop()
data = anvil_dump.read_dat(directory / 'world/level.dat')['Data']
return tuple(data['spawn']['pos'][1])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--random', type=int, default=50, help='random seeds besides the fixed ones')
parser.add_argument('--jobs', type=int, default=max(1, min(4, int(os.environ.get('FLEET_LOAD_JOBS', '4')) // 4)),
help='worlds created at once (each side)')
parser.add_argument('--origins-only', action='store_true', help='compare the spawn search origins only')
parser.add_argument('--seed', type=int, action='append', help='only these seeds (repeatable)')
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
build.mkdir(parents=True, exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
classes = build / 'classes'
classes.mkdir(exist_ok=True)
oh.run_logged(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies,
'-d', classes, lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaWorldSpawnOracle.java'],
build, 'compile-java')
oh.run_logged(['cargo', 'build', '--release', '-p', 'horizon-world', '--example', 'spawn_origin'], build, 'compile-probe')
all_seeds = args.seed or seeds(args.random)
started = time.time()
vanilla, rust = origins(build, classes, dependencies, all_seeds)
origin_diffs = [s for s in all_seeds if vanilla.get(s) != rust.get(s)]
for s in origin_diffs:
print(f'origin {s}: vanilla column {vanilla.get(s)} / horizon {rust.get(s)}')
print(f'Origins: {len(all_seeds) - len(origin_diffs)}/{len(all_seeds)} seeds match '
f'(column x z, chunk x z) in {time.time() - started:.0f}s', flush=True)
if args.origins_only:
sys.exit(1 if origin_diffs else 0)
oh.run_logged(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], build,
'compile-rust', timeout=3600)
binary = target_dir() / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
# One worker thread: the chunks decorated in a fixed order (see above).
vanilla_server = ['java', '-Xmx2g', '-Dmax.bg.threads=1', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', RESOURCES, '--dedicated', '--nogui']
os.environ['HORIZON_NO_AUDIO'] = '1'
def one(seed):
t0 = time.time()
a = world_spawn(vanilla_server, build / f'vanilla-{seed}', seed, f'vanilla-{seed}')
b = world_spawn(horizon_server, build / f'horizon-{seed}', seed, f'horizon-{seed}')
return seed, a, b, time.time() - t0
spawn_diffs = []
with ThreadPoolExecutor(args.jobs) as pool:
for seed, a, b, took in pool.map(one, all_seeds):
ok = a == b
if not ok:
spawn_diffs.append(seed)
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {a[0]} {a[1]} {a[2]} / horizon {b[0]} {b[1]} {b[2]} '
f'(origin chunk {vanilla[seed][2]} {vanilla[seed][3]}; {took:.0f}s)', flush=True)
if ok:
for path in build.glob(f'*-{seed}*'):
shutil.rmtree(path, ignore_errors=True)
print(f'World spawns: {len(all_seeds) - len(spawn_diffs)}/{len(all_seeds)} seeds match in {time.time() - started:.0f}s')
if origin_diffs or spawn_diffs:
sys.exit(1)
if __name__ == '__main__':
main()