Files
airdef/tools/airdef.py
T

180 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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():
run(["git", "clone", "--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 Path(tool).exists():
if not (FORK / "Cargo.toml").exists():
fork()
# Without trial runs (wasmtime): building a mod needs only packing, a fraction of the compile.
run([cargo(), "install", "--locked", "--no-default-features", "--path", FORK / "crates" / "cargo-horizon-mod"])
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()