Files
horizon-airdef/tools/test_structure_layout_parity.py
T
Ludovic Marie 379692f355 Port YUNG's worldgen as written; delete the old YUNG conversion and assembler
Clean-room (spec-yungsapi.md §2, §3, §5; no mod source read; the legacy
files were deleted unread). horizon-port-pack's new `yung` module carries
`yungsapi:yung_jigsaw` structures and `yungsapi:yung_single_element` pool
elements as the mods write them, so players' packs reach the YUNG draws of
the jigsaw assembler. Fields whose behaviour the specification leaves open
(adapt_noise, max_y/min_y, enhanced terrain, element conditions, modifiers,
priorities, depths, ignore_bounds) are counted as lost by name. YUNG's
other structure, placement and processor types go to the general code
(unsupported or dropped) until the specification covers them.

Deleted: legacy_structure_mods/yung.rs, horizon-world's structure/yung.rs,
the `horizon:yungsapi` keys and `horizon:disables`, which nothing writes
now. Two test initializers of the removed field (enhanced_terrain.rs,
piece_limited.rs) were removed by line from the compiler's error positions.

The layout harness now carries the mods through `horizon-port-pack carry`:
82 of 84 Better Dungeons starts match the oracle piece for piece, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 06:19:43 +01:00

248 lines
13 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.
usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons] [--filter REGEX] [--verbose]
"""
import argparse
import gzip
import json
import os
import re
import shutil
import subprocess
import sys
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'))
# 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$'},
}
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 carry(profile, mods, carried):
"""`horizon-port-pack carry` of the profile's mods; the carried mods' data folders."""
work = lab.LOCAL / 'structure-layout' / profile / 'work'
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
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': str(JARS / 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']}
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:
merged[BUNDLES[parts[2]]][f'{namespace}:' + '/'.join(parts[3:])[:-5]] = path.read_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)
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 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 (JARS / jar).exists()]
if missing:
print(f'SKIP: mod jars not in {JARS}: {", ".join(missing)}')
return 0
resources = lab.RESOURCES
if profile['carry']:
resources = overlay(args.profile, carry(args.profile, profile['mods'], profile['carry']))
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 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())