mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
269 lines
13 KiB
Python
269 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Build Horizon's native mods (native/<name>/) into Horizon, package them, and prove them in a running game.
|
|
|
|
python3 native/tools/native.py check zoom # apply on the pinned Horizon, cargo check the game
|
|
python3 native/tools/native.py check zoom minimap ... # several mods applied together, as a player's set is
|
|
python3 native/tools/native.py build zoom # ... and build it (fast profile); prints the binary
|
|
python3 native/tools/native.py package zoom # native/target/<id>-<version>.hnmod for the registry
|
|
python3 native/tools/native.py proof --resources PATH zoom # build, then drive the game with horizon-ctl
|
|
--horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of the mods'
|
|
compatibility.json: they use only the native hook points, so they build on any Horizon whose
|
|
NATIVE_HOOKS_API is at least the one they name, as the launcher does. Without it, mods pinned
|
|
to different commits build on the commit of those that need the most hook points.
|
|
`all` names every mod in native/.
|
|
|
|
Horizon's source comes from GitHub's archive of the commit, never a local checkout; horizon-mixins is built once at
|
|
the pinned commit. Everything goes under native/target/, shared by the mods.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tomllib
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
NATIVE = Path(__file__).resolve().parents[1]
|
|
TARGET = NATIVE / "target"
|
|
REPO = "horizon-rust/horizon"
|
|
HOOKS_FILE = "crates/horizon-client/src/game/native_hooks.rs"
|
|
HOOKS_LINE = re.compile(r"^pub\(crate\) const NATIVE_HOOKS_API: u32 = (\d+);$", re.M)
|
|
|
|
|
|
class Mod:
|
|
"""native/<name>/: its compatibility.json and its one mixin bundle."""
|
|
|
|
def __init__(self, name):
|
|
self.root = NATIVE / name
|
|
if not (self.root / "compatibility.json").is_file():
|
|
sys.exit(f"no mod native/{name}/ (compatibility.json)")
|
|
self.compat = json.loads((self.root / "compatibility.json").read_text())
|
|
mixins = self.root / "mixins"
|
|
self.bundle = next(p for p in mixins.iterdir() if (p / "horizon-mixin.toml").is_file())
|
|
manifest = tomllib.loads((self.bundle / "horizon-mixin.toml").read_text())
|
|
self.manifest = manifest
|
|
self.id = manifest["mod"]["id"]
|
|
self.version = manifest["mod"]["version"]
|
|
|
|
|
|
def all_mods():
|
|
return sorted(p.name for p in NATIVE.iterdir() if (p / "compatibility.json").is_file())
|
|
|
|
|
|
def log(*args):
|
|
print(*args, file=sys.stderr, flush=True)
|
|
|
|
|
|
def get(url):
|
|
request = urllib.request.Request(url, headers={"User-Agent": "horizon-native-mod"})
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
if token and url.startswith("https://api.github.com/"):
|
|
request.add_header("Authorization", f"Bearer {token}")
|
|
with urllib.request.urlopen(request, timeout=300) as response:
|
|
return response.read()
|
|
|
|
|
|
def latest_nightly():
|
|
"""The commit of the newest Nightly: its permanent nightly-source/<date>-<short> tag.
|
|
|
|
The newest is picked by the tag's date, and a same-date tie by the tagged commit's own
|
|
date: comparing the ref name alone breaks a tie by the short SHA, which can be an earlier
|
|
build than the day's latest nightly.
|
|
"""
|
|
refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/"))
|
|
def nightly_date(ref):
|
|
return ref.split("nightly-source/")[1][:10]
|
|
newest_day = max(nightly_date(r["ref"]) for r in refs)
|
|
same_day = [r for r in refs if nightly_date(r["ref"]) == newest_day]
|
|
if len(same_day) == 1:
|
|
newest = same_day[0]
|
|
else:
|
|
def commit_time(r):
|
|
commit = json.loads(get(f"https://api.github.com/repos/{REPO}/commits/{r['object']['sha']}"))
|
|
return commit["commit"]["committer"]["date"]
|
|
newest = max(same_day, key=commit_time)
|
|
return get_commit(newest["ref"].removeprefix("refs/tags/"))
|
|
|
|
|
|
def get_commit(ref):
|
|
return json.loads(get(f"https://api.github.com/repos/{REPO}/commits/{ref}"))["sha"]
|
|
|
|
|
|
def source(revision):
|
|
"""Horizon's source at `revision`, unpacked once into target/horizon/<revision>/."""
|
|
out = TARGET / "horizon" / revision
|
|
if (out / ".horizon-revision").is_file():
|
|
return out
|
|
log(f"Downloading Horizon {revision[:7]}")
|
|
data = get(f"https://codeload.github.com/{REPO}/tar.gz/{revision}")
|
|
partial = TARGET / "horizon" / f".{revision}.partial"
|
|
shutil.rmtree(partial, ignore_errors=True)
|
|
partial.mkdir(parents=True)
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
|
|
archive.extractall(partial, filter="data")
|
|
top = next(partial.iterdir())
|
|
# horizon-mixins refuses links; the source has a few (docs), none needed to build.
|
|
for path in top.rglob("*"):
|
|
if path.is_symlink():
|
|
path.unlink()
|
|
(top / ".horizon-revision").write_text(revision)
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
top.rename(out)
|
|
shutil.rmtree(partial)
|
|
return out
|
|
|
|
|
|
def hooks_api(tree):
|
|
found = HOOKS_LINE.search((tree / HOOKS_FILE).read_text()) if (tree / HOOKS_FILE).is_file() else None
|
|
return int(found.group(1)) if found else None
|
|
|
|
|
|
def tool(mods):
|
|
"""horizon-mixins at the mods' pinned commit, built once."""
|
|
revs = {m.compat["horizon_mixins_revision"] for m in mods}
|
|
if len(revs) != 1:
|
|
sys.exit(f"the mods pin different horizon-mixins commits: {sorted(revs)}")
|
|
rev = revs.pop()
|
|
root = TARGET / "horizon-mixins" / rev
|
|
binary = root / "bin" / "horizon-mixins"
|
|
if not binary.is_file():
|
|
log(f"Building horizon-mixins {rev[:7]}")
|
|
repo = mods[0].compat["horizon_mixins_repository"]
|
|
subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", repo, "--rev", rev, "--root", str(root),
|
|
"horizon-mixins-cli"], check=True)
|
|
return binary
|
|
|
|
|
|
def stage(mods, revision):
|
|
"""The mods applied together on Horizon `revision`: target/stage/<ids>/ (cargo's incremental state survives)."""
|
|
tree = source(revision)
|
|
api = hooks_api(tree)
|
|
# Hook points are only ever added: any later version has the ones these mods hook.
|
|
for m in mods:
|
|
if api is None or api < m.compat["hooks"]:
|
|
sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, {m.id} needs v{m.compat['hooks']}.")
|
|
key = "+".join(m.id for m in mods)
|
|
# As the launcher does for mods on the hook points: pin the copies to the commit they build on.
|
|
bundles = TARGET / "mods" / key
|
|
shutil.rmtree(bundles, ignore_errors=True)
|
|
for m in mods:
|
|
shutil.copytree(m.bundle, bundles / m.id)
|
|
toml = bundles / m.id / "horizon-mixin.toml"
|
|
toml.write_text(re.sub(r'horizon_revision = "[0-9a-f]{40}"', f'horizon_revision = "{revision}"',
|
|
toml.read_text()))
|
|
fresh = (TARGET / "stage.new").resolve()
|
|
shutil.rmtree(fresh, ignore_errors=True)
|
|
subprocess.run([str(tool(mods)), "apply", "--workspace", str(tree), "--mods", str(bundles), "--out", str(fresh)],
|
|
check=True, stdout=subprocess.DEVNULL)
|
|
out = TARGET / "stage" / key
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# Only changed files are replaced, so cargo rebuilds only what changed.
|
|
subprocess.run(["rsync", "-a", "--checksum", "--delete", f"{fresh}/", f"{out}/"], check=True)
|
|
shutil.rmtree(fresh)
|
|
return out
|
|
|
|
|
|
def cargo(stage_dir, action):
|
|
jobs = os.environ.get("FLEET_LOAD_JOBS") or os.environ.get("CARGO_BUILD_JOBS")
|
|
command = ["cargo", action, "--locked", "-p", "horizon-client", "--bin", "horizon-client"]
|
|
if action == "build":
|
|
# Newer Horizon moved the control tool into the reusable client library.
|
|
library = stage_dir / "crates/horizon-client-lib/Cargo.toml"
|
|
if library.is_file() and any(b.get("name") == "horizon-ctl" for b in tomllib.loads(library.read_text()).get("bin", [])):
|
|
command += ["-p", "horizon-client-lib"]
|
|
command += ["--bin", "horizon-ctl", "--profile", "fast"]
|
|
if os.environ.get("NATIVE_NO_DEFAULT_FEATURES"):
|
|
command.append("--no-default-features")
|
|
if jobs:
|
|
command += ["-j", jobs]
|
|
env = dict(os.environ, CARGO_TARGET_DIR=str(TARGET / "cargo"), RUSTFLAGS="-D warnings")
|
|
subprocess.run(command, cwd=stage_dir, env=env, check=True)
|
|
return TARGET / "cargo" / "fast"
|
|
|
|
|
|
def package(m):
|
|
"""A deterministic .hnmod: horizon-native.json, the bundle's .rs and .toml files (its crates' too) and the mod's
|
|
LICENSE as mixins/<id>/LICENSE, so the licence's notice travels with the sources (Horizon's bundle reader
|
|
accepts LICENSE* files there)."""
|
|
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
|
|
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
|
|
"hooks": m.compat["hooks"]}
|
|
pinned = m.manifest.get("compatibility", {}).get("horizon_revision")
|
|
if pinned != m.compat["horizon_revision"]:
|
|
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
|
|
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
|
|
# The helpers and modules, and the sources of the bundle's own crates (horizon-mixins 0.6.0).
|
|
for path in sorted(m.bundle.rglob("*")):
|
|
if path.is_file() and path.suffix in (".rs", ".toml"):
|
|
files[f"mixins/{m.id}/{path.relative_to(m.bundle).as_posix()}"] = path.read_bytes()
|
|
licence = m.root / "LICENSE"
|
|
if licence.is_file():
|
|
files.setdefault(f"mixins/{m.id}/LICENSE", licence.read_bytes())
|
|
out = TARGET / f"{m.id}-{m.version}.hnmod"
|
|
TARGET.mkdir(exist_ok=True)
|
|
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
for name, content in sorted(files.items()):
|
|
info = zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0))
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
info.external_attr = 0o100644 << 16
|
|
archive.writestr(info, content)
|
|
print(json.dumps({"file": str(out), "size": out.stat().st_size,
|
|
"sha256": hashlib.sha256(out.read_bytes()).hexdigest()}, indent=2))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("action", choices=["check", "build", "package", "proof"])
|
|
parser.add_argument("mods", nargs="+", help="folders of native/ (`all`: every one)")
|
|
parser.add_argument("--horizon", help="a Horizon commit, `latest` (newest Nightly) or a tag (default: the mods' own)")
|
|
parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3")
|
|
args = parser.parse_args()
|
|
names = all_mods() if args.mods == ["all"] else args.mods
|
|
mods = [Mod(n) for n in names]
|
|
if args.action == "package":
|
|
for m in mods:
|
|
package(m)
|
|
return
|
|
if args.horizon is None:
|
|
# Mods on the hook points build on any Horizon with the hook points they need, as the launcher builds
|
|
# them: the commit of the mods that need the most has them all (stage checks each mod's need).
|
|
newest = max(m.compat["hooks"] for m in mods)
|
|
revs = {m.compat["horizon_revision"] for m in mods if m.compat["hooks"] == newest}
|
|
if len(revs) != 1:
|
|
sys.exit(f"the mods were tested on different Horizons {sorted(revs)}: pass --horizon")
|
|
revision = revs.pop()
|
|
else:
|
|
revision = latest_nightly() if args.horizon == "latest" else args.horizon
|
|
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
|
revision = get_commit(revision)
|
|
log(f"{', '.join(f'{m.id} {m.version}' for m in mods)} on Horizon {revision[:7]}")
|
|
out = stage(mods, revision)
|
|
if args.action == "check":
|
|
cargo(out, "check")
|
|
print(json.dumps({"mods": [m.id for m in mods], "horizon": revision, "check": "ok"}))
|
|
return
|
|
binaries = cargo(out, "build")
|
|
if args.action == "build":
|
|
print(json.dumps({"mods": [m.id for m in mods], "horizon": revision, "game": str(binaries / "horizon-client")}))
|
|
return
|
|
if not args.resources:
|
|
sys.exit("proof needs --resources (prepared resources for Minecraft 26.3)")
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
import importlib.util
|
|
for m in mods:
|
|
spec = importlib.util.spec_from_file_location(f"proof_{m.id}", m.root / "proof.py")
|
|
proof = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(proof)
|
|
proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof" / m.id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|