7ace13cc82
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>
170 lines
8.1 KiB
Python
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()
|