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

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