874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
218 lines
11 KiB
Python
218 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-mod parity harness for ported mods (docs/modpacks.md, "The Java oracle and per-mod harnesses").
|
|
|
|
mod_port_harness.py --work WORK [--resources PREPARED] [--jars DIR --deps DIR] [MOD ...]
|
|
|
|
WORK is a `horizon-port-pack` work folder whose mods were carried over. With --jars the NeoForge
|
|
oracle (tools/neoforge_oracle.py, dev-only) first exports WORK/oracle/facts.json. The Horizon side
|
|
is crates/horizon-client/examples/mod_port_probe.rs: the content root built from WORK/mods/*/carry
|
|
and what the game's loaders make of it. For every block and item the mod registers, compared:
|
|
|
|
- blocks: declared in Horizon at all; per state hardness, explosion resistance, light, tool need,
|
|
friction, map colour, sounds, outline and collision shapes;
|
|
- drops: the loot of each sampled state for hand, diamond tools, shears and silk touch;
|
|
- placement: the state the block takes when placed, against its template's in the same oracle
|
|
(Horizon places the template as vanilla does, which the vanilla harnesses check);
|
|
- tags: the vanilla block tags (tool and mining tags) holding the mod's blocks;
|
|
- recipes: each recipe's result, count and ingredients;
|
|
- items: present, and their stack size.
|
|
|
|
Each mod is probed on its own, against vanilla. Writes WORK/mods/<id>/harness/{horizon,result}.json
|
|
per mod and prints a table. Jars, facts and results stay
|
|
in WORK, outside the repository.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import math
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CHECKS = ['blocks', 'properties', 'sounds', 'shapes', 'drops', 'placement', 'tags', 'recipes', 'items']
|
|
EXAMPLES = 8
|
|
|
|
|
|
def key(k):
|
|
return ','.join(sorted(p for p in k.split(',') if p))
|
|
|
|
|
|
def cells(boxes, grid=32):
|
|
out = set()
|
|
for b in boxes:
|
|
lo = [math.floor(round(v * grid, 6)) for v in b[:3]]
|
|
hi = [math.ceil(round(v * grid, 6)) for v in b[3:]]
|
|
for x in range(lo[0], hi[0]):
|
|
for y in range(lo[1], hi[1]):
|
|
for z in range(lo[2], hi[2]):
|
|
out.add((x, y, z))
|
|
return out
|
|
|
|
|
|
def same_shape(a, b):
|
|
if a is None or b is None:
|
|
return a == b
|
|
sa, sb = sorted(map(tuple, a)), sorted(map(tuple, b))
|
|
if sa == sb:
|
|
return True
|
|
return cells(a) == cells(b)
|
|
|
|
|
|
class Check:
|
|
def __init__(self):
|
|
self.passed = 0
|
|
self.failed = 0
|
|
self.examples = []
|
|
|
|
def record(self, ok, what):
|
|
if ok:
|
|
self.passed += 1
|
|
else:
|
|
self.failed += 1
|
|
if len(self.examples) < EXAMPLES:
|
|
self.examples.append(what)
|
|
|
|
def json(self):
|
|
return {'passed': self.passed, 'failed': self.failed, 'examples': self.examples}
|
|
|
|
|
|
def sound_upgrades(oracle, horizon):
|
|
"""The oracle version's vanilla sound sets → this version's, through the vanilla blocks both have."""
|
|
out = {}
|
|
for name, hs in horizon.get('vanilla_sounds', {}).items():
|
|
ob = oracle['blocks'].get(name)
|
|
if ob:
|
|
os_ = ob['states'].get(ob['default']) or next(iter(ob['states'].values()))
|
|
out.setdefault(json.dumps(os_['sound'], sort_keys=True), []).append(hs)
|
|
return out
|
|
|
|
|
|
def compare_mod(mod, oracle, horizon, carry, upgrades, old_items):
|
|
checks = {c: Check() for c in CHECKS}
|
|
prefix = mod + ':'
|
|
templates = {prefix + b: t for b, t in carry.get('blocks', {}).items()}
|
|
vanilla_tags = {t: set(m) for t, m in oracle['tags']['block'].items() if t.startswith('minecraft:')}
|
|
for name, ob in oracle['blocks'].items():
|
|
if not name.startswith(prefix):
|
|
continue
|
|
hb = horizon['blocks'].get(name)
|
|
checks['blocks'].record(hb is not None, f'{name}: not in Horizon ({carry.get("blocks_unknown", {}).get(name[len(prefix):], "left out")})')
|
|
if hb is None:
|
|
continue
|
|
for k, os_ in ob['states'].items():
|
|
hs = hb['states'].get(k)
|
|
if hs is None:
|
|
checks['properties'].record(False, f'{name}[{k}]: no such state in Horizon')
|
|
continue
|
|
props = [('hardness', os_['hardness'], hs['hardness']),
|
|
('explosion_resistance', ob['explosion_resistance'], hs['explosion_resistance']),
|
|
('emission', os_['emission'], hs['emission']),
|
|
('requires_tool', os_['requires_tool'], hs['requires_tool']),
|
|
('friction', ob['friction'], hs['friction']),
|
|
('map_color', os_['map_color_id'], hs['map_color_id'])]
|
|
bad = [f'{p} {o} vs {h}' for p, o, h in props
|
|
if (abs(o - h) > 1e-6 if isinstance(o, float) else o != h)]
|
|
checks['properties'].record(not bad, f'{name}[{k}]: ' + '; '.join(bad))
|
|
upgraded = upgrades.get(json.dumps(os_['sound'], sort_keys=True), [])
|
|
checks['sounds'].record(os_['sound'] == hs['sound'] or hs['sound'] in upgraded,
|
|
f'{name}[{k}]: {os_["sound"]["break"]} vs {hs["sound"]["break"]}')
|
|
shapes_ok = same_shape(os_['outline'], hs['outline']) and same_shape(os_['collision'], hs['collision'])
|
|
checks['shapes'].record(shapes_ok, f'{name}[{k}]: outline {os_["outline"]} vs {hs["outline"]}')
|
|
for tool, odrops in os_.get('drops', {}).items():
|
|
hdrops = (hs.get('drops') or {}).get(tool)
|
|
checks['drops'].record(odrops == hdrops, f'{name}[{k}] {tool}: {odrops} vs {hdrops}')
|
|
# Placement: the mod block against its template, both in the oracle.
|
|
template = templates.get(name)
|
|
ot = oracle['blocks'].get(template) if template else None
|
|
if ot and 'placement' in ob:
|
|
for face, placed in ob['placement'].items():
|
|
theirs = ot.get('placement', {}).get(face)
|
|
checks['placement'].record(
|
|
placed is not None and theirs is not None and key(placed) == key(theirs),
|
|
f'{name} on {face}: {placed} vs {template} {theirs}')
|
|
for tag, members in vanilla_tags.items():
|
|
if name in members:
|
|
checks['tags'].record(name in horizon['block_tags'].get(tag, []), f'{name}: not in #{tag}')
|
|
for rid, orec in oracle['recipes'].items():
|
|
if not rid.startswith(prefix):
|
|
continue
|
|
hrec = horizon['recipes'].get(rid)
|
|
if hrec is None:
|
|
checks['recipes'].record(False, f'{rid} ({orec["type"]}): not loaded')
|
|
continue
|
|
# Vanilla items added after the oracle's version (pale oak in a log tag) are not the mod's.
|
|
newer = lambda i: i.startswith('minecraft:') and old_items and i not in old_items
|
|
oing = sorted(sorted(i) for i in orec['ingredients'] if i)
|
|
hing = sorted(sorted(x for x in i if not newer(x)) for i in hrec.get('ingredients', []) if i)
|
|
ok = (orec['result'], orec['count']) == (hrec.get('result'), hrec.get('count')) and oing == hing
|
|
why = f'{rid}: {orec["result"]} x{orec["count"]} vs {hrec.get("result")} x{hrec.get("count")}'
|
|
if oing != hing:
|
|
missing = [i for i in oing if i not in hing][:2]
|
|
why += f'; ingredients differ, e.g. oracle {missing}'
|
|
checks['recipes'].record(ok, why)
|
|
for iid, oi in oracle['items'].items():
|
|
if not iid.startswith(prefix):
|
|
continue
|
|
hi = horizon['items'].get(iid)
|
|
checks['items'].record(hi is not None and hi['max_stack_size'] == oi['max_stack_size'],
|
|
f'{iid}: {"missing" if hi is None else "stack " + str(hi["max_stack_size"])}')
|
|
result = {c: v.json() for c, v in checks.items()}
|
|
failed = sum(v.failed for v in checks.values())
|
|
total = sum(v.passed + v.failed for v in checks.values())
|
|
return {'mod': mod, 'passed': failed == 0 and total > 0, 'checks': result,
|
|
'blocks_with_differences': len(carry.get('differences', {}))}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--work', required=True)
|
|
parser.add_argument('--resources', default=str(ROOT / '.local/horizon-client/26.3'))
|
|
parser.add_argument('--jars', help='export the oracle facts from these jars first')
|
|
parser.add_argument('--deps')
|
|
parser.add_argument('mods', nargs='*')
|
|
args = parser.parse_args()
|
|
work = Path(args.work).resolve()
|
|
facts = work / 'oracle' / 'facts.json'
|
|
if args.jars:
|
|
facts.parent.mkdir(parents=True, exist_ok=True)
|
|
command = [sys.executable, str(ROOT / 'tools/neoforge_oracle.py'), 'export', '--jars', args.jars, '--out', str(facts)]
|
|
if args.deps:
|
|
command += ['--deps', args.deps]
|
|
subprocess.run(command, check=True)
|
|
if not facts.exists():
|
|
sys.exit(f'{facts}: no oracle facts (run with --jars, or tools/neoforge_oracle.py export)')
|
|
subprocess.run(['cargo', 'build', '--release', '-q', '-p', 'horizon-client', '--example', 'mod_port_probe'],
|
|
cwd=ROOT, check=True)
|
|
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
|
|
cwd=ROOT, text=True))
|
|
probe = Path(metadata['target_directory']) / 'release/examples/mod_port_probe'
|
|
oracle = json.loads(facts.read_text())
|
|
old_items = set(oracle.get('vanilla_items', []))
|
|
mods = args.mods or sorted(p.name for p in (work / 'mods').iterdir() if (p / 'carry').is_dir())
|
|
rows = []
|
|
for mod in mods:
|
|
# One mod at a time: each against vanilla, as a player adding it would load it (and a pack's
|
|
# sum may pass the block states a world holds, which the probe would refuse).
|
|
out = work / 'mods' / mod / 'harness' / 'horizon.json'
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run([str(probe), args.resources, str(work), str(out), mod], check=True)
|
|
horizon = json.loads(out.read_text())
|
|
carry_report = work / 'mods' / mod / 'carry-report.json'
|
|
carry = json.loads(carry_report.read_text()) if carry_report.exists() else {}
|
|
rows.append(compare_mod(mod, oracle, horizon, carry, sound_upgrades(oracle, horizon), old_items))
|
|
print(f'{"mod":<16} {"result":<7} ' + ' '.join(f'{c:>12}' for c in CHECKS))
|
|
for result in rows:
|
|
mod = result['mod']
|
|
(work / 'mods' / mod / 'harness').mkdir(parents=True, exist_ok=True)
|
|
(work / 'mods' / mod / 'harness' / 'result.json').write_text(json.dumps(result, indent=1))
|
|
cols = []
|
|
for c in CHECKS:
|
|
r = result['checks'][c]
|
|
n = r['passed'] + r['failed']
|
|
cols.append(f'{r["passed"]}/{n}' if n else '-')
|
|
print(f'{mod:<16} {"pass" if result["passed"] else "FAIL":<7} ' + ' '.join(f'{c:>12}' for c in cols))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|