From 876f7d1dc7bef1fd4448bbc786cb402b1e781a39 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:59:14 +0100 Subject: [PATCH] Native mods: one folder each in native/, shared tools and CI Horizon's own native mods (zoom, fps-overlay, keystrokes, minimap) were one repo each; they now live here under native// with their history. native/tools/native.py builds, packages and proves any of them, alone or together; native.yml checks each changed folder (fmt, clippy, tests, the .hnmod, the game with it applied), every mod together, and every Monday all of them on the newest Nightly. Pinned to Horizon 1149c92 (hook points v1). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native.yml | 117 +++++++++ .gitignore | 2 + README.md | 20 ++ native/fps-overlay/.github/workflows/ci.yml | 53 ---- native/fps-overlay/.gitignore | 1 - native/fps-overlay/README.md | 15 +- native/fps-overlay/compatibility.json | 2 +- .../mixins/fps_overlay/horizon-mixin.toml | 2 +- native/fps-overlay/{tools => }/proof.py | 0 native/fps-overlay/tools/native.py | 202 --------------- native/keystrokes/.github/workflows/ci.yml | 53 ---- native/keystrokes/.gitignore | 1 - native/keystrokes/README.md | 15 +- native/keystrokes/compatibility.json | 2 +- .../mixins/keystrokes/horizon-mixin.toml | 2 +- native/keystrokes/{tools => }/proof.py | 0 native/keystrokes/tools/game.py | 117 --------- native/keystrokes/tools/native.py | 202 --------------- native/minimap/.github/workflows/ci.yml | 53 ---- native/minimap/.gitignore | 1 - native/minimap/README.md | 15 +- native/minimap/compatibility.json | 2 +- .../minimap/mixins/minimap/horizon-mixin.toml | 2 +- native/minimap/{tools => }/proof.py | 0 native/minimap/tools/game.py | 117 --------- native/minimap/tools/native.py | 202 --------------- native/{fps-overlay => }/tools/game.py | 0 native/tools/native.py | 239 ++++++++++++++++++ native/zoom/.github/workflows/ci.yml | 53 ---- native/zoom/.gitignore | 1 - native/zoom/README.md | 15 +- native/zoom/compatibility.json | 2 +- native/zoom/mixins/zoom/horizon-mixin.toml | 2 +- native/zoom/{tools => }/proof.py | 0 native/zoom/tools/game.py | 117 --------- native/zoom/tools/native.py | 202 --------------- 36 files changed, 418 insertions(+), 1411 deletions(-) create mode 100644 .github/workflows/native.yml delete mode 100644 native/fps-overlay/.github/workflows/ci.yml delete mode 100644 native/fps-overlay/.gitignore rename native/fps-overlay/{tools => }/proof.py (100%) delete mode 100644 native/fps-overlay/tools/native.py delete mode 100644 native/keystrokes/.github/workflows/ci.yml delete mode 100644 native/keystrokes/.gitignore rename native/keystrokes/{tools => }/proof.py (100%) delete mode 100644 native/keystrokes/tools/game.py delete mode 100644 native/keystrokes/tools/native.py delete mode 100644 native/minimap/.github/workflows/ci.yml delete mode 100644 native/minimap/.gitignore rename native/minimap/{tools => }/proof.py (100%) delete mode 100644 native/minimap/tools/game.py delete mode 100644 native/minimap/tools/native.py rename native/{fps-overlay => }/tools/game.py (100%) create mode 100644 native/tools/native.py delete mode 100644 native/zoom/.github/workflows/ci.yml delete mode 100644 native/zoom/.gitignore rename native/zoom/{tools => }/proof.py (100%) delete mode 100644 native/zoom/tools/game.py delete mode 100644 native/zoom/tools/native.py diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..dc1c864 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,117 @@ +# Horizon's own native mods (native//): each changed folder's tests and .hnmod, and the game with it applied +# on the Horizon it was tested on; every mod together, as a player's set. Every Monday, all of them on the newest +# Nightly, to catch a change of the hook points early. +name: native +on: + pull_request: + branches: [main] + paths: ["native/**", ".github/workflows/native.yml"] + push: + branches: [main] + paths: ["native/**", ".github/workflows/native.yml"] + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + inputs: + horizon: + description: "Horizon commit, tag, or `latest` (the newest Nightly); empty: the mods' tested commit" + default: latest +permissions: + contents: read +concurrency: + group: native-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + # The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs). + RUST: "1.99.1" +jobs: + changes: + runs-on: ubuntu-latest + outputs: + mods: ${{ steps.mods.outputs.mods }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: mods + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + EVENT: ${{ github.event_name }} + run: | + all=$(ls -d native/*/compatibility.json | cut -d/ -f2 | jq -R . | jq -sc .) + if [ "$EVENT" = schedule ] || [ "$EVENT" = workflow_dispatch ] || [ -z "$BASE" ] \ + || ! git cat-file -e "$BASE" 2>/dev/null; then + echo "mods=$all" >> "$GITHUB_OUTPUT"; exit 0 + fi + changed=$(git diff --name-only "$BASE" HEAD) + # The shared tools or this workflow: every mod. + if grep -qE '^(native/tools/|\.github/workflows/native\.yml)' <<< "$changed"; then + echo "mods=$all" >> "$GITHUB_OUTPUT"; exit 0 + fi + mods=$(grep -oE '^native/[^/]+/' <<< "$changed" | cut -d/ -f2 | sort -u \ + | while read -r m; do [ -f "native/$m/compatibility.json" ] && echo "$m"; done | jq -R . | jq -sc .) + echo "mods=$mods" >> "$GITHUB_OUTPUT" + mod: + needs: changes + if: needs.changes.outputs.mods != '[]' + strategy: + fail-fast: false + matrix: + mod: ${{ fromJson(needs.changes.outputs.mods) }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: native/${{ matrix.mod }} + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST + - run: cargo fmt --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --locked + - name: Package the .hnmod and check what it holds + working-directory: . + run: | + python3 native/tools/native.py package ${{ matrix.mod }} + python3 - <<'PY' + import json, zipfile, glob + path, = glob.glob("native/target/*.hnmod") + names = zipfile.ZipFile(path).namelist() + manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json")) + assert manifest["hooks"] >= 1, manifest + assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names + print(path, manifest) + PY + - uses: actions/upload-artifact@v4 + with: + name: hnmod-${{ matrix.mod }} + path: native/target/*.hnmod + horizon: + needs: changes + if: needs.changes.outputs.mods != '[]' + strategy: + fail-fast: false + matrix: + mod: ${{ fromJson(needs.changes.outputs.mods) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config + - name: Apply on Horizon and check the game + env: + GITHUB_TOKEN: ${{ github.token }} + HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} + run: python3 native/tools/native.py check ${{ matrix.mod }} ${HORIZON:+--horizon "$HORIZON"} + together: + needs: changes + if: needs.changes.outputs.mods != '[]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config + - name: Every native mod applied together, on Horizon + env: + GITHUB_TOKEN: ${{ github.token }} + HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} + run: python3 native/tools/native.py check all ${HORIZON:+--horizon "$HORIZON"} diff --git a/.gitignore b/.gitignore index 93b1299..2623ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /files/ /site/ /bootstrap/ +/native/target/ +/native/*/target/ diff --git a/README.md b/README.md index daf6fe7..dcab3ea 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ why. | `.github/workflows/check.yml` | A pull request's checks | | `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages | | `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more | +| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly | +| `native//` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap): the source of their `.hnmod` files | +| `native/tools/` | Shared by them: apply on Horizon, check, build, package, prove (`native/tools/native.py`) | | `scripts/` | What the workflows share | The format of the entries and of `index.json` is @@ -72,3 +75,20 @@ the checks are `cargo horizon-mod check` and `cargo horizon-mod registry`, from repository variable `HORIZON_REV` (else `main`). The index: `https://horizon-rust.github.io/horizon-mods/index.json`. + +## Horizon's native mods + +`native//` holds the source of Horizon's official mods, as +[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points +([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the +history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its +`compatibility.json` names; `horizon_revision` is the commit it was last tested on. + +```sh +python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py check all # every native mod together +python3 native/tools/native.py package all # native/target/-.hnmod, for a registry entry +python3 native/tools/native.py proof zoom --resources /path/to/.local/horizon-client/26.3 +``` + +To make your own, start from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template). diff --git a/native/fps-overlay/.github/workflows/ci.yml b/native/fps-overlay/.github/workflows/ci.yml deleted file mode 100644 index 84f47e7..0000000 --- a/native/fps-overlay/.github/workflows/ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CI -on: - push: - pull_request: - # Every Monday: the mod on the newest Nightly, to catch a change of the hook points early. - schedule: - - cron: "17 5 * * 1" - workflow_dispatch: - inputs: - horizon: - description: "Horizon commit, tag, or `latest` (the newest Nightly)" - default: latest -permissions: - contents: read -env: - # The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs). - RUST: "1.99.1" -jobs: - mod: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST - - run: cargo fmt --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked - - name: Package the .hnmod and check what it holds - run: | - python3 tools/native.py package - python3 - <<'PY' - import json, zipfile, glob - path, = glob.glob("target/*.hnmod") - names = zipfile.ZipFile(path).namelist() - manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json")) - assert manifest["hooks"] == 1, manifest - assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names - print(path, manifest) - PY - - uses: actions/upload-artifact@v4 - with: - name: hnmod - path: target/*.hnmod - horizon: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config - - name: Apply on Horizon and check the game - env: - GITHUB_TOKEN: ${{ github.token }} - HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} - run: python3 tools/native.py check ${HORIZON:+--horizon "$HORIZON"} diff --git a/native/fps-overlay/.gitignore b/native/fps-overlay/.gitignore deleted file mode 100644 index 2f7896d..0000000 --- a/native/fps-overlay/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/native/fps-overlay/README.md b/native/fps-overlay/README.md index 54e3936..e12306e 100644 --- a/native/fps-overlay/README.md +++ b/native/fps-overlay/README.md @@ -23,17 +23,18 @@ mod. `horizon_revision` is only the commit it was last tested on. | `mixins/fps_overlay/horizon-mixin.toml` | The bundle: one module and the HEAD hooks | | `mixins/fps_overlay/runtime.rs` | The mod's logic and state, added to the game as `game::official_fps_overlay`; also this crate's library, for its tests | | `mixins/fps_overlay/*.rs` | One small helper per hook | -| `tools/native.py` | Apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | -| `tools/proof.py`, `tools/game.py` | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | +| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | +| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | ## Build and prove ```sh -cargo test # the logic -python3 tools/native.py check # apply on the tested Horizon and cargo check the game -python3 tools/native.py check --horizon latest # ... on the newest Nightly -python3 tools/native.py package # target/fps_overlay-.hnmod for the registry -python3 tools/native.py proof --resources /path/to/.local/horizon-client/26.3 +(cd native/fps-overlay && cargo test) # the logic +python3 native/tools/native.py check fps-overlay # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py check fps-overlay --horizon latest # ... on the newest Nightly +python3 native/tools/native.py check all # with the other native mods, as a player's set +python3 native/tools/native.py package fps-overlay # native/target/fps_overlay-.hnmod for the registry +python3 native/tools/native.py proof fps-overlay --resources /path/to/.local/horizon-client/26.3 ``` The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository. diff --git a/native/fps-overlay/compatibility.json b/native/fps-overlay/compatibility.json index dc242a9..9cc6aea 100644 --- a/native/fps-overlay/compatibility.json +++ b/native/fps-overlay/compatibility.json @@ -2,7 +2,7 @@ "game": "26.3", "hooks": 1, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c", + "horizon_revision": "1149c92cb8c1fde5136197b6647a6eb220401a60", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0500ff24aa5933fca95a877ef41125353c4b4fe3" } diff --git a/native/fps-overlay/mixins/fps_overlay/horizon-mixin.toml b/native/fps-overlay/mixins/fps_overlay/horizon-mixin.toml index dda2892..f9cfa60 100644 --- a/native/fps-overlay/mixins/fps_overlay/horizon-mixin.toml +++ b/native/fps-overlay/mixins/fps_overlay/horizon-mixin.toml @@ -6,7 +6,7 @@ priority = 100 # The commit it was tested on. It uses only the native hook points (hooks 1 in # compatibility.json): the launcher builds it on any Horizon that has them. [compatibility] -horizon_revision = "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c" +horizon_revision = "1149c92cb8c1fde5136197b6647a6eb220401a60" [[module]] id = "runtime" diff --git a/native/fps-overlay/tools/proof.py b/native/fps-overlay/proof.py similarity index 100% rename from native/fps-overlay/tools/proof.py rename to native/fps-overlay/proof.py diff --git a/native/fps-overlay/tools/native.py b/native/fps-overlay/tools/native.py deleted file mode 100644 index bfb9453..0000000 --- a/native/fps-overlay/tools/native.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Build this native mod into Horizon, package it, and prove it in a running game. - - python3 tools/native.py check # apply on the pinned Horizon, cargo check the game - python3 tools/native.py build # ... and build it (fast profile); prints the binary - python3 tools/native.py package # target/-.hnmod for the registry - python3 tools/native.py proof --resources PATH # build, then drive the game with horizon-ctl - --horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of - compatibility.json's: the mod uses only the native hook points, so it builds - on any Horizon whose NATIVE_HOOKS_API is at least the one it names, as the - launcher does. - -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 target/. -""" -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 - -ROOT = Path(__file__).resolve().parents[1] -TARGET = ROOT / "target" -COMPAT = json.loads((ROOT / "compatibility.json").read_text()) -MIXINS = ROOT / "mixins" -BUNDLE = next(p for p in MIXINS.iterdir() if (p / "horizon-mixin.toml").is_file()) -MANIFEST = tomllib.loads((BUNDLE / "horizon-mixin.toml").read_text()) -MOD_ID = MANIFEST["mod"]["id"] -VERSION = MANIFEST["mod"]["version"] -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) - - -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/- tag.""" - refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/")) - newest = max(refs, key=lambda r: r["ref"]) - 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//.""" - 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(): - """horizon-mixins at the pinned commit, built once.""" - rev = COMPAT["horizon_mixins_revision"] - root = TARGET / "horizon-mixins" / rev - binary = root / "bin" / "horizon-mixins" - if not binary.is_file(): - log(f"Building horizon-mixins {rev[:7]}") - subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", COMPAT["horizon_mixins_repository"], - "--rev", rev, "--root", str(root), "horizon-mixins-cli"], check=True) - return binary - - -def stage(revision): - """The mod applied on Horizon `revision`: target/stage/ (cargo's incremental state survives).""" - tree = source(revision) - api = hooks_api(tree) - # Hook points are only ever added: any later version has the ones this mod hooks. - if api is None or api < COMPAT["hooks"]: - sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, this mod needs v{COMPAT['hooks']}.") - # As the launcher does for a mod on the hook points: pin the copy to the commit it builds on. - mods = TARGET / "mods" - shutil.rmtree(mods, ignore_errors=True) - shutil.copytree(BUNDLE, mods / MOD_ID) - toml = mods / MOD_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()), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(fresh)], - check=True, stdout=subprocess.DEVNULL) - out = TARGET / "stage" - out.mkdir(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": - 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(): - """A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files.""" - manifest = {"schema": 1, "id": MOD_ID, "version": VERSION, "game": COMPAT["game"], - "horizon": COMPAT["horizon_revision"], "mixins": COMPAT["horizon_mixins_revision"], - "hooks": COMPAT["hooks"]} - pinned = MANIFEST.get("compatibility", {}).get("horizon_revision") - if pinned != COMPAT["horizon_revision"]: - sys.exit(f"horizon-mixin.toml pins {pinned}, compatibility.json {COMPAT['horizon_revision']}") - files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()} - for path in sorted(BUNDLE.iterdir()): - if path.suffix in (".rs", ".toml"): - files[f"mixins/{MOD_ID}/{path.name}"] = path.read_bytes() - out = TARGET / f"{MOD_ID}-{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("--horizon", default=COMPAT["horizon_revision"], - help="a Horizon commit, `latest` (newest Nightly) or a tag") - parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3") - args = parser.parse_args() - if args.action == "package": - return package() - 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"{MOD_ID} {VERSION} on Horizon {revision[:7]}") - out = stage(revision) - if args.action == "check": - cargo(out, "check") - print(json.dumps({"mod": MOD_ID, "horizon": revision, "check": "ok"})) - return - binaries = cargo(out, "build") - if args.action == "build": - print(json.dumps({"mod": MOD_ID, "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(ROOT / "tools")) - import proof - proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof") - - -if __name__ == "__main__": - main() diff --git a/native/keystrokes/.github/workflows/ci.yml b/native/keystrokes/.github/workflows/ci.yml deleted file mode 100644 index 84f47e7..0000000 --- a/native/keystrokes/.github/workflows/ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CI -on: - push: - pull_request: - # Every Monday: the mod on the newest Nightly, to catch a change of the hook points early. - schedule: - - cron: "17 5 * * 1" - workflow_dispatch: - inputs: - horizon: - description: "Horizon commit, tag, or `latest` (the newest Nightly)" - default: latest -permissions: - contents: read -env: - # The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs). - RUST: "1.99.1" -jobs: - mod: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST - - run: cargo fmt --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked - - name: Package the .hnmod and check what it holds - run: | - python3 tools/native.py package - python3 - <<'PY' - import json, zipfile, glob - path, = glob.glob("target/*.hnmod") - names = zipfile.ZipFile(path).namelist() - manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json")) - assert manifest["hooks"] == 1, manifest - assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names - print(path, manifest) - PY - - uses: actions/upload-artifact@v4 - with: - name: hnmod - path: target/*.hnmod - horizon: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config - - name: Apply on Horizon and check the game - env: - GITHUB_TOKEN: ${{ github.token }} - HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} - run: python3 tools/native.py check ${HORIZON:+--horizon "$HORIZON"} diff --git a/native/keystrokes/.gitignore b/native/keystrokes/.gitignore deleted file mode 100644 index 2f7896d..0000000 --- a/native/keystrokes/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/native/keystrokes/README.md b/native/keystrokes/README.md index e85e101..08cd6cf 100644 --- a/native/keystrokes/README.md +++ b/native/keystrokes/README.md @@ -23,17 +23,18 @@ mod. `horizon_revision` is only the commit it was last tested on. | `mixins/keystrokes/horizon-mixin.toml` | The bundle: one module and the HEAD hooks | | `mixins/keystrokes/runtime.rs` | The mod's logic and state, added to the game as `game::official_keystrokes`; also this crate's library, for its tests | | `mixins/keystrokes/*.rs` | One small helper per hook | -| `tools/native.py` | Apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | -| `tools/proof.py`, `tools/game.py` | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | +| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | +| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | ## Build and prove ```sh -cargo test # the logic -python3 tools/native.py check # apply on the tested Horizon and cargo check the game -python3 tools/native.py check --horizon latest # ... on the newest Nightly -python3 tools/native.py package # target/keystrokes-.hnmod for the registry -python3 tools/native.py proof --resources /path/to/.local/horizon-client/26.3 +(cd native/keystrokes && cargo test) # the logic +python3 native/tools/native.py check keystrokes # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py check keystrokes --horizon latest # ... on the newest Nightly +python3 native/tools/native.py check all # with the other native mods, as a player's set +python3 native/tools/native.py package keystrokes # native/target/keystrokes-.hnmod for the registry +python3 native/tools/native.py proof keystrokes --resources /path/to/.local/horizon-client/26.3 ``` The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository. diff --git a/native/keystrokes/compatibility.json b/native/keystrokes/compatibility.json index dc242a9..9cc6aea 100644 --- a/native/keystrokes/compatibility.json +++ b/native/keystrokes/compatibility.json @@ -2,7 +2,7 @@ "game": "26.3", "hooks": 1, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c", + "horizon_revision": "1149c92cb8c1fde5136197b6647a6eb220401a60", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0500ff24aa5933fca95a877ef41125353c4b4fe3" } diff --git a/native/keystrokes/mixins/keystrokes/horizon-mixin.toml b/native/keystrokes/mixins/keystrokes/horizon-mixin.toml index 8ba8a7f..6893a6b 100644 --- a/native/keystrokes/mixins/keystrokes/horizon-mixin.toml +++ b/native/keystrokes/mixins/keystrokes/horizon-mixin.toml @@ -6,7 +6,7 @@ priority = 100 # The commit it was tested on. It uses only the native hook points (hooks 1 in # compatibility.json): the launcher builds it on any Horizon that has them. [compatibility] -horizon_revision = "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c" +horizon_revision = "1149c92cb8c1fde5136197b6647a6eb220401a60" [[module]] id = "runtime" diff --git a/native/keystrokes/tools/proof.py b/native/keystrokes/proof.py similarity index 100% rename from native/keystrokes/tools/proof.py rename to native/keystrokes/proof.py diff --git a/native/keystrokes/tools/game.py b/native/keystrokes/tools/game.py deleted file mode 100644 index 2e251e1..0000000 --- a/native/keystrokes/tools/game.py +++ /dev/null @@ -1,117 +0,0 @@ -"""A modded game driven with horizon-ctl, on fresh data: a temporary game folder (the game keeps its -options, worlds and mods' settings in its working folder) and temporary launcher and XDG folders, with -no audio and no self-update. Nothing touches the player's own data.""" -import json -import os -import struct -import subprocess -import tempfile -import time -import zlib -from pathlib import Path - - -class Game: - def __init__(self, ctl, resources, out, files=None): - self.ctl, self.resources, self.out = Path(ctl), Path(resources), Path(out) - self.files = files or {} - - def __enter__(self): - self.out.mkdir(parents=True, exist_ok=True) - self.dirs = [tempfile.mkdtemp(prefix=f"native-proof-{n}-") for n in ("home", "launcher", "data", "config")] - self.home = Path(self.dirs[0]) - for name, text in self.files.items(): - (self.home / name).parent.mkdir(parents=True, exist_ok=True) - (self.home / name).write_text(text) - self.socket = str(Path(tempfile.mkdtemp(prefix="native-ctl-")) / "ctl.sock") - self.env = dict(os.environ, HORIZON_HOME=self.dirs[0], HORIZON_LAUNCHER_HOME=self.dirs[1], - XDG_DATA_HOME=self.dirs[2], XDG_CONFIG_HOME=self.dirs[3], - HORIZON_LAUNCHER_NO_SELF_UPDATE="1", HORIZON_NO_AUDIO="1") - self.run("launch", "--world", "Proof", "--creative", "--offscreen", "--wait", - "--resources", str(self.resources)) - time.sleep(3) - return self - - def __exit__(self, *_): - subprocess.run([str(self.ctl), "--socket", self.socket, "quit"], env=self.env, cwd=self.home, - capture_output=True) - - def run(self, *args): - done = subprocess.run([str(self.ctl), "--socket", self.socket, *args], env=self.env, cwd=self.home, - capture_output=True, text=True, check=True) - try: - return json.loads(done.stdout) - except json.JSONDecodeError: - return done.stdout - - def play(self): - """Back to the world from any screen.""" - for _ in range(6): - if self.run("status").get("screen") == "playing": - return - self.run("key", "esc", "tap") - time.sleep(0.5) - if self.run("status").get("screen") == "playing": - return - raise AssertionError(f"not back in the world: {self.run('status').get('screen')}") - - def labels(self): - return [w.get("label") for w in self.run("ui").get("widgets", [])] - - def ids(self): - return {w.get("label"): w.get("id") for w in self.run("ui").get("widgets", [])} - - def shot(self, name): - path = self.out / f"{name}.png" - self.run("screenshot", str(path)) - return path - - -def pixels(path): - """An 8-bit RGB or RGBA, non-interlaced PNG's (width, height, channels, rows).""" - data = Path(path).read_bytes() - pos, chunks, width = 8, b"", 0 - while pos < len(data): - length, kind = struct.unpack(">I4s", data[pos:pos + 8]) - body = data[pos + 8:pos + 8 + length] - if kind == b"IHDR": - width, height, depth, color = struct.unpack(">IIBB", body[:10]) - channels = {2: 3, 6: 4}[color] - assert depth == 8 - elif kind == b"IDAT": - chunks += body - pos += 12 + length - raw, stride, rows, prev = zlib.decompress(chunks), width * channels, [], bytearray(width * channels) - for y in range(height): - start = y * (stride + 1) - kind, line = raw[start], bytearray(raw[start + 1:start + 1 + stride]) - for i in range(stride): - a = line[i - channels] if i >= channels else 0 - b, c = prev[i], prev[i - channels] if i >= channels else 0 - if kind == 1: - line[i] = (line[i] + a) & 255 - elif kind == 2: - line[i] = (line[i] + b) & 255 - elif kind == 3: - line[i] = (line[i] + (a + b) // 2) & 255 - elif kind == 4: - p = a + b - c - pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) - line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 - rows.append(line) - prev = line - return width, height, channels, rows - - -def difference(a, b, box=(0.0, 0.0, 1.0, 1.0)): - """The mean difference of two screenshots' colours, 0..255, on a grid of samples, within `box` - (left, top, right, bottom, as fractions of the screen).""" - wa, ha, ca, ra = pixels(a) - _, _, cb, rb = pixels(b) - total = count = 0 - x0, y0, x1, y1 = int(box[0] * wa), int(box[1] * ha), int(box[2] * wa), int(box[3] * ha) - for y in range(y0, y1, 4): - for x in range(x0, x1, 4): - total += sum(abs(ra[y][x * ca + k] - rb[y][x * cb + k]) for k in range(3)) - count += 3 - return total / count diff --git a/native/keystrokes/tools/native.py b/native/keystrokes/tools/native.py deleted file mode 100644 index bfb9453..0000000 --- a/native/keystrokes/tools/native.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Build this native mod into Horizon, package it, and prove it in a running game. - - python3 tools/native.py check # apply on the pinned Horizon, cargo check the game - python3 tools/native.py build # ... and build it (fast profile); prints the binary - python3 tools/native.py package # target/-.hnmod for the registry - python3 tools/native.py proof --resources PATH # build, then drive the game with horizon-ctl - --horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of - compatibility.json's: the mod uses only the native hook points, so it builds - on any Horizon whose NATIVE_HOOKS_API is at least the one it names, as the - launcher does. - -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 target/. -""" -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 - -ROOT = Path(__file__).resolve().parents[1] -TARGET = ROOT / "target" -COMPAT = json.loads((ROOT / "compatibility.json").read_text()) -MIXINS = ROOT / "mixins" -BUNDLE = next(p for p in MIXINS.iterdir() if (p / "horizon-mixin.toml").is_file()) -MANIFEST = tomllib.loads((BUNDLE / "horizon-mixin.toml").read_text()) -MOD_ID = MANIFEST["mod"]["id"] -VERSION = MANIFEST["mod"]["version"] -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) - - -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/- tag.""" - refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/")) - newest = max(refs, key=lambda r: r["ref"]) - 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//.""" - 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(): - """horizon-mixins at the pinned commit, built once.""" - rev = COMPAT["horizon_mixins_revision"] - root = TARGET / "horizon-mixins" / rev - binary = root / "bin" / "horizon-mixins" - if not binary.is_file(): - log(f"Building horizon-mixins {rev[:7]}") - subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", COMPAT["horizon_mixins_repository"], - "--rev", rev, "--root", str(root), "horizon-mixins-cli"], check=True) - return binary - - -def stage(revision): - """The mod applied on Horizon `revision`: target/stage/ (cargo's incremental state survives).""" - tree = source(revision) - api = hooks_api(tree) - # Hook points are only ever added: any later version has the ones this mod hooks. - if api is None or api < COMPAT["hooks"]: - sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, this mod needs v{COMPAT['hooks']}.") - # As the launcher does for a mod on the hook points: pin the copy to the commit it builds on. - mods = TARGET / "mods" - shutil.rmtree(mods, ignore_errors=True) - shutil.copytree(BUNDLE, mods / MOD_ID) - toml = mods / MOD_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()), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(fresh)], - check=True, stdout=subprocess.DEVNULL) - out = TARGET / "stage" - out.mkdir(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": - 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(): - """A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files.""" - manifest = {"schema": 1, "id": MOD_ID, "version": VERSION, "game": COMPAT["game"], - "horizon": COMPAT["horizon_revision"], "mixins": COMPAT["horizon_mixins_revision"], - "hooks": COMPAT["hooks"]} - pinned = MANIFEST.get("compatibility", {}).get("horizon_revision") - if pinned != COMPAT["horizon_revision"]: - sys.exit(f"horizon-mixin.toml pins {pinned}, compatibility.json {COMPAT['horizon_revision']}") - files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()} - for path in sorted(BUNDLE.iterdir()): - if path.suffix in (".rs", ".toml"): - files[f"mixins/{MOD_ID}/{path.name}"] = path.read_bytes() - out = TARGET / f"{MOD_ID}-{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("--horizon", default=COMPAT["horizon_revision"], - help="a Horizon commit, `latest` (newest Nightly) or a tag") - parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3") - args = parser.parse_args() - if args.action == "package": - return package() - 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"{MOD_ID} {VERSION} on Horizon {revision[:7]}") - out = stage(revision) - if args.action == "check": - cargo(out, "check") - print(json.dumps({"mod": MOD_ID, "horizon": revision, "check": "ok"})) - return - binaries = cargo(out, "build") - if args.action == "build": - print(json.dumps({"mod": MOD_ID, "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(ROOT / "tools")) - import proof - proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof") - - -if __name__ == "__main__": - main() diff --git a/native/minimap/.github/workflows/ci.yml b/native/minimap/.github/workflows/ci.yml deleted file mode 100644 index 84f47e7..0000000 --- a/native/minimap/.github/workflows/ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CI -on: - push: - pull_request: - # Every Monday: the mod on the newest Nightly, to catch a change of the hook points early. - schedule: - - cron: "17 5 * * 1" - workflow_dispatch: - inputs: - horizon: - description: "Horizon commit, tag, or `latest` (the newest Nightly)" - default: latest -permissions: - contents: read -env: - # The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs). - RUST: "1.99.1" -jobs: - mod: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST - - run: cargo fmt --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked - - name: Package the .hnmod and check what it holds - run: | - python3 tools/native.py package - python3 - <<'PY' - import json, zipfile, glob - path, = glob.glob("target/*.hnmod") - names = zipfile.ZipFile(path).namelist() - manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json")) - assert manifest["hooks"] == 1, manifest - assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names - print(path, manifest) - PY - - uses: actions/upload-artifact@v4 - with: - name: hnmod - path: target/*.hnmod - horizon: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config - - name: Apply on Horizon and check the game - env: - GITHUB_TOKEN: ${{ github.token }} - HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} - run: python3 tools/native.py check ${HORIZON:+--horizon "$HORIZON"} diff --git a/native/minimap/.gitignore b/native/minimap/.gitignore deleted file mode 100644 index 2f7896d..0000000 --- a/native/minimap/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/native/minimap/README.md b/native/minimap/README.md index 3f51a59..d39d2bd 100644 --- a/native/minimap/README.md +++ b/native/minimap/README.md @@ -23,17 +23,18 @@ mod. `horizon_revision` is only the commit it was last tested on. | `mixins/minimap/horizon-mixin.toml` | The bundle: one module and the HEAD hooks | | `mixins/minimap/runtime.rs` | The mod's logic and state, added to the game as `game::official_minimap`; also this crate's library, for its tests | | `mixins/minimap/*.rs` | One small helper per hook | -| `tools/native.py` | Apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | -| `tools/proof.py`, `tools/game.py` | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | +| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | +| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | ## Build and prove ```sh -cargo test # the logic -python3 tools/native.py check # apply on the tested Horizon and cargo check the game -python3 tools/native.py check --horizon latest # ... on the newest Nightly -python3 tools/native.py package # target/minimap-.hnmod for the registry -python3 tools/native.py proof --resources /path/to/.local/horizon-client/26.3 +(cd native/minimap && cargo test) # the logic +python3 native/tools/native.py check minimap # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py check minimap --horizon latest # ... on the newest Nightly +python3 native/tools/native.py check all # with the other native mods, as a player's set +python3 native/tools/native.py package minimap # native/target/minimap-.hnmod for the registry +python3 native/tools/native.py proof minimap --resources /path/to/.local/horizon-client/26.3 ``` The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository. diff --git a/native/minimap/compatibility.json b/native/minimap/compatibility.json index dc242a9..9cc6aea 100644 --- a/native/minimap/compatibility.json +++ b/native/minimap/compatibility.json @@ -2,7 +2,7 @@ "game": "26.3", "hooks": 1, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c", + "horizon_revision": "1149c92cb8c1fde5136197b6647a6eb220401a60", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0500ff24aa5933fca95a877ef41125353c4b4fe3" } diff --git a/native/minimap/mixins/minimap/horizon-mixin.toml b/native/minimap/mixins/minimap/horizon-mixin.toml index f95ea43..202f95f 100644 --- a/native/minimap/mixins/minimap/horizon-mixin.toml +++ b/native/minimap/mixins/minimap/horizon-mixin.toml @@ -6,7 +6,7 @@ priority = 100 # The commit it was tested on. It uses only the native hook points (hooks 1 in # compatibility.json): the launcher builds it on any Horizon that has them. [compatibility] -horizon_revision = "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c" +horizon_revision = "1149c92cb8c1fde5136197b6647a6eb220401a60" [[module]] id = "runtime" diff --git a/native/minimap/tools/proof.py b/native/minimap/proof.py similarity index 100% rename from native/minimap/tools/proof.py rename to native/minimap/proof.py diff --git a/native/minimap/tools/game.py b/native/minimap/tools/game.py deleted file mode 100644 index 2e251e1..0000000 --- a/native/minimap/tools/game.py +++ /dev/null @@ -1,117 +0,0 @@ -"""A modded game driven with horizon-ctl, on fresh data: a temporary game folder (the game keeps its -options, worlds and mods' settings in its working folder) and temporary launcher and XDG folders, with -no audio and no self-update. Nothing touches the player's own data.""" -import json -import os -import struct -import subprocess -import tempfile -import time -import zlib -from pathlib import Path - - -class Game: - def __init__(self, ctl, resources, out, files=None): - self.ctl, self.resources, self.out = Path(ctl), Path(resources), Path(out) - self.files = files or {} - - def __enter__(self): - self.out.mkdir(parents=True, exist_ok=True) - self.dirs = [tempfile.mkdtemp(prefix=f"native-proof-{n}-") for n in ("home", "launcher", "data", "config")] - self.home = Path(self.dirs[0]) - for name, text in self.files.items(): - (self.home / name).parent.mkdir(parents=True, exist_ok=True) - (self.home / name).write_text(text) - self.socket = str(Path(tempfile.mkdtemp(prefix="native-ctl-")) / "ctl.sock") - self.env = dict(os.environ, HORIZON_HOME=self.dirs[0], HORIZON_LAUNCHER_HOME=self.dirs[1], - XDG_DATA_HOME=self.dirs[2], XDG_CONFIG_HOME=self.dirs[3], - HORIZON_LAUNCHER_NO_SELF_UPDATE="1", HORIZON_NO_AUDIO="1") - self.run("launch", "--world", "Proof", "--creative", "--offscreen", "--wait", - "--resources", str(self.resources)) - time.sleep(3) - return self - - def __exit__(self, *_): - subprocess.run([str(self.ctl), "--socket", self.socket, "quit"], env=self.env, cwd=self.home, - capture_output=True) - - def run(self, *args): - done = subprocess.run([str(self.ctl), "--socket", self.socket, *args], env=self.env, cwd=self.home, - capture_output=True, text=True, check=True) - try: - return json.loads(done.stdout) - except json.JSONDecodeError: - return done.stdout - - def play(self): - """Back to the world from any screen.""" - for _ in range(6): - if self.run("status").get("screen") == "playing": - return - self.run("key", "esc", "tap") - time.sleep(0.5) - if self.run("status").get("screen") == "playing": - return - raise AssertionError(f"not back in the world: {self.run('status').get('screen')}") - - def labels(self): - return [w.get("label") for w in self.run("ui").get("widgets", [])] - - def ids(self): - return {w.get("label"): w.get("id") for w in self.run("ui").get("widgets", [])} - - def shot(self, name): - path = self.out / f"{name}.png" - self.run("screenshot", str(path)) - return path - - -def pixels(path): - """An 8-bit RGB or RGBA, non-interlaced PNG's (width, height, channels, rows).""" - data = Path(path).read_bytes() - pos, chunks, width = 8, b"", 0 - while pos < len(data): - length, kind = struct.unpack(">I4s", data[pos:pos + 8]) - body = data[pos + 8:pos + 8 + length] - if kind == b"IHDR": - width, height, depth, color = struct.unpack(">IIBB", body[:10]) - channels = {2: 3, 6: 4}[color] - assert depth == 8 - elif kind == b"IDAT": - chunks += body - pos += 12 + length - raw, stride, rows, prev = zlib.decompress(chunks), width * channels, [], bytearray(width * channels) - for y in range(height): - start = y * (stride + 1) - kind, line = raw[start], bytearray(raw[start + 1:start + 1 + stride]) - for i in range(stride): - a = line[i - channels] if i >= channels else 0 - b, c = prev[i], prev[i - channels] if i >= channels else 0 - if kind == 1: - line[i] = (line[i] + a) & 255 - elif kind == 2: - line[i] = (line[i] + b) & 255 - elif kind == 3: - line[i] = (line[i] + (a + b) // 2) & 255 - elif kind == 4: - p = a + b - c - pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) - line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 - rows.append(line) - prev = line - return width, height, channels, rows - - -def difference(a, b, box=(0.0, 0.0, 1.0, 1.0)): - """The mean difference of two screenshots' colours, 0..255, on a grid of samples, within `box` - (left, top, right, bottom, as fractions of the screen).""" - wa, ha, ca, ra = pixels(a) - _, _, cb, rb = pixels(b) - total = count = 0 - x0, y0, x1, y1 = int(box[0] * wa), int(box[1] * ha), int(box[2] * wa), int(box[3] * ha) - for y in range(y0, y1, 4): - for x in range(x0, x1, 4): - total += sum(abs(ra[y][x * ca + k] - rb[y][x * cb + k]) for k in range(3)) - count += 3 - return total / count diff --git a/native/minimap/tools/native.py b/native/minimap/tools/native.py deleted file mode 100644 index bfb9453..0000000 --- a/native/minimap/tools/native.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Build this native mod into Horizon, package it, and prove it in a running game. - - python3 tools/native.py check # apply on the pinned Horizon, cargo check the game - python3 tools/native.py build # ... and build it (fast profile); prints the binary - python3 tools/native.py package # target/-.hnmod for the registry - python3 tools/native.py proof --resources PATH # build, then drive the game with horizon-ctl - --horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of - compatibility.json's: the mod uses only the native hook points, so it builds - on any Horizon whose NATIVE_HOOKS_API is at least the one it names, as the - launcher does. - -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 target/. -""" -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 - -ROOT = Path(__file__).resolve().parents[1] -TARGET = ROOT / "target" -COMPAT = json.loads((ROOT / "compatibility.json").read_text()) -MIXINS = ROOT / "mixins" -BUNDLE = next(p for p in MIXINS.iterdir() if (p / "horizon-mixin.toml").is_file()) -MANIFEST = tomllib.loads((BUNDLE / "horizon-mixin.toml").read_text()) -MOD_ID = MANIFEST["mod"]["id"] -VERSION = MANIFEST["mod"]["version"] -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) - - -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/- tag.""" - refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/")) - newest = max(refs, key=lambda r: r["ref"]) - 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//.""" - 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(): - """horizon-mixins at the pinned commit, built once.""" - rev = COMPAT["horizon_mixins_revision"] - root = TARGET / "horizon-mixins" / rev - binary = root / "bin" / "horizon-mixins" - if not binary.is_file(): - log(f"Building horizon-mixins {rev[:7]}") - subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", COMPAT["horizon_mixins_repository"], - "--rev", rev, "--root", str(root), "horizon-mixins-cli"], check=True) - return binary - - -def stage(revision): - """The mod applied on Horizon `revision`: target/stage/ (cargo's incremental state survives).""" - tree = source(revision) - api = hooks_api(tree) - # Hook points are only ever added: any later version has the ones this mod hooks. - if api is None or api < COMPAT["hooks"]: - sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, this mod needs v{COMPAT['hooks']}.") - # As the launcher does for a mod on the hook points: pin the copy to the commit it builds on. - mods = TARGET / "mods" - shutil.rmtree(mods, ignore_errors=True) - shutil.copytree(BUNDLE, mods / MOD_ID) - toml = mods / MOD_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()), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(fresh)], - check=True, stdout=subprocess.DEVNULL) - out = TARGET / "stage" - out.mkdir(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": - 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(): - """A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files.""" - manifest = {"schema": 1, "id": MOD_ID, "version": VERSION, "game": COMPAT["game"], - "horizon": COMPAT["horizon_revision"], "mixins": COMPAT["horizon_mixins_revision"], - "hooks": COMPAT["hooks"]} - pinned = MANIFEST.get("compatibility", {}).get("horizon_revision") - if pinned != COMPAT["horizon_revision"]: - sys.exit(f"horizon-mixin.toml pins {pinned}, compatibility.json {COMPAT['horizon_revision']}") - files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()} - for path in sorted(BUNDLE.iterdir()): - if path.suffix in (".rs", ".toml"): - files[f"mixins/{MOD_ID}/{path.name}"] = path.read_bytes() - out = TARGET / f"{MOD_ID}-{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("--horizon", default=COMPAT["horizon_revision"], - help="a Horizon commit, `latest` (newest Nightly) or a tag") - parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3") - args = parser.parse_args() - if args.action == "package": - return package() - 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"{MOD_ID} {VERSION} on Horizon {revision[:7]}") - out = stage(revision) - if args.action == "check": - cargo(out, "check") - print(json.dumps({"mod": MOD_ID, "horizon": revision, "check": "ok"})) - return - binaries = cargo(out, "build") - if args.action == "build": - print(json.dumps({"mod": MOD_ID, "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(ROOT / "tools")) - import proof - proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof") - - -if __name__ == "__main__": - main() diff --git a/native/fps-overlay/tools/game.py b/native/tools/game.py similarity index 100% rename from native/fps-overlay/tools/game.py rename to native/tools/game.py diff --git a/native/tools/native.py b/native/tools/native.py new file mode 100644 index 0000000..29ec997 --- /dev/null +++ b/native/tools/native.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build Horizon's native mods (native//) 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/-.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. + `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//: 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/- tag.""" + refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/")) + newest = max(refs, key=lambda r: r["ref"]) + 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//.""" + 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// (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": + 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 and the bundle's .rs and .toml files.""" + 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()} + for path in sorted(m.bundle.iterdir()): + if path.suffix in (".rs", ".toml"): + files[f"mixins/{m.id}/{path.name}"] = path.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: + revs = {m.compat["horizon_revision"] for m in mods} + 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() diff --git a/native/zoom/.github/workflows/ci.yml b/native/zoom/.github/workflows/ci.yml deleted file mode 100644 index 84f47e7..0000000 --- a/native/zoom/.github/workflows/ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CI -on: - push: - pull_request: - # Every Monday: the mod on the newest Nightly, to catch a change of the hook points early. - schedule: - - cron: "17 5 * * 1" - workflow_dispatch: - inputs: - horizon: - description: "Horizon commit, tag, or `latest` (the newest Nightly)" - default: latest -permissions: - contents: read -env: - # The Rust the launcher builds native mods with (launcher/src/rust_toolchain.rs). - RUST: "1.99.1" -jobs: - mod: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal --component rustfmt --component clippy && rustup default $RUST - - run: cargo fmt --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked - - name: Package the .hnmod and check what it holds - run: | - python3 tools/native.py package - python3 - <<'PY' - import json, zipfile, glob - path, = glob.glob("target/*.hnmod") - names = zipfile.ZipFile(path).namelist() - manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json")) - assert manifest["hooks"] == 1, manifest - assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names - print(path, manifest) - PY - - uses: actions/upload-artifact@v4 - with: - name: hnmod - path: target/*.hnmod - horizon: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: rustup toolchain install $RUST --profile minimal && rustup default $RUST - - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config - - name: Apply on Horizon and check the game - env: - GITHUB_TOKEN: ${{ github.token }} - HORIZON: ${{ github.event_name == 'schedule' && 'latest' || inputs.horizon || '' }} - run: python3 tools/native.py check ${HORIZON:+--horizon "$HORIZON"} diff --git a/native/zoom/.gitignore b/native/zoom/.gitignore deleted file mode 100644 index 2f7896d..0000000 --- a/native/zoom/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/native/zoom/README.md b/native/zoom/README.md index f0a1c84..7e57d2c 100644 --- a/native/zoom/README.md +++ b/native/zoom/README.md @@ -27,17 +27,18 @@ mod. `horizon_revision` is only the commit it was last tested on. | `mixins/zoom/horizon-mixin.toml` | The bundle: one module and the HEAD hooks | | `mixins/zoom/runtime.rs` | The mod's logic and state, added to the game as `game::official_zoom`; also this crate's library, for its tests | | `mixins/zoom/*.rs` | One small helper per hook | -| `tools/native.py` | Apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | -| `tools/proof.py`, `tools/game.py` | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | +| `../tools/native.py` | Shared by the native mods: apply on Horizon (from GitHub, by commit), check, build, package the `.hnmod`, run the proof | +| `proof.py` (with `../tools/game.py`) | The proof: the built game driven with `horizon-ctl --offscreen` on fresh, temporary data | ## Build and prove ```sh -cargo test # the logic -python3 tools/native.py check # apply on the tested Horizon and cargo check the game -python3 tools/native.py check --horizon latest # ... on the newest Nightly -python3 tools/native.py package # target/zoom-.hnmod for the registry -python3 tools/native.py proof --resources /path/to/.local/horizon-client/26.3 +(cd native/zoom && cargo test) # the logic +python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game +python3 native/tools/native.py check zoom --horizon latest # ... on the newest Nightly +python3 native/tools/native.py check all # with the other native mods, as a player's set +python3 native/tools/native.py package zoom # native/target/zoom-.hnmod for the registry +python3 native/tools/native.py proof zoom --resources /path/to/.local/horizon-client/26.3 ``` The proof needs resources prepared from your own Minecraft 26.3 (`horizon-prepare`); none are in this repository. diff --git a/native/zoom/compatibility.json b/native/zoom/compatibility.json index dc242a9..9cc6aea 100644 --- a/native/zoom/compatibility.json +++ b/native/zoom/compatibility.json @@ -2,7 +2,7 @@ "game": "26.3", "hooks": 1, "horizon_repository": "https://github.com/horizon-rust/horizon", - "horizon_revision": "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c", + "horizon_revision": "1149c92cb8c1fde5136197b6647a6eb220401a60", "horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins", "horizon_mixins_revision": "0500ff24aa5933fca95a877ef41125353c4b4fe3" } diff --git a/native/zoom/mixins/zoom/horizon-mixin.toml b/native/zoom/mixins/zoom/horizon-mixin.toml index f5d5e18..b578d39 100644 --- a/native/zoom/mixins/zoom/horizon-mixin.toml +++ b/native/zoom/mixins/zoom/horizon-mixin.toml @@ -6,7 +6,7 @@ priority = 100 # The commit it was tested on. It uses only the native hook points (hooks 1 in # compatibility.json): the launcher builds it on any Horizon that has them. [compatibility] -horizon_revision = "db38aedc03b2982976bcfe6ae84cd98bc9f2ce1c" +horizon_revision = "1149c92cb8c1fde5136197b6647a6eb220401a60" [[module]] id = "runtime" diff --git a/native/zoom/tools/proof.py b/native/zoom/proof.py similarity index 100% rename from native/zoom/tools/proof.py rename to native/zoom/proof.py diff --git a/native/zoom/tools/game.py b/native/zoom/tools/game.py deleted file mode 100644 index 2e251e1..0000000 --- a/native/zoom/tools/game.py +++ /dev/null @@ -1,117 +0,0 @@ -"""A modded game driven with horizon-ctl, on fresh data: a temporary game folder (the game keeps its -options, worlds and mods' settings in its working folder) and temporary launcher and XDG folders, with -no audio and no self-update. Nothing touches the player's own data.""" -import json -import os -import struct -import subprocess -import tempfile -import time -import zlib -from pathlib import Path - - -class Game: - def __init__(self, ctl, resources, out, files=None): - self.ctl, self.resources, self.out = Path(ctl), Path(resources), Path(out) - self.files = files or {} - - def __enter__(self): - self.out.mkdir(parents=True, exist_ok=True) - self.dirs = [tempfile.mkdtemp(prefix=f"native-proof-{n}-") for n in ("home", "launcher", "data", "config")] - self.home = Path(self.dirs[0]) - for name, text in self.files.items(): - (self.home / name).parent.mkdir(parents=True, exist_ok=True) - (self.home / name).write_text(text) - self.socket = str(Path(tempfile.mkdtemp(prefix="native-ctl-")) / "ctl.sock") - self.env = dict(os.environ, HORIZON_HOME=self.dirs[0], HORIZON_LAUNCHER_HOME=self.dirs[1], - XDG_DATA_HOME=self.dirs[2], XDG_CONFIG_HOME=self.dirs[3], - HORIZON_LAUNCHER_NO_SELF_UPDATE="1", HORIZON_NO_AUDIO="1") - self.run("launch", "--world", "Proof", "--creative", "--offscreen", "--wait", - "--resources", str(self.resources)) - time.sleep(3) - return self - - def __exit__(self, *_): - subprocess.run([str(self.ctl), "--socket", self.socket, "quit"], env=self.env, cwd=self.home, - capture_output=True) - - def run(self, *args): - done = subprocess.run([str(self.ctl), "--socket", self.socket, *args], env=self.env, cwd=self.home, - capture_output=True, text=True, check=True) - try: - return json.loads(done.stdout) - except json.JSONDecodeError: - return done.stdout - - def play(self): - """Back to the world from any screen.""" - for _ in range(6): - if self.run("status").get("screen") == "playing": - return - self.run("key", "esc", "tap") - time.sleep(0.5) - if self.run("status").get("screen") == "playing": - return - raise AssertionError(f"not back in the world: {self.run('status').get('screen')}") - - def labels(self): - return [w.get("label") for w in self.run("ui").get("widgets", [])] - - def ids(self): - return {w.get("label"): w.get("id") for w in self.run("ui").get("widgets", [])} - - def shot(self, name): - path = self.out / f"{name}.png" - self.run("screenshot", str(path)) - return path - - -def pixels(path): - """An 8-bit RGB or RGBA, non-interlaced PNG's (width, height, channels, rows).""" - data = Path(path).read_bytes() - pos, chunks, width = 8, b"", 0 - while pos < len(data): - length, kind = struct.unpack(">I4s", data[pos:pos + 8]) - body = data[pos + 8:pos + 8 + length] - if kind == b"IHDR": - width, height, depth, color = struct.unpack(">IIBB", body[:10]) - channels = {2: 3, 6: 4}[color] - assert depth == 8 - elif kind == b"IDAT": - chunks += body - pos += 12 + length - raw, stride, rows, prev = zlib.decompress(chunks), width * channels, [], bytearray(width * channels) - for y in range(height): - start = y * (stride + 1) - kind, line = raw[start], bytearray(raw[start + 1:start + 1 + stride]) - for i in range(stride): - a = line[i - channels] if i >= channels else 0 - b, c = prev[i], prev[i - channels] if i >= channels else 0 - if kind == 1: - line[i] = (line[i] + a) & 255 - elif kind == 2: - line[i] = (line[i] + b) & 255 - elif kind == 3: - line[i] = (line[i] + (a + b) // 2) & 255 - elif kind == 4: - p = a + b - c - pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) - line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 - rows.append(line) - prev = line - return width, height, channels, rows - - -def difference(a, b, box=(0.0, 0.0, 1.0, 1.0)): - """The mean difference of two screenshots' colours, 0..255, on a grid of samples, within `box` - (left, top, right, bottom, as fractions of the screen).""" - wa, ha, ca, ra = pixels(a) - _, _, cb, rb = pixels(b) - total = count = 0 - x0, y0, x1, y1 = int(box[0] * wa), int(box[1] * ha), int(box[2] * wa), int(box[3] * ha) - for y in range(y0, y1, 4): - for x in range(x0, x1, 4): - total += sum(abs(ra[y][x * ca + k] - rb[y][x * cb + k]) for k in range(3)) - count += 3 - return total / count diff --git a/native/zoom/tools/native.py b/native/zoom/tools/native.py deleted file mode 100644 index bfb9453..0000000 --- a/native/zoom/tools/native.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Build this native mod into Horizon, package it, and prove it in a running game. - - python3 tools/native.py check # apply on the pinned Horizon, cargo check the game - python3 tools/native.py build # ... and build it (fast profile); prints the binary - python3 tools/native.py package # target/-.hnmod for the registry - python3 tools/native.py proof --resources PATH # build, then drive the game with horizon-ctl - --horizon REV build on another Horizon commit (`latest` = the newest Nightly) instead of - compatibility.json's: the mod uses only the native hook points, so it builds - on any Horizon whose NATIVE_HOOKS_API is at least the one it names, as the - launcher does. - -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 target/. -""" -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 - -ROOT = Path(__file__).resolve().parents[1] -TARGET = ROOT / "target" -COMPAT = json.loads((ROOT / "compatibility.json").read_text()) -MIXINS = ROOT / "mixins" -BUNDLE = next(p for p in MIXINS.iterdir() if (p / "horizon-mixin.toml").is_file()) -MANIFEST = tomllib.loads((BUNDLE / "horizon-mixin.toml").read_text()) -MOD_ID = MANIFEST["mod"]["id"] -VERSION = MANIFEST["mod"]["version"] -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) - - -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/- tag.""" - refs = json.loads(get(f"https://api.github.com/repos/{REPO}/git/matching-refs/tags/nightly-source/")) - newest = max(refs, key=lambda r: r["ref"]) - 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//.""" - 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(): - """horizon-mixins at the pinned commit, built once.""" - rev = COMPAT["horizon_mixins_revision"] - root = TARGET / "horizon-mixins" / rev - binary = root / "bin" / "horizon-mixins" - if not binary.is_file(): - log(f"Building horizon-mixins {rev[:7]}") - subprocess.run(["cargo", "install", "--locked", "--quiet", "--git", COMPAT["horizon_mixins_repository"], - "--rev", rev, "--root", str(root), "horizon-mixins-cli"], check=True) - return binary - - -def stage(revision): - """The mod applied on Horizon `revision`: target/stage/ (cargo's incremental state survives).""" - tree = source(revision) - api = hooks_api(tree) - # Hook points are only ever added: any later version has the ones this mod hooks. - if api is None or api < COMPAT["hooks"]: - sys.exit(f"Horizon {revision[:7]} has native hook points v{api}, this mod needs v{COMPAT['hooks']}.") - # As the launcher does for a mod on the hook points: pin the copy to the commit it builds on. - mods = TARGET / "mods" - shutil.rmtree(mods, ignore_errors=True) - shutil.copytree(BUNDLE, mods / MOD_ID) - toml = mods / MOD_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()), "apply", "--workspace", str(tree), "--mods", str(mods), "--out", str(fresh)], - check=True, stdout=subprocess.DEVNULL) - out = TARGET / "stage" - out.mkdir(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": - 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(): - """A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files.""" - manifest = {"schema": 1, "id": MOD_ID, "version": VERSION, "game": COMPAT["game"], - "horizon": COMPAT["horizon_revision"], "mixins": COMPAT["horizon_mixins_revision"], - "hooks": COMPAT["hooks"]} - pinned = MANIFEST.get("compatibility", {}).get("horizon_revision") - if pinned != COMPAT["horizon_revision"]: - sys.exit(f"horizon-mixin.toml pins {pinned}, compatibility.json {COMPAT['horizon_revision']}") - files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()} - for path in sorted(BUNDLE.iterdir()): - if path.suffix in (".rs", ".toml"): - files[f"mixins/{MOD_ID}/{path.name}"] = path.read_bytes() - out = TARGET / f"{MOD_ID}-{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("--horizon", default=COMPAT["horizon_revision"], - help="a Horizon commit, `latest` (newest Nightly) or a tag") - parser.add_argument("--resources", help="prepared resources (proof): .local/horizon-client/26.3") - args = parser.parse_args() - if args.action == "package": - return package() - 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"{MOD_ID} {VERSION} on Horizon {revision[:7]}") - out = stage(revision) - if args.action == "check": - cargo(out, "check") - print(json.dumps({"mod": MOD_ID, "horizon": revision, "check": "ok"})) - return - binaries = cargo(out, "build") - if args.action == "build": - print(json.dumps({"mod": MOD_ID, "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(ROOT / "tools")) - import proof - proof.run(binaries / "horizon-ctl", Path(args.resources).resolve(), TARGET / "proof") - - -if __name__ == "__main__": - main()