874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
308 lines
16 KiB
Python
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())
|