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/<name>/ 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 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-28 20:59:14 +01:00
1 parent 415352eed1
commit 876f7d1dc7
36 files changed
+418 -1411

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
# Horizon's own native mods (native/<name>/): 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"}
+2
View File
@@ -1,3 +1,5 @@
/files/
/site/
/bootstrap/
/native/target/
/native/*/target/
+20
View File
@@ -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/<name>/` | 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/<name>/` 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/<id>-<version>.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).
-53
View File
@@ -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"}
-1
View File
@@ -1 +0,0 @@
target/
+8 -7
View File
@@ -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-<version>.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-<version>.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.
+1 -1
View File
@@ -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"
}
@@ -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"
File renamed without changes.
-202
View File
@@ -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/<id>-<version>.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/<date>-<short> 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/<revision>/."""
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()
-53
View File
@@ -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"}
-1
View File
@@ -1 +0,0 @@
target/
+8 -7
View File
@@ -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-<version>.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-<version>.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.
+1 -1
View File
@@ -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"
}
@@ -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"
File renamed without changes.
-117
View File
@@ -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
-202
View File
@@ -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/<id>-<version>.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/<date>-<short> 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/<revision>/."""
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()
-53
View File
@@ -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"}
-1
View File
@@ -1 +0,0 @@
target/
+8 -7
View File
@@ -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-<version>.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-<version>.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.
+1 -1
View File
@@ -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"
}
@@ -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"
File renamed without changes.
-117
View File
@@ -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
-202
View File
@@ -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/<id>-<version>.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/<date>-<short> 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/<revision>/."""
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()
File renamed without changes.
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Build Horizon's native mods (native/<name>/) 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/<id>-<version>.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/<name>/: 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/<date>-<short> 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/<revision>/."""
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/<ids>/ (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()
-53
View File
@@ -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"}
-1
View File
@@ -1 +0,0 @@
target/
+8 -7
View File
@@ -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-<version>.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-<version>.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.
+1 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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"
File renamed without changes.
-117
View File
@@ -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
-202
View File
@@ -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/<id>-<version>.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/<date>-<short> 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/<revision>/."""
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()