Files
RustNotMC/tools/test_structure_layout_parity.py
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

308 lines
16 KiB
Python

#!/usr/bin/env python3
"""Compare Horizon's structure starts, piece by piece, with the starts a real server saved.
Expected side: the clean-room structure oracle (horizon-parity `cleanroom/structures/oracle/`), which ran
vanilla 1.21.1 servers and kept every saved start. Its `base_pieces` are the no-mod world's start of each
located structure (villages, outposts, trial chambers, shipwrecks, ruined portals, desert pyramids): the
vanilla layout. Its results live outside the repository; put them (or link them) under
`.local/structure-oracle/<profile>/seed-<seed>.json.gz`, or point HORIZON_STRUCTURE_ORACLE at that folder.
Horizon side: the structure_pieces probe generates the same seed and lists the starts each chunk owns.
Jigsaw pieces must match exactly: element (template location), rotation, position, ground level delta and
bounding box, in saved order. Other pieces (shipwrecks, ruined portals, desert pyramids) match by type and
horizontal box: their height is settled when they are placed, after the start is created, and the oracle
saved them placed.
The oracle's game is 1.21.1 and Horizon's 26.3. KNOWN lists the starts where 26.3 itself differs from
1.21.1, each checked against 26.3 vanilla (VanillaChunkGenerationOracle's `scan`, HORIZON_PIECES=1), whose
pieces equal Horizon's there; they are reported, not failed.
Mod profiles compare the mod world's `pieces` instead, through the path players' packs take: the mod jars
(`.local/structure-oracle/jars/`, or HORIZON_STRUCTURE_JARS) are carried by `horizon-port-pack carry` into
a fresh work folder, and an overlay of the 26.3 resources adds the carried structures, sets, pools,
processor lists, biome tags and templates. The jars are the player's; nothing of them enters the
repository.
The `yung-probe` profiles check YUNG's API's draws with a data pack of the spec team's own (spec-yungsapi
§3, probe experiments e1-e9: fallback, max_count, empty elements, min_required_depth, ignore_bounds,
max_y, is_priority). The pack is carried as one more mod, built at run time from the oracle's profile file
(`.local/structure-oracle/profiles/<profile>.json`), and its starts are read from the oracle's census of
every start (`all_starts`).
usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons|yung-probe|yung-probe2]
[--filter REGEX] [--verbose]
"""
import base64
import argparse
import gzip
import json
import os
import re
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
try:
from . import lab
except ImportError:
import lab
ORACLE = Path(os.environ.get('HORIZON_STRUCTURE_ORACLE', lab.LOCAL / 'structure-oracle'))
JARS = Path(os.environ.get('HORIZON_STRUCTURE_JARS', ORACLE / 'jars'))
# Jars of a modpack's mods the oracle cache does not hold (Lithostitched, CTOV): a pack's jar folder.
PACK_JARS = Path(os.environ.get('HORIZON_STRUCTURE_PACK_JARS', ORACLE / 'pack-jars'))
def jar_path(jar):
return JARS / jar if (JARS / jar).exists() else PACK_JARS / jar
# Per profile: which saved pieces are expected, the mods in the pack (id: jar), those carried, and the
# structures compared (a regex; None: every start that has the pieces).
PROFILES = {
'lithostitched': {'side': 'base_pieces', 'mods': {}, 'carry': [], 'structures': None},
'betterdungeons': {'side': 'pieces', 'mods': {'yungsapi': 'YungsApi-1.21.1-NeoForge-5.1.9.jar',
'betterdungeons': 'YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar'},
'carry': ['betterdungeons'], 'structures': r'^betterdungeons:(small|skeleton|zombie)_dungeon$'},
**{name: {'side': 'all_starts', 'mods': {'yungsapi': 'YungsApi-1.21.1-NeoForge-5.1.9.jar'},
'packs': {'probe': name}, 'carry': ['probe'], 'structures': r'^probe:'}
for name in ['yung-probe', 'yung-probe2']},
}
BUNDLES = {'structure': 'structures', 'structure_set': 'structure_sets', 'template_pool': 'template_pools',
'processor_list': 'processor_lists'}
# (profile, seed, structure id, start chunk x, z) -> how 26.3 vanilla differs from 1.21.1 there.
TRIAL_CHAMBERS = '26.3 vanilla lays it out as Horizon does, unlike 1.21.1'
SULFUR_CAVES = "the start is in 26.3's sulfur_caves, a biome the mod's 1.21.1 tag cannot list"
RUINED_PORTAL = "26.3 vanilla's portal is Horizon's; 1.21.1 picked another"
KNOWN = {
('lithostitched', 1234, 'minecraft:village_plains', -24, 47): 'no start in 26.3 vanilla (nor in Horizon)',
('lithostitched', 1234, 'minecraft:ruined_portal', -16, 51): RUINED_PORTAL,
('lithostitched', 42, 'minecraft:ruined_portal', -19, -38): RUINED_PORTAL,
('betterdungeons', 1234, 'betterdungeons:small_dungeon', 162, 10): SULFUR_CAVES,
('betterdungeons', 42, 'betterdungeons:small_dungeon', -29, -28): SULFUR_CAVES,
**{('lithostitched', seed, 'minecraft:trial_chambers', x, z): TRIAL_CHAMBERS
for seed, x, z in [(1234, 13, 21), (2, -28, 13), (2, 11, 16), (2, 146, 44), (42, 17, 7), (42, 38, -25)]},
}
def element(saved):
kind = saved.get('element_type')
if kind in ('minecraft:single_pool_element', 'minecraft:legacy_single_pool_element', 'yungsapi:yung_single_element'):
location = saved['location']
return location if ':' in location else 'minecraft:' + location
if kind == 'minecraft:feature_pool_element':
return 'feature:' + saved['feature']
if kind == 'minecraft:list_pool_element':
return 'list:' + '+'.join(element(e) for e in saved['elements'])
if kind == 'minecraft:empty_pool_element':
return 'empty'
return kind
def expected_piece(saved):
box = saved['BB']
piece = {'type': saved['id'], 'box': box if saved['id'] == 'minecraft:jigsaw' else [box[0], box[2], box[3], box[5]]}
if saved['id'] == 'minecraft:jigsaw':
piece.update(element=element(saved['pool_element']), rotation=saved['rotation'],
position=[saved['PosX'], saved['PosY'], saved['PosZ']], ground_level_delta=saved['ground_level_delta'])
return piece
# horizon-port-pack's jar census (inventory.json); the carry does not read it.
CENSUS = ['blockstates', 'item_models', 'recipes', 'loot_tables', 'tags', 'worldgen', 'structures', 'languages',
'textures', 'sounds', 'classes', 'mixin_configs']
def pack_jar(work, mod, profile):
"""The data pack an oracle profile added to its world, as a jar of mod `mod`."""
spec = json.loads((ORACLE / 'profiles' / f'{profile}.json').read_text())
files = {**spec['config_files'], **{path: base64.b64decode(data) for path, data in spec.get('binary_files', {}).items()}}
jar = work / f'{mod}.jar'
with zipfile.ZipFile(jar, 'w') as z:
for path, content in files.items():
parts = Path(path).parts
if parts[:2] == ('world', 'datapacks') and len(parts) > 3:
z.writestr(str(Path(*parts[3:])), content)
return jar
def carry(profile, mods, carried, packs=None):
"""`horizon-port-pack carry` of the profile's mods and packs; the carried mods' data folders."""
work = lab.LOCAL / 'structure-layout' / profile / 'work'
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
mods = {key: str(jar_path(jar)) for key, jar in mods.items()}
mods.update({key: str(pack_jar(work, key, name)) for key, name in (packs or {}).items()})
records = [{'key': key, 'name': key, 'version': '', 'path': jar, 'tier': 'data', 'confidence': 'known',
'class': 'content' if key in carried else 'library', 'source': 'file', 'license': '',
'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': jar,
'info': {'mod_id': key, 'name': key, 'version': '', 'license': '', 'depends': [],
'census': dict.fromkeys(CENSUS, 0)}}
for key, jar in mods.items()]
(work / 'inventory.json').write_text(json.dumps(records))
subprocess.run(['cargo', 'run', '--release', '-q', '-p', 'horizon-port-pack', '--', 'carry', str(work),
'--resources', str(lab.RESOURCES), *carried], check=True, cwd=lab.ROOT, stdout=subprocess.DEVNULL)
data = [work / 'mods' / key / 'carry' / 'data' for key in carried]
if not all(d.is_dir() for d in data):
raise SystemExit(f'horizon-port-pack carried nothing into {work / "mods"}')
return data
def overlay(profile, data_dirs):
"""The 26.3 resources plus the carried structure data, in a folder the probe reads as its resources."""
root = lab.LOCAL / 'structure-layout' / profile / 'resources'
shutil.rmtree(root, ignore_errors=True)
worldgen = root / 'worldgen'
(worldgen / 'structure').mkdir(parents=True)
(root / 'facts.json').symlink_to(lab.RESOURCES / 'facts.json')
source = lab.RESOURCES / 'worldgen'
merged = {name: json.loads((source / f'{name}.json').read_text()) for name in [*BUNDLES.values(), 'biome_tags']}
additions = []
for entry in source.iterdir():
if entry.name != 'structure' and entry.stem not in merged:
(worldgen / entry.name).symlink_to(entry)
for namespace in (source / 'structure').iterdir():
(worldgen / 'structure' / namespace.name).symlink_to(namespace)
for data in data_dirs:
for path in sorted(p for p in data.rglob('*') if p.is_file()):
parts = path.relative_to(data).parts
if len(parts) < 3:
continue
namespace, kind = parts[0], parts[1]
if kind == 'structure' and path.suffix == '.nbt':
target = worldgen / 'structure' / namespace / Path(*parts[2:])
target.parent.mkdir(parents=True, exist_ok=True)
target.symlink_to(path)
elif path.suffix == '.json' and kind == 'worldgen' and parts[2] in BUNDLES:
text = path.read_text()
if 'horizon:add_to' in text:
additions.append((BUNDLES[parts[2]], json.loads(text)))
else:
merged[BUNDLES[parts[2]]][f'{namespace}:' + '/'.join(parts[3:])[:-5]] = text
elif path.suffix == '.json' and parts[1:4] == ('tags', 'worldgen', 'biome'):
tag = f'{namespace}:' + '/'.join(parts[4:])[:-5]
value = json.loads(path.read_text())
if tag in merged['biome_tags'] and not value.get('replace'):
value['values'] = json.loads(merged['biome_tags'][tag])['values'] + value['values']
merged['biome_tags'][tag] = json.dumps(value)
# A carried file with `horizon:add_to` adds its entries to other sets or pools, as the game's
# mod_content.rs merges them.
for bundle, addition in additions:
targets = addition['horizon:add_to']
key = 'structures' if bundle == 'structure_sets' else 'elements'
for target in [targets] if isinstance(targets, str) else targets:
if target in merged[bundle]:
value = json.loads(merged[bundle][target])
value[key] = value.get(key, []) + addition.get(key, [])
merged[bundle][target] = json.dumps(value)
for name, bundle in merged.items():
(worldgen / f'{name}.json').write_text(json.dumps(bundle))
return root
def probe_binary():
subprocess.run(['cargo', 'build', '--release', '-q', '-p', 'horizon-world', '--example', 'world_probe'], check=True, cwd=lab.ROOT)
name = 'world_probe.exe' if os.name == 'nt' else 'world_probe'
return lab.cargo_target() / 'release/examples' / name
def horizon_starts(binary, resources, seed, dimension, chunks):
result = subprocess.run([str(binary), 'structure_pieces', str(resources), str(seed), dimension,
*(f'{x},{z}' for x, z in chunks)], capture_output=True, text=True, cwd=lab.ROOT)
if result.returncode:
raise SystemExit('structure_pieces failed: ' + result.stderr[-2000:])
starts, current = {}, None
for line in result.stdout.splitlines():
fields = line.split()
if fields[0] == 'start':
current = starts.setdefault((fields[1], int(fields[2]), int(fields[3])), [])
elif fields[0] == 'piece':
box = list(map(int, fields[8:14]))
piece = {'type': fields[1], 'box': box if fields[1] == 'minecraft:jigsaw' else [box[0], box[2], box[3], box[5]]}
if fields[1] == 'minecraft:jigsaw':
piece.update(element=fields[2], rotation=fields[3], position=list(map(int, fields[4:7])),
ground_level_delta=int(fields[7]))
current.append(piece)
return starts
def compare(expected, actual):
"""Index of the first differing piece and what differs, or None."""
for i, (want, got) in enumerate(zip(expected, actual)):
if want != got:
return i, {k: (want.get(k), got.get(k)) for k in want.keys() | got.keys() if want.get(k) != got.get(k)}
if len(expected) != len(actual):
return min(len(expected), len(actual)), {'pieces': (len(expected), len(actual))}
return None
def census_starts(oracle):
"""The oracle's census of every start (`all_starts`), as `structures` entries with their `all_starts`."""
for chunk in oracle['all_starts']:
for start in chunk['starts']:
if 'children' in start:
yield {'id': start['id'], 'dimension': chunk['dimension'], 'start_chunk': chunk['chunk'],
'all_starts': start['children']}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--profile', default='lithostitched', choices=sorted(PROFILES))
parser.add_argument('--filter', default='', help='only structure ids matching this regex')
parser.add_argument('--verbose', action='store_true')
args = parser.parse_args()
results = sorted((ORACLE / args.profile).glob('seed-*.json.gz'))
if not results:
print(f'SKIP: no oracle results in {ORACLE / args.profile} (see this file\'s docstring)')
return 0
profile = PROFILES[args.profile]
missing = [jar for jar in profile['mods'].values() if not jar_path(jar).exists()]
missing += [str(ORACLE / 'profiles' / f'{name}.json') for name in profile.get('packs', {}).values()
if not (ORACLE / 'profiles' / f'{name}.json').exists()]
if missing:
print(f'SKIP: not found: {", ".join(missing)}')
return 0
resources = lab.RESOURCES
if profile['carry']:
data = carry(args.profile, profile['mods'], profile['carry'], profile.get('packs'))
resources = overlay(args.profile, data)
binary = probe_binary()
matched, known, failed = 0, 0, []
for path in results:
oracle = json.load(gzip.open(path))
seed = oracle['seed']
by_dimension = {}
for start in oracle['structures'] if profile['side'] != 'all_starts' else census_starts(oracle):
if start.get(profile['side']) is None or not re.search(args.filter, start['id']) \
or not re.search(profile['structures'] or '', start['id']):
continue
by_dimension.setdefault(start['dimension'], []).append(start)
for dimension, starts in by_dimension.items():
chunks = sorted({tuple(s['start_chunk']) for s in starts})
actual = horizon_starts(binary, resources, seed, dimension.removeprefix('minecraft:'), chunks)
for start in starts:
key = (start['id'], *start['start_chunk'])
expected = [expected_piece(p) for p in start[profile['side']]]
got = actual.get(key)
label = f'seed {seed} {start["id"]} chunk {start["start_chunk"]}'
difference = (0, {'start': 'missing in Horizon'}) if got is None else compare(expected, got)
reason = KNOWN.get((args.profile, seed, *key))
if difference is None:
matched += 1
if args.verbose:
print(f'ok {label}: {len(expected)} pieces')
elif reason:
known += 1
print(f'known {label}: {reason}')
else:
failed.append(label)
index, fields = difference
print(f'FAIL {label}: {len(expected)} pieces expected, {len(got or [])} placed; piece {index}: {fields}')
total = matched + known + len(failed)
print(f'{matched} of {total} starts match the oracle piece for piece; {known} known version differences')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())