#!/usr/bin/env python3 """AirDef: сборка, установка и запуск на Windows, macOS и Linux. Только стандартная библиотека Python. python3 tools/airdef.py fork # скачать Horizon нужной версии и наложить патчи форка (horizon/) python3 tools/airdef.py game # собрать игру (форк) — нужен Rust python3 tools/airdef.py mod # собрать мод airdef.hmod — нужен Rust с wasm32 python3 tools/airdef.py install # положить мод в папку игры и включить python3 tools/airdef.py play [аргументы игры] # запустить форк на папке игры python3 tools/airdef.py all # всё по порядку: fork, game, mod, install Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного). """ import glob import hashlib import os import platform import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent FORK = ROOT / "horizon" UPSTREAM = "https://github.com/ludovic111/horizon.git" EXE = ".exe" if os.name == "nt" else "" def data_dir() -> Path: """Как `dirs::data_dir()` в Horizon.""" system = platform.system() if system == "Windows": return Path(os.environ["APPDATA"]) if system == "Darwin": return Path.home() / "Library" / "Application Support" return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) def game_home() -> Path: return Path(os.environ.get("HORIZON_HOME") or data_dir() / "horizon") def run(cmd, cwd=None, env=None): print("+", " ".join(str(c) for c in cmd), flush=True) subprocess.run([str(c) for c in cmd], cwd=cwd, env=env, check=True) def cargo() -> str: found = shutil.which("cargo") or str(Path.home() / ".cargo" / "bin" / ("cargo" + EXE)) if not Path(found).exists() and not shutil.which("cargo"): sys.exit("Нужен Rust: https://rustup.rs (затем: rustup target add wasm32-unknown-unknown)") return found def fork_stamp(commit: str, patches) -> str: """Что должно стоять в horizon/: коммит апстрима и содержимое патчей.""" h = hashlib.sha256(commit.encode()) for p in patches: h.update(p.name.encode()) h.update(p.read_bytes()) return h.hexdigest() def fork(): """horizon/ = апстрим на коммите HORIZON_COMMIT + патчи horizon-patches/. Ветка airdef пересобирается, когда коммит или патчи изменились (после обновления репозитория); прежняя остаётся в ветке airdef-prev.""" commit = (ROOT / "HORIZON_COMMIT").read_text().strip() patches = sorted((ROOT / "horizon-patches").glob("*.patch")) stamp = fork_stamp(commit, patches) stamp_file = FORK / ".git" / "airdef-stamp" if not (FORK / ".git").exists(): # Без CRLF на Windows: патчи накладываются на файлы как в апстриме. run(["git", "clone", "-c", "core.autocrlf=false", "--filter=blob:none", UPSTREAM, FORK]) branch = subprocess.run(["git", "-C", FORK, "branch", "--show-current"], capture_output=True, text=True).stdout.strip() if branch == "airdef" and stamp_file.exists() and stamp_file.read_text().strip() == stamp: print("horizon/: ветка airdef на нужных коммите и патчах") return dirty = subprocess.run(["git", "-C", FORK, "status", "--porcelain", "--untracked-files=no"], capture_output=True, text=True).stdout.strip() if dirty: sys.exit("horizon/: есть незакоммиченные изменения, а движок надо обновить. Закоммить или убери их и запусти снова.") exists = subprocess.run(["git", "-C", FORK, "rev-parse", "--verify", "-q", "refs/heads/airdef"], capture_output=True).returncode == 0 if exists: print("horizon/: коммит или патчи изменились, ветка airdef пересобирается (прежняя: airdef-prev)") run(["git", "-C", FORK, "branch", "-f", "airdef-prev", "airdef"]) run(["git", "-C", FORK, "fetch", "--quiet", "origin", commit]) run(["git", "-C", FORK, "checkout", "-B", "airdef", commit]) if patches: run(["git", "-C", FORK, "-c", "user.name=airdef", "-c", "user.email=airdef@localhost", "am", "--3way", *patches]) stamp_file.write_text(stamp + "\n") def game(): if not (FORK / "Cargo.toml").exists(): fork() run([cargo(), "build", "--profile", "fast", "-p", "horizon-client", "--features", "mods"], cwd=FORK) def built_game() -> Path: if os.environ.get("AIRDEF_GAME"): return Path(os.environ["AIRDEF_GAME"]) for name in (FORK / "target" / "fast" / ("horizon-client" + EXE), ROOT / ("horizon-client" + EXE)): if name.exists(): return name sys.exit("Нет собранной игры: python3 tools/airdef.py game (или AIRDEF_GAME=путь к horizon-client)") def mod(): tool = shutil.which("cargo-horizon-mod") or Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE) if not (FORK / "Cargo.toml").exists(): fork() # Упаковщик должен быть от этого форка: старый не знает новых уровней API. stamp_file = FORK / ".git" / "airdef-packer-stamp" commit = (ROOT / "HORIZON_COMMIT").read_text().strip() stamp = fork_stamp(commit, sorted((ROOT / "horizon-patches").glob("*.patch"))) if not Path(tool).exists() or not stamp_file.exists() or stamp_file.read_text().strip() != stamp: # Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile. run([cargo(), "install", "--locked", "--force", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"]) stamp_file.write_text(stamp + "\n") run([cargo(), "horizon-mod", "build"], cwd=ROOT / "airdef-mod") def hmod() -> Path: for p in (ROOT / "airdef-mod" / "target" / "horizon-mod" / "airdef.hmod", ROOT / "airdef.hmod"): if p.exists(): return p sys.exit("Нет airdef.hmod: python3 tools/airdef.py mod") def install(): home = game_home() mods = home / "horizon-mods" mods.mkdir(parents=True, exist_ok=True) shutil.rmtree(mods / "airdef", ignore_errors=True) # первая версия мода была папкой shutil.copy2(hmod(), mods / "airdef.hmod") listing = home / "horizon-mods.txt" lines = listing.read_text().splitlines() if listing.exists() else [] lines = [l for l in lines if not l.startswith("mod.airdef:")] + ["mod.airdef:true"] listing.write_text("\n".join(lines) + "\n") print(f"Мод установлен в {mods} и включён") def resources() -> Path: if os.environ.get("HORIZON_RESOURCES"): return Path(os.environ["HORIZON_RESOURCES"]) found = sorted(glob.glob(str(data_dir() / "horizon-launcher" / "versions" / "*" / ".local" / "horizon-client" / "26.3"))) local = FORK / ".local" / "horizon-client" / "26.3" if local.exists(): found.append(str(local)) if not found: sys.exit("Не найдены ресурсы игры. Установи Horizon через лаунчер и запусти его один раз " "(он подготовит ресурсы из твоего Minecraft 26.3), либо задай HORIZON_RESOURCES.") return Path(found[-1]) def play(args): exe = built_game().resolve() home = game_home() home.mkdir(parents=True, exist_ok=True) # Игра хранит настройки, моды и миры в текущей папке. run([exe, "--resources", resources(), *args], cwd=home) def main(): if len(sys.argv) < 2: sys.exit(__doc__) cmd, rest = sys.argv[1], sys.argv[2:] steps = {"fork": fork, "game": game, "mod": mod, "install": install} if cmd == "play": play(rest) elif cmd == "all": for step in ("fork", "game", "mod", "install"): steps[step]() elif cmd in steps: steps[cmd]() else: sys.exit(__doc__) if __name__ == "__main__": main()