ccc3e20902
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
14 KiB
Python
301 lines
14 KiB
Python
#!/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
|
||
python3 tools/airdef.py check # совпадают ли форк, упаковщик, уровень API мода и ресурсы
|
||
|
||
Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы
|
||
Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного).
|
||
"""
|
||
import glob
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import zipfile
|
||
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 fork_api():
|
||
"""Новейший уровень API форка и его таблица функций (horizon-mods/src/api_level.rs); None без форка."""
|
||
try:
|
||
text = (FORK / "crates" / "horizon-mods" / "src" / "api_level.rs").read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
try:
|
||
level = int(re.search(r"pub const LEVEL: u32 = (\d+);", text).group(1))
|
||
table = text[text.index("pub const FUNCTIONS"):]
|
||
table = table[:table.index("];")]
|
||
except (AttributeError, ValueError):
|
||
return None
|
||
return level, [(name, int(lvl)) for name, lvl in re.findall(r'\("([a-z.\-]+)", (\d+)\)', table)]
|
||
|
||
|
||
def needed_level(wasm: bytes, table) -> tuple:
|
||
"""Уровень, который нужен модулю: самая новая функция, которую он импортирует (как api_level::needed).
|
||
|
||
В компоненте имя функции записано как 0x00, длина, UTF-8 имя; по этим байтам и ищем."""
|
||
need = (0, None)
|
||
for name, level in table:
|
||
part = name.split(".", 1)[1].encode()
|
||
if level > need[0] and b"\x00" + bytes([len(part)]) + part in wasm:
|
||
need = (level, name)
|
||
return need
|
||
|
||
|
||
def game_level():
|
||
"""Уровень, который игра записала в себя (метка horizon-mod-api-level:NNNN;); None, если игры нет.
|
||
Игра без метки считается уровня 0, как в api_level::read_mark."""
|
||
try:
|
||
exe = built_game()
|
||
except SystemExit:
|
||
return None
|
||
if not exe.exists():
|
||
return None
|
||
found = re.search(rb"horizon-mod-api-level:(\d{4});", exe.read_bytes())
|
||
return int(found.group(1)) if found else 0
|
||
|
||
|
||
def check():
|
||
"""Говорит прямо, совпадают ли форк, упаковщик, уровень API мода с игрой и ресурсы. Код 0 — да, 1 — нет."""
|
||
commit = (ROOT / "HORIZON_COMMIT").read_text().strip()
|
||
patches = sorted((ROOT / "horizon-patches").glob("*.patch"))
|
||
stamp = fork_stamp(commit, patches)
|
||
problems = []
|
||
|
||
def line(name, ok, text):
|
||
print(f"[{'ok' if ok else 'нет'}] {name}: {text}")
|
||
if not ok:
|
||
problems.append(name)
|
||
|
||
stamp_file = FORK / ".git" / "airdef-stamp"
|
||
fork_ok = stamp_file.exists() and stamp_file.read_text().strip() == stamp
|
||
line("форк", fork_ok, f"коммит {commit[:12]}, патчей {len(patches)}" if fork_ok else
|
||
"horizon/ не собран из HORIZON_COMMIT и horizon-patches/: python3 tools/airdef.py fork")
|
||
|
||
tool = shutil.which("cargo-horizon-mod") or str(Path.home() / ".cargo" / "bin" / ("cargo-horizon-mod" + EXE))
|
||
packer_stamp = FORK / ".git" / "airdef-packer-stamp"
|
||
packer_ok = Path(tool).exists() and packer_stamp.exists() and packer_stamp.read_text().strip() == stamp
|
||
line("упаковщик", packer_ok, "собран из этого форка" if packer_ok else
|
||
"не установлен или собран из другого форка: python3 tools/airdef.py mod")
|
||
|
||
api = fork_api()
|
||
try:
|
||
mod_file = hmod()
|
||
except SystemExit:
|
||
mod_file = None
|
||
if api is None:
|
||
line("уровень API", False, "нет или не читается horizon/crates/horizon-mods/src/api_level.rs: python3 tools/airdef.py fork")
|
||
elif mod_file is None:
|
||
line("уровень API", False, "нет airdef.hmod: python3 tools/airdef.py mod")
|
||
else:
|
||
level, table = api
|
||
with zipfile.ZipFile(mod_file) as z:
|
||
wasm = z.read("mod.wasm") if "mod.wasm" in z.namelist() else b""
|
||
need, function = needed_level(wasm, table)
|
||
game = game_level()
|
||
text = f"мод нужен {need} ({function}), форк {level}, игра " + ("не собрана" if game is None else str(game))
|
||
if game is None:
|
||
line("уровень API", False, text + ": python3 tools/airdef.py game")
|
||
else:
|
||
# Сравнивается уровень, не код: одинаковый уровень не доказывает одинаковые патчи.
|
||
supported = need <= game and game == level
|
||
line("уровень API", supported, text if supported else
|
||
text + ": игра не поддерживает мод или собрана из другого форка")
|
||
|
||
try:
|
||
res = resources()
|
||
except SystemExit as e:
|
||
res = None
|
||
line("ресурсы", False, str(e.code))
|
||
if res is not None:
|
||
try:
|
||
preparer = (FORK / "crates" / "horizon-prepare" / "src" / "lib.rs").read_text(encoding="utf-8")
|
||
schema = int(re.search(r"pub const SCHEMA: i64 = (\d+);", preparer).group(1))
|
||
except (OSError, AttributeError):
|
||
schema = None
|
||
try:
|
||
manifest = json.loads((res / "horizon-resources.json").read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
manifest = {}
|
||
ok = schema is not None and manifest.get("schema") == schema and manifest.get("minecraft") == "26.3"
|
||
line("ресурсы", ok, f"{res}, схема {schema}, Minecraft 26.3" if ok else
|
||
f"{res}: схема {manifest.get('schema')}, Minecraft {manifest.get('minecraft')}; нужны схема {schema} и Minecraft 26.3")
|
||
|
||
if problems:
|
||
sys.exit("Не совпадает: " + ", ".join(problems) + ".")
|
||
print("Всё совпадает: форк, упаковщик, уровень API мода и ресурсы.")
|
||
|
||
|
||
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, "check": check}
|
||
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()
|