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

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())