874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
243 lines
11 KiB
Python
243 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Dev-only NeoForge oracle for Horizon's mod-port harnesses (docs/modpacks.md).
|
|
|
|
neoforge_oracle.py export --jars DIR [--deps DIR] [--namespaces a,b] --out FACTS.json
|
|
|
|
installs a NeoForge server under .local/neoforge-oracle (once), builds the exporter mod in
|
|
mods/horizon/src/neoforge-oracle, starts the server headless with the given mod jars, and writes every
|
|
block, item, recipe and tag of the mods' namespaces (and the vanilla blocks, for templates) to
|
|
FACTS.json. The jars are the user's own; nothing here is shipped or committed.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
LOCAL = ROOT / '.local' / 'neoforge-oracle'
|
|
SOURCE = ROOT / 'mods' / 'horizon' / 'src' / 'neoforge-oracle'
|
|
# ATM10 8.x: Minecraft 1.21.1 on NeoForge 21.1.259 (docs/modpacks.md).
|
|
NEOFORGE = os.environ.get('HORIZON_NEOFORGE', '21.1.259')
|
|
MAVEN = 'https://maven.neoforged.net/releases/net/neoforged/neoforge'
|
|
|
|
|
|
def install():
|
|
"""The NeoForge server, installed once per version."""
|
|
home = LOCAL / NEOFORGE
|
|
args = home / f'libraries/net/neoforged/neoforge/{NEOFORGE}/unix_args.txt'
|
|
if args.exists():
|
|
return home
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
installer = LOCAL / f'neoforge-{NEOFORGE}-installer.jar'
|
|
if not installer.exists():
|
|
url = f'{MAVEN}/{NEOFORGE}/neoforge-{NEOFORGE}-installer.jar'
|
|
with urllib.request.urlopen(url, timeout=120) as r:
|
|
installer.write_bytes(r.read())
|
|
with (LOCAL / f'install-{NEOFORGE}.log').open('w') as log:
|
|
subprocess.run(['java', '-jar', str(installer), '--installServer', str(home)],
|
|
cwd=LOCAL, stdout=log, stderr=subprocess.STDOUT, check=True)
|
|
return home
|
|
|
|
|
|
def client():
|
|
"""The NeoForge client (tools/perf_bench.py --game neoforge), installed once per version: the installer's
|
|
own files, then the vanilla libraries its version inherits, fetched as the launcher does (sha1-checked)."""
|
|
sys.path.insert(0, str(ROOT / 'tools'))
|
|
import hashlib
|
|
import lab
|
|
home = LOCAL / f'client-{NEOFORGE}'
|
|
profile = home / 'versions' / f'neoforge-{NEOFORGE}' / f'neoforge-{NEOFORGE}.json'
|
|
if not profile.exists():
|
|
install() # the installer jar
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / 'launcher_profiles.json').write_text('{"profiles":{}}')
|
|
with (LOCAL / f'install-client-{NEOFORGE}.log').open('w') as log:
|
|
subprocess.run(['java', '-jar', str(LOCAL / f'neoforge-{NEOFORGE}-installer.jar'), '--installClient',
|
|
str(home)], cwd=LOCAL, stdout=log, stderr=subprocess.STDOUT, check=True)
|
|
neo = json.loads(profile.read_text())
|
|
base = json.loads((home / 'versions' / neo['inheritsFrom'] / f'{neo["inheritsFrom"]}.json').read_text())
|
|
jars = []
|
|
for manifest in (neo, base):
|
|
names = {library['name'] for library in manifest['libraries']}
|
|
for library in manifest['libraries']:
|
|
artifact = library.get('downloads', {}).get('artifact')
|
|
if not artifact or not lab.library_allowed(library) or not lab.native_library_matches(library, None, names):
|
|
continue
|
|
path = home / 'libraries' / artifact['path']
|
|
if not path.exists():
|
|
with urllib.request.urlopen(artifact['url'], timeout=120) as r:
|
|
data = r.read()
|
|
if hashlib.sha1(data).hexdigest() != artifact['sha1']:
|
|
sys.exit(f'{artifact["url"]}: sha1 mismatch')
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
if path not in jars:
|
|
jars.append(path)
|
|
return home, neo, base, jars
|
|
|
|
|
|
def client_command(java, heap, game_dir, natives, assets, jvm=(), game=()):
|
|
"""The command the launcher would run for the NeoForge client profile, with no account."""
|
|
home, neo, base, jars = client()
|
|
import lab
|
|
values = {'library_directory': str(home / 'libraries'), 'classpath_separator': os.pathsep,
|
|
'version_name': f'neoforge-{NEOFORGE}', 'natives_directory': str(natives),
|
|
'launcher_name': 'horizon-bench', 'launcher_version': '1'}
|
|
expand = lambda s: s.replace('${', '{').format(**values)
|
|
command = [str(java), f'-Xmx{heap}']
|
|
for argument in base['arguments']['jvm']:
|
|
if isinstance(argument, dict):
|
|
if lab.library_allowed(argument):
|
|
command += [argument['value']] if isinstance(argument['value'], str) else argument['value']
|
|
elif argument == '${classpath}':
|
|
# Every library; not the vanilla jar, which NeoForge's patched client replaces.
|
|
command.append(os.pathsep.join(map(str, jars)))
|
|
else:
|
|
command.append(expand(argument))
|
|
command += [expand(a) for a in neo['arguments']['jvm']]
|
|
command += [*jvm, neo['mainClass']]
|
|
command += ['--username', 'Bench', '--uuid', '00000000000000000000000000000000', '--accessToken', '0',
|
|
'--version', f'neoforge-{NEOFORGE}', '--gameDir', str(game_dir), '--assetsDir', str(assets),
|
|
'--assetIndex', base['assetIndex']['id'], '--userType', 'msa', '--versionType', 'release',
|
|
*neo['arguments']['game'], *game]
|
|
return home, command
|
|
|
|
|
|
def build_bench(home):
|
|
"""The NeoForge benchmark driver (mods/horizon/src/neoforge-bench), against the client."""
|
|
return build(home, ROOT / 'mods/horizon/src/neoforge-bench', 'horizon-bench-neoforge.jar', client_classpath(home))
|
|
|
|
|
|
def client_classpath(home):
|
|
libs = home / 'libraries'
|
|
first = [libs / f'net/neoforged/neoforge/{NEOFORGE}/neoforge-{NEOFORGE}-client.jar',
|
|
libs / f'net/neoforged/neoforge/{NEOFORGE}/neoforge-{NEOFORGE}-universal.jar']
|
|
rest = sorted(p for p in libs.rglob('*.jar') if p not in first and 'slim' not in p.name
|
|
and 'extra' not in p.name and not p.name.endswith('-unpacked.jar'))
|
|
return os.pathsep.join(map(str, first + rest))
|
|
|
|
|
|
def classpath(home):
|
|
libs = home / 'libraries'
|
|
first = [libs / f'net/neoforged/neoforge/{NEOFORGE}/neoforge-{NEOFORGE}-server.jar',
|
|
libs / f'net/neoforged/neoforge/{NEOFORGE}/neoforge-{NEOFORGE}-universal.jar']
|
|
rest = sorted(p for p in libs.rglob('*.jar') if p not in first and 'slim' not in p.name
|
|
and 'extra' not in p.name and not p.name.endswith('-unpacked.jar'))
|
|
return os.pathsep.join(map(str, first + rest))
|
|
|
|
|
|
def build(home, source=SOURCE, name='horizon-oracle.jar', cp=None):
|
|
"""A dev-only mod jar (by default the exporter), rebuilt when its sources change."""
|
|
out = LOCAL / 'build'
|
|
jar = out / name
|
|
sources = sorted((source / 'java').rglob('*.java'))
|
|
resources = sorted(p for p in (source / 'resources').rglob('*') if p.is_file())
|
|
newest = max(p.stat().st_mtime for p in sources + resources)
|
|
if jar.exists() and jar.stat().st_mtime > newest:
|
|
return jar
|
|
classes = out / f'classes-{Path(name).stem}'
|
|
shutil.rmtree(classes, ignore_errors=True)
|
|
classes.mkdir(parents=True)
|
|
javac = subprocess.run(['javac', '--release', '21', '-proc:none', '-nowarn', '-cp', cp or classpath(home),
|
|
'-d', str(classes), *map(str, sources)], capture_output=True, text=True)
|
|
if javac.returncode:
|
|
sys.exit(f'{name} does not compile:\n{javac.stdout}{javac.stderr}')
|
|
with zipfile.ZipFile(jar, 'w', zipfile.ZIP_DEFLATED) as z:
|
|
for p in sorted(classes.rglob('*.class')):
|
|
z.write(p, p.relative_to(classes).as_posix())
|
|
for p in resources:
|
|
z.write(p, p.relative_to(source / 'resources').as_posix())
|
|
return jar
|
|
|
|
|
|
SERVER_PROPERTIES = """level-type=minecraft\\:flat
|
|
online-mode=false
|
|
server-port=0
|
|
spawn-protection=0
|
|
view-distance=2
|
|
simulation-distance=2
|
|
generate-structures=false
|
|
spawn-monsters=false
|
|
spawn-animals=false
|
|
"""
|
|
|
|
|
|
def instance(name, jars, extra=()):
|
|
"""A server folder with `jars` (and the exporter) in mods/."""
|
|
home = install()
|
|
run = LOCAL / 'runs' / name
|
|
shutil.rmtree(run, ignore_errors=True)
|
|
(run / 'mods').mkdir(parents=True)
|
|
(run / 'libraries').symlink_to(home / 'libraries')
|
|
(run / 'eula.txt').write_text('eula=true\n')
|
|
(run / 'server.properties').write_text(SERVER_PROPERTIES)
|
|
for jar in [*jars, *extra]:
|
|
(run / 'mods' / Path(jar).name).symlink_to(Path(jar).resolve())
|
|
return home, run
|
|
|
|
|
|
def start(home, run, props, log_name='server.log', timeout=900):
|
|
"""Runs the server to its end; returns (seconds, the log)."""
|
|
args = home / f'libraries/net/neoforged/neoforge/{NEOFORGE}/unix_args.txt'
|
|
command = ['java', '-Xmx4G', *[f'-D{k}={v}' for k, v in props.items()], f'@{args}', 'nogui']
|
|
began = time.monotonic()
|
|
with (run / log_name).open('w') as log:
|
|
subprocess.run(command, cwd=run, stdin=subprocess.DEVNULL, stdout=log,
|
|
stderr=subprocess.STDOUT, timeout=timeout)
|
|
return time.monotonic() - began, (run / log_name).read_text(errors='replace')
|
|
|
|
|
|
def mod_ids(jar):
|
|
"""The mod ids a jar declares (its neoforge.mods.toml)."""
|
|
import tomllib
|
|
with zipfile.ZipFile(jar) as z:
|
|
for name in ('META-INF/neoforge.mods.toml', 'META-INF/mods.toml'):
|
|
if name in z.namelist():
|
|
try:
|
|
toml = tomllib.loads(z.read(name).decode('utf-8', 'replace'))
|
|
except tomllib.TOMLDecodeError:
|
|
return []
|
|
return [m.get('modId', '') for m in toml.get('mods', [])]
|
|
return []
|
|
|
|
|
|
def export(args):
|
|
jars = sorted(Path(args.jars).glob('*.jar'))
|
|
deps = sorted(Path(args.deps).glob('*.jar')) if args.deps else []
|
|
namespaces = args.namespaces or ','.join(i for j in jars for i in mod_ids(j))
|
|
home = install()
|
|
exporter = build(home)
|
|
home, run = instance('export', jars, [*deps, exporter])
|
|
out = Path(args.out).resolve()
|
|
out.unlink(missing_ok=True)
|
|
seconds, log = start(home, run, {'horizon.oracle.out': out, 'horizon.oracle.namespaces': namespaces})
|
|
if not out.exists():
|
|
tail = '\n'.join(log.splitlines()[-40:])
|
|
sys.exit(f'the oracle wrote nothing; the end of {run / "server.log"}:\n{tail}')
|
|
facts = json.loads(out.read_text())
|
|
mods = sum(1 for b in facts['blocks'] if not b.startswith('minecraft:'))
|
|
print(f'{out}: {mods} mod blocks, {len(facts["items"])} items, {len(facts["recipes"])} recipes '
|
|
f'({namespaces}); server ran {seconds:.1f} s')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest='command', required=True)
|
|
e = sub.add_parser('export', help='write the facts of the mods in --jars')
|
|
e.add_argument('--jars', required=True, help='the mods to export')
|
|
e.add_argument('--deps', help='their required libraries (loaded, not exported)')
|
|
e.add_argument('--namespaces', help='comma-separated; default: the jars\' mod ids')
|
|
e.add_argument('--out', required=True)
|
|
args = parser.parse_args()
|
|
{'export': export}[args.command](args)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|