874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
244 lines
12 KiB
Python
244 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare the blocks Horizon's structure mods place with the blocks a real server saved.
|
|
|
|
Expected side: the clean-room structure oracle (see test_structure_layout_parity.py), which kept, for every
|
|
start of a mod world, its blocks (`blocks`, the start's box) and every position of the box grown by
|
|
`diff_margin` where the mod world differs from the no-mod world of the same seed (`changed_vs_vanilla`).
|
|
|
|
Horizon side: the chunk_dump probe generates the chunks around each start in full (terrain, structures,
|
|
features) twice: from the 26.3 resources (the no-mod world) and from those resources with the mods carried
|
|
by `horizon-port-pack carry` (the mod world), as test_structure_layout_parity.py builds them. In the mod
|
|
world each start whose pieces are all jigsaw pieces is the oracle's own (HORIZON_SAVED_STARTS): the blocks
|
|
are checked whatever the layout (Lithostitched lays out jigsaws its own way, spec-lithostitched OPEN-L5), and
|
|
test_structure_layout_parity.py checks layouts apart.
|
|
|
|
The oracle's game is 1.21.1 and Horizon's 26.3, so the worlds also differ where the versions do. A position
|
|
is compared only where the two no-mod worlds agree (the oracle's no-mod block is known inside the start's
|
|
box and at the changed positions); elsewhere in the grown box, where the oracle saw no change, Horizon's mod
|
|
world must equal its no-mod world. Overlapping structures need no special case: both worlds have them.
|
|
|
|
A mismatch is counted by the oracle's no-mod block, the expected block and Horizon's, so a processor's
|
|
missing rule reads as one line. KNOWN names the mismatch classes the implementation does not cover yet, with
|
|
the reason; they are reported, not failed.
|
|
|
|
usage: test_structure_blocks_parity.py [--profile betterdungeons|ctov] [--filter REGEX] [--limit N]
|
|
"""
|
|
import argparse
|
|
import gzip
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections import Counter, defaultdict
|
|
|
|
try:
|
|
from . import lab, test_structure_layout_parity as layout
|
|
except ImportError:
|
|
import lab
|
|
import test_structure_layout_parity as layout
|
|
|
|
# The mod profiles: the layout harness's, and CTOV (its jar and Lithostitched's come from a pack's jar
|
|
# folder, `.local/structure-oracle/pack-jars/`).
|
|
PROFILES = {
|
|
**{name: p for name, p in layout.PROFILES.items() if p['carry']},
|
|
'ctov': {'mods': {'lithostitched': 'lithostitched-1.8.1-neoforge-21.1.jar', 'ctov': '[Neoforge]ctov-3.6.3.jar'},
|
|
'carry': ['lithostitched', 'ctov']},
|
|
}
|
|
# profile -> {structure id regex: what the implementation does not cover yet}
|
|
KNOWN = {
|
|
'betterdungeons': {
|
|
r'^betterdungeons:(small|skeleton|zombie)_dungeon$':
|
|
"Better Dungeons' placeholder processors and its biome modifier that removes vanilla's "
|
|
'monster rooms (spec-betterdungeons §1.2, §3.2) are not implemented yet',
|
|
r'^betterdungeons:(spider|small_nether)_dungeon$':
|
|
"Better Dungeons' own structure types (spec-betterdungeons §5, §6; OPEN-B14, B15) are not "
|
|
'implemented yet',
|
|
},
|
|
'ctov': {
|
|
r'^ctov:': "Lithostitched's random is not known (spec-lithostitched OPEN-L6): per-block road and palette "
|
|
'choices match by share, not by block; and decoration (grass, trees) differs where the village '
|
|
'changed the terrain',
|
|
r'^minecraft:village_plains$': 'decoration (grass, trees) differs where the village changed the terrain',
|
|
},
|
|
}
|
|
|
|
|
|
def state(text):
|
|
"""`name[k=v,...]` with sorted properties, the same for both games' spellings."""
|
|
name, _, rest = text.partition('[')
|
|
return name + ('[' + ','.join(sorted(rest.rstrip(']').split(','))) + ']' if rest else '')
|
|
|
|
|
|
def state_names(resources):
|
|
facts = json.loads((resources / 'facts.json').read_text())
|
|
return [state(s[3].removeprefix('Block{').replace('}', '', 1)) for s in facts['states']]
|
|
|
|
|
|
def chunk_rect(box):
|
|
x0, _, z0, x1, _, z1 = box
|
|
return f'{x0 >> 4},{z0 >> 4},{x1 >> 4},{z1 >> 4}'
|
|
|
|
|
|
def saved_element(element):
|
|
"""A saved pool element as the game reads it: Lithostitched's wrappers place their `delegate`
|
|
(spec-lithostitched §3), and 1.21.1's `{"Name", "Properties"}` states are 26.3's `{"id", "properties"}`."""
|
|
if isinstance(element, dict):
|
|
if 'delegate' in element and str(element.get('element_type', '')).startswith('lithostitched:'):
|
|
return saved_element(element['delegate'])
|
|
if 'Name' in element and set(element) <= {'Name', 'Properties'}:
|
|
return {'id': element['Name'], **({'properties': element['Properties']} if 'Properties' in element else {})}
|
|
return {k: saved_element(v) for k, v in element.items()}
|
|
if isinstance(element, list):
|
|
return [saved_element(v) for v in element]
|
|
return element
|
|
|
|
|
|
def saved_starts(starts):
|
|
"""The starts chunk_dump can place as saved (every piece a jigsaw piece), in HORIZON_SAVED_STARTS's form."""
|
|
return [{'id': s['id'], 'chunk': s['start_chunk'],
|
|
'pieces': [{**p, 'pool_element': saved_element(p['pool_element'])} for p in s['pieces']]}
|
|
for s in starts if s['pieces'] and all(p['id'] == 'minecraft:jigsaw' for p in s['pieces'])]
|
|
|
|
|
|
def generate(binary, resources, seed, dimension, boxes, names, saved=()):
|
|
"""Block states by position inside `boxes`, from chunk_dump's full generation of their chunks, with the
|
|
`saved` starts in place of the ones generation builds."""
|
|
env = dict(os.environ)
|
|
if saved:
|
|
path = lab.LOCAL / 'structure-layout' / 'saved-starts.json'
|
|
path.write_text(json.dumps(list(saved)))
|
|
env['HORIZON_SAVED_STARTS'] = str(path)
|
|
result = subprocess.run([str(binary), 'chunk_dump', str(resources), str(seed), dimension,
|
|
*(chunk_rect(b) for b in boxes)], capture_output=True, text=True, cwd=lab.ROOT, env=env)
|
|
if result.returncode:
|
|
raise SystemExit('chunk_dump failed: ' + result.stderr[-2000:])
|
|
rows = {} # chunk -> the heights the boxes over it need
|
|
for x0, y0, z0, x1, y1, z1 in boxes:
|
|
for cx in range(x0 >> 4, (x1 >> 4) + 1):
|
|
for cz in range(z0 >> 4, (z1 >> 4) + 1):
|
|
low, high = rows.get((cx, cz), (y0, y1))
|
|
rows[(cx, cz)] = (min(low, y0), max(high, y1))
|
|
blocks, chunk, low, high = {}, None, 0, -1
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith('chunk '):
|
|
_, x, z = line.split()
|
|
low, high = rows.get((int(x), int(z)), (0, -1))
|
|
chunk = (int(x) * 16, int(z) * 16)
|
|
elif line.startswith('y '):
|
|
_, y, row = line.split(' ', 2)
|
|
y = int(y)
|
|
if low <= y <= high:
|
|
for i, state_id in enumerate(row.split(' ')):
|
|
blocks[(chunk[0] + i % 16, y, chunk[1] + i // 16)] = names[int(state_id)]
|
|
return blocks
|
|
|
|
|
|
def oracle_blocks(start):
|
|
"""(mod world, no-mod world) block by position: the box in full, the changed positions around it."""
|
|
x0, y0, z0, x1, y1, z1 = start['bounding_box']
|
|
palette = [state(p) for p in start['blocks']['palette']]
|
|
runs = iter(index for index, count in start['blocks']['rle'] for _ in range(count))
|
|
mod = {(x, y, z): palette[next(runs)] for y in range(y0, y1 + 1) for z in range(z0, z1 + 1)
|
|
for x in range(x0, x1 + 1)}
|
|
base = dict(mod)
|
|
for x, y, z, mod_state, base_state in start['changed_vs_vanilla']:
|
|
mod[(x, y, z)], base[(x, y, z)] = state(mod_state), state(base_state)
|
|
return mod, base
|
|
|
|
|
|
REGIONS = ('pieces', 'around')
|
|
|
|
|
|
def compare(start, vanilla, modded):
|
|
"""Per region (inside a piece's box, or around them: terrain adaptation and decoration), the compared
|
|
positions and the mismatches, as (no-mod, expected, Horizon) -> count."""
|
|
mod, base = oracle_blocks(start)
|
|
x0, y0, z0, x1, y1, z1 = start['diff_box']
|
|
width, depth = x1 - x0 + 1, z1 - z0 + 1
|
|
in_piece = bytearray(width * depth * (y1 - y0 + 1)) # (y, z, x) over the diff box
|
|
for bx0, by0, bz0, bx1, by1, bz1 in (p['BB'] for p in start['pieces']):
|
|
for y in range(max(by0, y0), min(by1, y1) + 1):
|
|
for z in range(max(bz0, z0), min(bz1, z1) + 1):
|
|
row = ((y - y0) * depth + z - z0) * width - x0
|
|
lo, hi = max(bx0, x0), min(bx1, x1)
|
|
if lo <= hi:
|
|
in_piece[row + lo:row + hi + 1] = b'\x01' * (hi - lo + 1)
|
|
compared, mismatches = Counter(), {region: Counter() for region in REGIONS}
|
|
for y in range(y0, y1 + 1):
|
|
for z in range(z0, z1 + 1):
|
|
for x in range(x0, x1 + 1):
|
|
p = (x, y, z)
|
|
if p not in vanilla or p not in modded:
|
|
continue
|
|
if p in base:
|
|
if vanilla[p] != base[p]:
|
|
continue # the versions differ here
|
|
expected = mod[p]
|
|
else:
|
|
expected = vanilla[p] # the oracle saw no change
|
|
region = REGIONS[0] if in_piece[((y - y0) * depth + z - z0) * width + x - x0] else REGIONS[1]
|
|
compared[region] += 1
|
|
if modded[p] != expected:
|
|
mismatches[region][(base.get(p, vanilla[p]), expected, modded[p])] += 1
|
|
return compared, mismatches
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--profile', default='betterdungeons', choices=sorted(PROFILES))
|
|
parser.add_argument('--filter', default='', help='only structure ids matching this regex')
|
|
parser.add_argument('--limit', type=int, default=8, help='mismatch classes shown per structure')
|
|
args = parser.parse_args()
|
|
profile = PROFILES[args.profile]
|
|
results = sorted((layout.ORACLE / args.profile).glob('seed-*.json.gz'))
|
|
missing = [jar for jar in profile['mods'].values() if not layout.jar_path(jar).exists()]
|
|
if not results or missing:
|
|
print(f'SKIP: oracle results or mod jars missing under {layout.ORACLE} (see test_structure_layout_parity.py)')
|
|
return 0
|
|
resources = layout.overlay(args.profile, layout.carry(args.profile, profile['mods'], profile['carry']))
|
|
binary = layout.probe_binary()
|
|
names = state_names(lab.RESOURCES)
|
|
# structure -> [starts, compared by region, mismatches by region]
|
|
totals = defaultdict(lambda: [0, Counter(), {region: Counter() for region in REGIONS}])
|
|
for path in results:
|
|
oracle = json.load(gzip.open(path))
|
|
seed = oracle['seed']
|
|
starts = [s for s in oracle['structures'] if s['complete'] and re.search(args.filter, s['id'])]
|
|
for dimension in sorted({s['dimension'] for s in starts}):
|
|
here = [s for s in starts if s['dimension'] == dimension]
|
|
boxes = [s['diff_box'] for s in here]
|
|
dim = dimension.removeprefix('minecraft:')
|
|
vanilla = generate(binary, lab.RESOURCES, seed, dim, boxes, names)
|
|
modded = generate(binary, resources, seed, dim, boxes, names, saved_starts(here))
|
|
for start in here:
|
|
compared, mismatches = compare(start, vanilla, modded)
|
|
total = totals[start['id']]
|
|
total[0] += 1
|
|
total[1].update(compared)
|
|
for region in REGIONS:
|
|
total[2][region].update(mismatches[region])
|
|
failed = False
|
|
report = {}
|
|
for structure, (starts, compared, mismatches) in sorted(totals.items()):
|
|
wrong = {region: sum(mismatches[region].values()) for region in REGIONS}
|
|
reason = next((r for pattern, r in KNOWN.get(args.profile, {}).items() if re.search(pattern, structure)), None)
|
|
verdict = 'ok ' if not any(wrong.values()) else 'known' if reason else 'FAIL '
|
|
failed |= verdict == 'FAIL '
|
|
counts = ', '.join(f'{compared[r] - wrong[r]} of {compared[r]} {r}' for r in REGIONS)
|
|
print(f'{verdict} {structure}: {starts} starts, positions matching: {counts}'
|
|
+ (f' ({reason})' if any(wrong.values()) and reason else ''))
|
|
for region in REGIONS:
|
|
for (base, expected, got), n in mismatches[region].most_common(args.limit):
|
|
print(f' {region:6} {n:6} no-mod {base} expected {expected} Horizon {got}')
|
|
report[structure] = {'starts': starts, 'compared': compared,
|
|
'mismatches': {r: [[*k, n] for k, n in mismatches[r].most_common()] for r in REGIONS}}
|
|
# Every mismatch class, for a closer look than the summary.
|
|
out = lab.LOCAL / 'structure-layout' / args.profile / 'blocks-report.json'
|
|
out.write_text(json.dumps(report, indent=1))
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|