diff --git a/.gitea/workflows/wasm-budget.yml b/.gitea/workflows/wasm-budget.yml new file mode 100644 index 0000000..7024340 --- /dev/null +++ b/.gitea/workflows/wasm-budget.yml @@ -0,0 +1,27 @@ +# Opt-in heavy sandbox runtime tier; separate from the routine WASM contract. +name: wasm-budget +on: + workflow_dispatch: +env: + CARGO_BUILD_JOBS: "1" + CARGO_INCREMENTAL: "0" +jobs: + budget: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Rust + run: | + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target wasm32-unknown-unknown + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Actual WASM budget scenario + run: nice -n 19 python3 tools/airdef.py test-budgets + - name: Preserve machine-readable provenance + if: always() + uses: actions/upload-artifact@v4 + with: + name: wasm-budget-provenance + path: .horizon-cache/wasm-budget-report.json + if-no-files-found: warn diff --git a/airdef-mod/tests/budgets/wasm_budget.toml b/airdef-mod/tests/budgets/wasm_budget.toml new file mode 100644 index 0000000..fc0939e --- /dev/null +++ b/airdef-mod/tests/budgets/wasm_budget.toml @@ -0,0 +1,278 @@ +# Opt-in heavy contract scenario. Run through `test-budgets` so the routine +# `test-wasm` remains quick. This drives the real .hmod in Horizon's sandbox; +# no GPU or rendered-frame correctness is implied by missing query replies. +[[step]] +ticks = 2 +frames = 2 +expect.commands = ["airdef"] + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy patriot" +expect.feedback = "Patriot развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy pantsir" +expect.feedback = "Панцирь-С1 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy gepard" +expect.feedback = "Гепард развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy nasams" +expect.feedback = "NASAMS развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy irist" +expect.feedback = "IRIS-T SLM развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy tor" +expect.feedback = "Тор-М2 развёрнут (integrated, multi)" + +[[step]] +command = "airdef deploy s400" +expect.feedback = "С-400 развёрнут (integrated, multi)" + +# Two maximum supported waves are issued before simulation advances, so +# their 160 airborne units overlap while 64 defensive systems track them. +[[step]] +command = "airdef wave mix 80 4000" + +[[step]] +command = "airdef wave kh101 80 4000" + +[[step]] +ticks = 600 +frames = 300 + +[[step]] +command = "airdef clear" diff --git a/tools/airdef.py b/tools/airdef.py index d1962bc..066f89b 100644 --- a/tools/airdef.py +++ b/tools/airdef.py @@ -9,6 +9,7 @@ python3 tools/airdef.py all # всё по порядку: fork, game, mod, install python3 tools/airdef.py check # совпадают ли форк, упаковщик, уровень API мода и ресурсы python3 tools/airdef.py test-wasm # собрать и прогнать настоящий .hmod в песочнице без GPU + python3 tools/airdef.py test-budgets # тяжёлый WASM budget сценарий + JSON provenance Переменные окружения: HORIZON_HOME (папка игры), HORIZON_RESOURCES (подготовленные ресурсы Minecraft 26.3), AIRDEF_GAME (готовый horizon-client вместо собранного). @@ -22,6 +23,7 @@ import re import shutil import subprocess import sys +import tempfile import zipfile from pathlib import Path @@ -132,8 +134,8 @@ def mod(): "engine": engine_identity(), "engine_pin": engine_commit(), "wit_sha256": hashlib.sha256((FORK / "crates/horizon-mod-sdk/wit/horizon.wit").read_bytes()).hexdigest(), "artifact_sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), - "airdef_dirty": bool(subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=no"], cwd=ROOT)), - "airdef_diff_sha256": hashlib.sha256(subprocess.check_output(["git", "diff", "--binary", "HEAD"], cwd=ROOT)).hexdigest()} + "airdef_dirty": bool(subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", "status", "--porcelain", "--untracked-files=no"], cwd=ROOT)), + "airdef_diff_sha256": hashlib.sha256(subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", "diff", "--binary", "HEAD"], cwd=ROOT)).hexdigest()} artifact.with_name("build-identity.json").write_text(json.dumps(identity, indent=2) + "\n") @@ -155,6 +157,106 @@ def test_wasm(): cwd=ROOT / "airdef-mod", env=build_env) +def test_budgets(): + """Run the opt-in heavy scenario and always write a machine-readable report.""" + report_path = Path(os.environ.get("AIRDEF_BUDGET_REPORT") or + ROOT / ".horizon-cache" / "wasm-budget-report.json") + report = { + "schema": 1, + "command": "test-budgets", + "scenario": "tests/budgets/wasm_budget.toml", + "status": "failed", + "exit_code": None, + "scenario_counts": {"scripts": 1, "steps": None, "raids": 2, + "max_units_per_raid": 80, "raid_units": 160, + "air_defense_units": None, "layout_unit_limit": None, + "frames": 300, "ticks": 602}, + "runtime_limits": {"source": "horizon/crates/horizon-mods/src/runtime.rs", + "metering": "source_configured; measured_per_scenario: unavailable"}, + } + + def git(*args, text=True): + return subprocess.check_output(["git", "-c", "submodule.horizon.ignore=all", *args], cwd=ROOT, + text=text, stderr=subprocess.DEVNULL) + + try: + report["airdef"] = { + "sha": git("rev-parse", "HEAD").strip(), + "dirty": bool(git("status", "--porcelain", "--untracked-files=no").strip()), + "diff_sha256": hashlib.sha256(git("diff", "--binary", "HEAD", text=False)).hexdigest(), + } + report["engine"] = {"pin": engine_commit(), "identity": engine_identity()} + wit = FORK / "crates" / "horizon-mod-sdk" / "wit" / "horizon.wit" + report["api_level"] = fork_api()[0] if fork_api() else None + report["wit_sha256"] = hashlib.sha256(wit.read_bytes()).hexdigest() if wit.is_file() else None + runtime = (FORK / "crates" / "horizon-mods" / "src" / "runtime.rs").read_text() + layout = (ROOT / "airdef-mod" / "src" / "layout.rs").read_text() + limit = re.search(r"pub const MAX_UNITS: usize = (\d+);", layout) + report["scenario_counts"]["layout_unit_limit"] = int(limit.group(1)) if limit else None + memory = re.search(r"const MEMORY: usize = (\d+) << (\d+);", runtime) + report["runtime_limits"].update({ + "memory_bytes": int(memory.group(1)) << int(memory.group(2)) if memory else None, + "fuel_per_call": {name.lower(): int(value) for name, value in + re.findall(r"const FUEL_(INIT|TICK|FRAME|EVENT): u64 = ([\d_]+);", runtime) + for value in [value.replace("_", "")]}, + "wasmtime_component_model": bool(re.search(r"wasm_component_model\(true\)", runtime)), + "wasmtime_fuel_metering": bool(re.search(r"consume_fuel\(true\)", runtime)), + }) + lock_text = (ROOT / "horizon" / "Cargo.lock").read_text() + wasmtime = re.search(r'\[\[package\]\]\s*name = "wasmtime"\s*version = "([^"]+)"', lock_text) + report["wasmtime_version"] = wasmtime.group(1) if wasmtime else None + fixture = ROOT / "airdef-mod" / "tests" / "budgets" / "wasm_budget.toml" + report["scenario_counts"]["steps"] = len(re.findall(r"(?m)^\[\[step\]\]$", fixture.read_text())) + report["scenario_counts"]["air_defense_units"] = len(re.findall(r'(?m)^command = "airdef deploy [^"]+"', fixture.read_text())) + report["scenario_sha256"] = hashlib.sha256(fixture.read_bytes()).hexdigest() + + mod() + artifact = hmod() + report["artifact_sha256"] = hashlib.sha256(artifact.read_bytes()).hexdigest() + identity_path = artifact.with_name("build-identity.json") + identity = json.loads(identity_path.read_text()) + identity_checks = { + "engine_pin": identity.get("engine_pin") == report["engine"]["pin"], + "engine_identity": identity.get("engine") == report["engine"]["identity"], + "airdef_sha": identity.get("airdef") == report["airdef"]["sha"], + "airdef_dirty": identity.get("airdef_dirty") == report["airdef"]["dirty"], + "airdef_diff_sha256": identity.get("airdef_diff_sha256") == report["airdef"]["diff_sha256"], + "artifact_sha256": identity.get("artifact_sha256") == report["artifact_sha256"], + "wit_sha256": identity.get("wit_sha256") == report["wit_sha256"], + } + report["build_identity"] = {"path": str(identity_path), "checks": identity_checks, + "matches": all(identity_checks.values())} + if not report["build_identity"]["matches"]: + raise RuntimeError("build identity is stale or does not match the tested artifact: " + + ", ".join(k for k, ok in identity_checks.items() if not ok)) + build_env = dict(os.environ) + build_env["PATH"] = str(Path(cargo()).parent) + os.pathsep + build_env.get("PATH", "") + fixture = ROOT / "airdef-mod" / "tests" / "budgets" / "wasm_budget.toml" + with tempfile.TemporaryDirectory(prefix="airdef-wasm-budget-") as temp: + isolated = Path(temp) + (isolated / "tests").mkdir() + shutil.copy2(fixture, isolated / "tests" / "wasm_budget.toml") + command = [cargo(), "run", "--locked", "--manifest-path", FORK / "Cargo.toml", + "-p", "cargo-horizon-mod", "--", "horizon-mod", "test", "--hmod", artifact, "budget"] + print("+", " ".join(str(c) for c in command), flush=True) + result = subprocess.run([str(c) for c in command], cwd=isolated, env=build_env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + print(result.stdout, end="", flush=True) + report["exit_code"] = result.returncode + report["status"] = "passed" if result.returncode == 0 else "failed" + report["output"] = result.stdout[-12000:] + except Exception as exc: + report["error"] = f"{type(exc).__name__}: {exc}" + except SystemExit as exc: + report["error"] = f"SystemExit: {exc.code}" + finally: + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + print(f"Budget report: {report_path}", flush=True) + if report["status"] != "passed": + sys.exit(f"WASM budget scenario failed; see {report_path}") + + def install(): home = game_home() mods = home / "horizon-mods" @@ -298,7 +400,7 @@ 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, "test-wasm": test_wasm} + steps = {"fork": fork, "game": game, "mod": mod, "install": install, "check": check, "test-wasm": test_wasm, "test-budgets": test_budgets} if cmd == "play": play(rest) elif cmd == "all":