Files
horizon-airdef/tools/test_structure_blocks_parity.py
T
Ludovic Marie f80f4e01e7 Structure blocks harness: the blocks Horizon's structure mods place against the structure oracle's
The clean-room structure oracle saved, for each start of a mod world, the
blocks of its box and every position around it that differs from the no-mod
world of the same seed. tools/test_structure_blocks_parity.py generates the
same chunks in full with Horizon twice (26.3 alone, and with the mods carried
by horizon-port-pack as players' packs are) and compares: a position counts
only where Horizon's no-mod world equals the oracle's (1.21.1 and 26.3 differ
elsewhere), and a mismatch is reported by its no-mod, expected and Horizon
blocks, so a missing processor rule reads as one line.

Better Dungeons (3 seeds, 96 complete starts): every structure is listed as
known, with the reason: the placeholder processors, the biome modifier and the
mod's own structure types are not implemented yet.

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

181 lines
8.5 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.
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] [--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
# 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',
},
}
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 generate(binary, resources, seed, dimension, boxes, names):
"""Block states by position inside `boxes`, from chunk_dump's full generation of their chunks."""
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)
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
def inside(box, p):
return all(box[i] <= p[i] <= box[i + 3] for i in range(3))
def compare(start, vanilla, modded):
"""Compared positions and the mismatches, as (no-mod, expected, Horizon) -> count."""
mod, base = oracle_blocks(start)
compared, mismatches = 0, Counter()
x0, y0, z0, x1, y1, z1 = start['diff_box']
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
compared += 1
if modded[p] != expected:
mismatches[(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=[p for p, v in layout.PROFILES.items() if v['carry']])
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 = layout.PROFILES[args.profile]
results = sorted((layout.ORACLE / args.profile).glob('seed-*.json.gz'))
missing = [jar for jar in profile['mods'].values() if not (layout.JARS / 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)
totals = defaultdict(lambda: [0, 0, Counter()]) # structure -> [starts, compared, mismatches]
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)
for start in here:
compared, mismatches = compare(start, vanilla, modded)
total = totals[start['id']]
total[0] += 1
total[1] += compared
total[2].update(mismatches)
failed = False
for structure, (starts, compared, mismatches) in sorted(totals.items()):
wrong = sum(mismatches.values())
reason = next((r for pattern, r in KNOWN.get(args.profile, {}).items() if re.search(pattern, structure)), None)
verdict = 'ok ' if not wrong else 'known' if reason else 'FAIL '
failed |= verdict == 'FAIL '
print(f'{verdict} {structure}: {starts} starts, {compared - wrong} of {compared} positions match'
+ (f' ({reason})' if wrong and reason else ''))
for (base, expected, got), n in mismatches.most_common(args.limit):
print(f' {n:6} no-mod {base} expected {expected} Horizon {got}')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())