Files
horizon-airdef/tools/test_world_spawn_parity.py
T
Ludovic Marie 7ace13cc82 Find a new world's spawn as vanilla does
Horizon searched its own spiral of 48-block steps from 0 0 for a dry,
non-leaf top block, so seed 1891623539983467248 spawned at -48 -48 where
vanilla puts the world spawn at 144 71 144 (the player then lands within
the respawn radius, 152 145 in the report).

Vanilla (MinecraftServer.setInitialSpawn): NoiseBasedChunkGenerator.
getOrigin runs NoiseSpawnFinder over the noise settings' spawn_target
points (SpawnTargetPoint.sampleFitness, a 2048/512 then 512/32 radial
search biased towards 0 0); an 11 by 11 chunk spiral around that chunk
takes the first column PlayerSpawnFinder.getSpawnPosInChunk accepts
(getLevelRespawnPos: MOTION_BLOCKING, WORLD_SURFACE and OCEAN_FLOOR
heights, no liquid, a full-topped collision shape); without one, the
origin chunk's centre at the spawn height. The first player is then
placed by PlayerSpawnFinder.findSpawn: the respawn-radius square in a
random coprime order, a free player box with liquids colliding, else the
spawn with fixupSpawnHeight; adventure mode uses the spawn as is.

horizon-world gains worldgen::spawn_finder and OverworldGenerator::origin;
the client's world_spawn module and game/initial_spawn.rs follow
setInitialSpawn and PlayerSpawnFinder, for single player and the
dedicated server alike. Flat worlds use FlatLevelSource's origin and
spawn height.

tools/test_world_spawn_parity.py compares, for the report's seed, the
chunk harness's seeds and 50 random ones, the origins
(VanillaWorldSpawnOracle against the spawn_origin example) and the
level.dat spawn of worlds created by the vanilla dedicated server and by
Horizon's. Vanilla is not deterministic on chunk corners where trees of
neighbouring chunks overlap (decoration order follows its worker
threads), so a mismatching seed reruns vanilla up to three times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:49:15 +02:00

170 lines
8.1 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.
Vanilla is not deterministic everywhere: trees of neighbouring chunks
overlap on chunk corners, and which one is placed last depends on the order
the server's worker threads decorate the chunks. A spawn column on such a
corner can then stand on different leaves from one vanilla run to the next.
When the spawns differ, vanilla creates the world up to RETRIES more times;
Horizon's spawn passes if one of vanilla's runs gives it too, and the seed is
reported as varying in vanilla.
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
RETRIES = 3
SEEDS = [REPORT, 12345, -7046029254386353131, 987654321987, 4242, -1, 0x5DEECE66D, 20260607, 777, 0]
RESOURCES = lab.LOCAL / 'horizon-client/26.3'
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')
vanilla_server = ['java', '-Xmx2g', '-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}')
runs = [a]
while b not in runs and len(runs) <= RETRIES:
runs.append(world_spawn(vanilla_server, build / f'vanilla-{seed}-{len(runs)}', seed, f'vanilla-{seed}-{len(runs)}'))
return seed, runs, b, time.time() - t0
spawn_diffs, varying = [], []
with ThreadPoolExecutor(args.jobs) as pool:
for seed, runs, b, took in pool.map(one, all_seeds):
ok = b in runs
if not ok:
spawn_diffs.append(seed)
elif len(set(runs)) > 1:
varying.append(seed)
seen = ', '.join(' '.join(map(str, r)) for r in dict.fromkeys(runs))
print(f'{"ok " if ok else "DIFF"} {seed}: vanilla {seen} / 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'
+ (f'; vanilla itself gave several spawns for {len(varying)}: {varying}' if varying else ''))
if origin_diffs or spawn_diffs:
sys.exit(1)
if __name__ == '__main__':
main()