Create native Horizon Mixins mod template based on Veinminer

This commit is contained in:
levzzz committed 2026-10-28 04:06:39 +05:00
commit c8bfba72fb
18 files changed
+843

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
name: Native mod template
on:
push:
pull_request:
permissions:
contents: read
jobs:
fixture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install pinned Rust
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
- name: Check out pinned mixin transformer
run: |
git clone https://github.com/horizon-rust/horizon-mixins ../horizon-mixins
git -C ../horizon-mixins checkout "$(python3 -c 'import json; print(json.load(open("compatibility.json"))["horizon_mixins_revision"])')"
cargo fetch --locked --manifest-path ../horizon-mixins/Cargo.toml
- run: cargo fetch --locked
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --locked --offline -- -D warnings
- run: cargo test --workspace --locked --offline
- run: python3 tools/test_template.py
+3
View File
@@ -0,0 +1,3 @@
/target/
__pycache__/
*.pyc
+15
View File
@@ -0,0 +1,15 @@
# Working on a native Horizon mod
Read README.md and compatibility.json before editing. Keep this repository
separate from Horizon and horizon-mixins. Only export committed pinned Horizon
sources into ignored target trees; never modify the original checkout or player
resources. Do not commit binaries, exported sources or Minecraft assets.
The Cargo library and injected runtime module share runtime.rs. Keep helper
functions small and gameplay logic testable there. All AST changes go through
horizon-mixins plan/apply; do not add string-based Rust source rewriting.
Validate with cargo fmt, clippy with warnings denied, cargo test and
python3 tools/test_template.py. For bundle/compatibility changes also run
python3 tools/test_stage.py and python3 tools/horizon.py check/test against the
pinned source export. Keep validation claims and supported revisions honest.
Generated
+133
View File
@@ -0,0 +1,133 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "horizon-example-mod"
version = "0.1.0"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "native-mod-source-audit"
version = "0.1.0"
dependencies = [
"prettyplease",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-example-mod"
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
description = "A minimal native Horizon Mixins mod"
[lib]
# This exact source is also injected as a same-crate module by horizon-mixins.
path = "mixins/example-mod/runtime.rs"
[workspace]
members = ["tools/audit"]
resolver = "2"
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Horizon Veinminer contributors
Copyright (c) 2026 Horizon native mod contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+151
View File
@@ -0,0 +1,151 @@
# Horizon native mod template
A minimal **horizon-mixins** mod template based on
[Horizon Veinminer](https://github.com/levzzz5154/horizon-veinminer).
It declares one HEAD hook that logs a block-break attempt, then lets Horizon's
original method run unchanged. Runtime logic lives in one Rust file that also
builds as a standalone testable Cargo library.
This is the native source-mixin path: it builds a custom Horizon executable.
For sandboxed `.hmod`/Wasm API mods, use Horizon's `cargo horizon-mod new`
instead. This template does not install into a running game or the launcher's
ordinary `.hmod` folder.
## Start a mod
Use GitHub's **Use this template → Create a new repository**, then clone your
new repository. Requirements: Linux, Rust 1.95.0, Python 3.12+, Git and Cargo.
The staging script uses Linux file locks; Windows/macOS are not validated.
```sh
cargo fetch --locked
cargo test --workspace --locked --offline
```
The exact compatible revisions are in [compatibility.json](compatibility.json):
- Horizon: `028f60881678a9a0e3e198f693a82858fa475c76`.
- Horizon Mixins: `0500ff2` (0.5.0, plan schema 5).
Clone the tools beside your mod if you do not already have suitable checkouts:
```sh
git clone https://github.com/horizon-rust/horizon ../horizon
git clone https://github.com/horizon-rust/horizon-mixins ../horizon-mixins
git -C ../horizon-mixins checkout "$(python3 -c 'import json; print(json.load(open("compatibility.json"))["horizon_mixins_revision"])')"
cargo fetch --locked --manifest-path ../horizon-mixins/Cargo.toml
```
Horizon can stay on its existing branch: staging exports the committed pin with
`git archive` and never checks out or edits the original source. Its Git history
must contain the pinned commit. A shallow clone may need a fetch first.
The Mixins checkout must be at the exact recorded revision with clean tracked
engine sources. For an existing development checkout at another revision, use
a separate clone/worktree and set `HORIZON_MIXINS=/path/to/pinned/horizon-mixins`.
## Layout and customization
```text
Cargo.toml Standalone runtime library + source audit workspace
compatibility.json Exact upstream/tool revisions and source hashes
mixins/example-mod/
horizon-mixin.toml Module and HEAD declaration
break.rs Small typed hook adapter
runtime.rs Runtime implementation and unit tests (single source)
tools/horizon.py Export → plan → apply → check/build/test
tools/test_template.py Tiny compiled fixture; no Horizon checkout/assets needed
tools/test_stage.py Real pinned source audit and repeatability test
tools/audit/ Rust AST comparison against the pristine export
```
To rename the example:
1. Change Cargo package name/description and the manifest's mod ID/version.
2. Rename `mixins/example-mod/` and update the Cargo `[lib].path`.
3. Change the manifest module name `example_mod` and its reference in `break.rs`.
4. Change the runtime log prefix, unit-test expectation and the native test filter
`game::example_mod::tests` in `tools/horizon.py`.
5. Run `cargo generate-lockfile --offline` after renaming the Cargo package and
run the checks below.
Add behavior to `runtime.rs`, then expose it through small typed helpers and
TOML declarations. No payload generation is necessary: Cargo and the injected
module read the same file. Module payloads cannot use inner file attributes
(including `//!` comments), external `mod` files or `include!`; use ordinary
comments and inline modules. Hooks compile in their target's crate/module, so
native-specific imports can be placed in the helper. To keep runtime tests
standalone, use ordinary Rust types or a small host interface there.
This example passes only `pos`. To access the native game, declare an explicit
helper binding for `&mut crate::game::Game<'_>` with value `&mut *self`, as
Veinminer does. Existing crate dependencies/privacy/borrowing remain enforced;
Cargo dependencies from this template are not automatically injected into Horizon.
See [Mixin contracts](https://github.com/horizon-rust/horizon-mixins/blob/main/README.md)
and [effects/state](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/effects-and-state.md)
for cancellation, captures, value changes, bridges and state.
## Stage, compile and run
```sh
python3 tools/horizon.py stage --horizon ../horizon
```
This prints the generated stage path. It uses only committed pinned sources,
records original hashes, runs the real Mixins `plan`/`apply` CLI, and preserves
unchanged bytes/mtimes on repeated runs. The original checkout and player data
stay untouched. The plan is saved at `target/mixins/plan.json`.
If Horizon dependencies are not cached, fetch the staged lockfile once:
```sh
cargo fetch --locked --manifest-path target/mixins/modded/Cargo.toml
python3 tools/horizon.py check --horizon ../horizon
python3 tools/horizon.py test --horizon ../horizon
python3 tools/horizon.py build --horizon ../horizon
```
`check` checks the actual client binary. `test` compiles it and runs the embedded
runtime test module. `build` uses Horizon's `fast` profile and
`--no-default-features` (audio/controllers disabled), and prints its binary path.
These commands use offline, locked builds; raw logs are in `target/horizon-*.log`.
`--target-dir PATH` selects a reusable external Cargo target directory.
Launch the built client with your own **prepared resources for the pinned
Horizon version**, and explicitly pass their path:
```sh
./target/horizon-cargo/fast/horizon-client --resources /absolute/path/to/prepared/resources
```
Prepare those resources using the compatible Horizon launcher or
`horizon-prepare --prepare`; game resources are not supplied by this repository.
Use the binary's `--help` for world/server arguments. Breaking a block should
print `[example-mod] Breaking block at ...` to the host's stderr. The HEAD log
reports attempts, including attempts the original game later rejects.
Compilation tests do not establish interactive gameplay/resource compatibility.
## Validation
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked --offline -- -D warnings
cargo test --workspace --locked --offline
python3 tools/test_template.py
# With a Horizon Git clone containing the pin:
python3 tools/test_stage.py --horizon ../horizon
python3 tools/horizon.py check --horizon ../horizon
python3 tools/horizon.py test --horizon ../horizon
```
The tiny fixture compiles the actual bundle, checks the original body executes
once with its result preserved, runs the shared runtime unit test, and verifies
repeated source bytes/mtimes. Wrong revisions and missing targets fail before
replacing the stage. The Rust audit checks the exact generated HEAD/helper and
module, removes only those planned additions, then compares the remaining AST
with pristine source. The same audit runs on a complete pinned Horizon export.
CI runs the standalone checks and tiny fixture; the full native checks require
the larger cached Horizon dependency set. See [VALIDATION.md](VALIDATION.md).
Build/staging tooling is adapted from the MIT-licensed Veinminer port. This
repository includes no Veinminer gameplay, Minecraft assets, game binaries or
exported Horizon sources. License: [MIT](LICENSE).
+30
View File
@@ -0,0 +1,30 @@
# Validation
Validated on Linux with Rust 1.95.0 on 2026-10-28, using the exact revisions in
`compatibility.json` and the real bundle committed here.
| Check | Result |
|---|---|
| `cargo fmt --all --check` | Pass |
| `cargo clippy --workspace --all-targets --locked --offline -- -D warnings` | Pass |
| `cargo test --workspace --locked --offline` | Runtime unit test passes |
| `python3 tools/test_template.py` | Actual bundle compiles in the tiny fixture; original method calls/result, shared runtime, AST audit, unchanged bytes/mtimes and incompatible revision/target rejection pass |
| `python3 tools/test_stage.py` | Complete pinned Horizon export stages deterministically; original checkout unchanged; exact HEAD/module audit passes |
| `python3 tools/horizon.py check` | Actual `horizon-client` binary checks successfully with `--no-default-features` |
| `python3 tools/horizon.py test` | The runtime's embedded test passes inside the actual Horizon game test binary |
The native check reported 24.76 seconds in Cargo; the native test reported 41.49
seconds. Both reused an existing external Cargo target directory with cached
Horizon dependencies. These are compilation/test checks, not a fresh-build
benchmark. Staging the full export took about 24 seconds per command on this
machine; a repeated application preserved the stage.
No game was launched and no Minecraft resources or player data were read or
changed. Interactive logging/gameplay remains a manual check with the compatible
prepared resources. The source audit verifies only the declared HEAD statement
and support module differ from pristine Rust ASTs; it does not claim compatibility
with other Horizon revisions or arbitrary third-party mods.
CI reproduces the formatting, clippy, standalone runtime and tiny-fixture tests.
The full native checks additionally need a Horizon Git history containing the
pin and its cached dependencies; use the commands in README.md to reproduce them.
+10
View File
@@ -0,0 +1,10 @@
{
"horizon_revision": "028f60881678a9a0e3e198f693a82858fa475c76",
"source_sha256": {
"crates/horizon-client/src/game.rs": "0dfd3cb9a29a43f7faa3443eb25a88290b6a8e05b18f52c892e8d14e232a2170"
},
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0500ff24aa5933fca95a877ef41125353c4b4fe3",
"mixin_schema": 5
}
+4
View File
@@ -0,0 +1,4 @@
// Runs before the native method. Returning unit leaves its body/result intact.
fn before_break(pos: [i32; 3]) {
crate::game::example_mod::before_break(pos);
}
+20
View File
@@ -0,0 +1,20 @@
[mod]
id = "example-mod"
version = "0.1.0"
priority = 100
[compatibility]
horizon_revision = "028f60881678a9a0e3e198f693a82858fa475c76"
[[module]]
id = "runtime"
file = "crates/horizon-client/src/game.rs"
name = "example_mod"
source = "runtime.rs"
[[injection]]
id = "before-break"
kind = "HEAD"
target = "horizon_client::game::Game<'_>::break_block"
helper = "break.rs"
bindings = [{ name = "pos", ty = "[i32;3]", value = "pos" }]
+26
View File
@@ -0,0 +1,26 @@
// Put testable mod logic here. This file is both the standalone Cargo library
// and the injected `crate::game::example_mod` module; no generated copy exists.
pub fn block_break_message(pos: [i32; 3]) -> String {
format!(
"[example-mod] Breaking block at {}, {}, {}",
pos[0], pos[1], pos[2]
)
}
pub fn before_break(pos: [i32; 3]) {
eprintln!("{}", block_break_message(pos));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_preserves_signed_block_coordinates() {
assert_eq!(
block_break_message([-3, 64, 12]),
"[example-mod] Breaking block at -3, 64, 12"
);
}
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.95.0"
components = ["rustfmt", "clippy"]
profile = "minimal"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "native-mod-source-audit"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
syn = { version = "2", features = ["full", "extra-traits"] }
quote = "1"
serde_json = "1"
prettyplease = "0.2"
+92
View File
@@ -0,0 +1,92 @@
use quote::quote;
use std::{collections::BTreeMap, env, fs, path::Path};
use syn::{Block, Item};
fn block<'a>(items: &'a mut [Item], node: &[usize]) -> &'a mut Block {
let (index, rest) = node.split_first().unwrap();
match &mut items[*index] {
Item::Fn(f) if rest.is_empty() => &mut f.block,
Item::Impl(i) if rest.len() == 1 => match &mut i.items[rest[0]] {
syn::ImplItem::Fn(f) => &mut f.block,
_ => panic!("planned method absent"),
},
Item::Mod(m) => block(&mut m.content.as_mut().unwrap().1, rest),
_ => panic!("planned target absent"),
}
}
fn canonical(items: &[Item]) -> String {
prettyplease::unparse(&syn::File {
shebang: None,
attrs: vec![],
items: items.to_vec(),
})
}
fn main() {
let args: Vec<_> = env::args().collect();
let plan: serde_json::Value = serde_json::from_slice(&fs::read(&args[3]).unwrap()).unwrap();
let mut trees = BTreeMap::new();
for p in plan["injections"].as_array().unwrap() {
let file = p["file"].as_str().unwrap();
let tree = trees.entry(file).or_insert_with(|| {
syn::parse_file(&fs::read_to_string(Path::new(&args[2]).join(file)).unwrap()).unwrap()
});
let node: Vec<usize> = p["node"]
.as_array()
.unwrap()
.iter()
.map(|n| n.as_u64().unwrap() as usize)
.collect();
assert_eq!(p["kind"], "HEAD");
assert_eq!(p["cancellable"], false);
let helper: syn::ItemFn = syn::parse_str(p["helper_source"].as_str().unwrap()).unwrap();
let name = &helper.sig.ident;
let args: Vec<syn::Expr> = p["bindings"]
.as_array()
.unwrap()
.iter()
.map(|b| syn::parse_str(b["value"].as_str().unwrap()).unwrap())
.collect();
let expected: syn::Stmt = syn::parse2(quote!({#helper #name(#(#args),*)};)).unwrap();
let b = block(&mut tree.items, &node);
let actual = b.stmts.first().expect("HEAD missing");
let actual_fn: Item = syn::parse_quote!(fn audit() {#actual});
let expected_fn: Item = syn::parse_quote!(fn audit() {#expected});
assert_eq!(
canonical(&[actual_fn]),
canonical(&[expected_fn]),
"HEAD differs from planned helper/bindings"
);
b.stmts.remove(0);
}
for m in plan["modules"].as_array().unwrap() {
let file = m["file"].as_str().unwrap();
let tree = trees.get_mut(file).unwrap();
let index = tree
.items
.iter()
.position(|i| matches!(i,Item::Mod(v) if v.ident==m["name"].as_str().unwrap()))
.expect("support module missing");
let Item::Mod(module) = tree.items.remove(index) else {
unreachable!()
};
let payload = syn::parse_file(m["source"].as_str().unwrap()).unwrap();
assert!(module.attrs.is_empty());
assert!(matches!(&module.vis, syn::Visibility::Restricted(v) if v.path.is_ident("crate")));
let mut expected = module.clone();
expected.content.as_mut().unwrap().1 = payload.items;
assert!(
canonical(&[Item::Mod(module)]) == canonical(&[Item::Mod(expected)]),
"support module differs from planned source"
);
}
for (file, tree) in trees {
let original =
syn::parse_file(&fs::read_to_string(Path::new(&args[1]).join(file)).unwrap()).unwrap();
assert!(
prettyplease::unparse(&original) == prettyplease::unparse(&tree),
"unexpected source change in {file}"
);
}
println!(
"all original ASTs preserved apart from the planned HEAD statement and support module"
);
}
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Export pinned sources, apply the template bundle and build a native Horizon."""
import argparse
import contextlib
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
COMPAT = json.loads((ROOT / "compatibility.json").read_text())
PIN = COMPAT["horizon_revision"]
MIXINS = Path(os.environ.get("HORIZON_MIXINS", ROOT.parent / "horizon-mixins")).resolve()
def digest(data):
return hashlib.sha256(data).hexdigest()
def stage(horizon, stages):
if not (horizon / ".git").exists():
raise ValueError("--horizon must be a Git checkout (including a worktree)")
subprocess.run(["git", "-C", str(horizon), "cat-file", "-e", PIN + "^{commit}"], check=True, capture_output=True)
inputs = {"revision": PIN.encode(), "export-schema": b"2"}
signature = digest(b"".join(k.encode() + b"\0" + value for k, value in sorted(inputs.items())))
stages.mkdir(parents=True, exist_ok=True)
out = stages / signature[:20]
if out.exists():
marker = json.loads((out / ".horizon-mod-template.json").read_text())
if marker["signature"] != signature:
raise ValueError(f"stage marker mismatch: {out}")
for path, expected in marker["files"].items():
if digest((out / path).read_bytes()) != expected:
raise ValueError(f"staged source was edited: {path}; remove this generated stage to rebuild it")
return out, False
temporary = Path(tempfile.mkdtemp(prefix=".mod-template-stage-", dir=stages))
try:
process = subprocess.Popen(["git", "-C", str(horizon), "archive", "--format=tar", PIN], stdout=subprocess.PIPE)
try:
with tarfile.open(fileobj=process.stdout, mode="r|") as archive:
for member in archive:
archive.extract(member, temporary, filter="data")
finally:
process.stdout.close()
code = process.wait()
if code:
raise ValueError(f"git archive exited {code}")
for path, expected in COMPAT["source_sha256"].items():
if digest((temporary / path).read_bytes()) != expected:
raise ValueError(f"pinned source hash mismatch: {path}")
# Materialize the committed documentation symlink for the mixin CLI's
# symlink-free source snapshot. No Rust source is edited here.
for file in temporary.rglob("*"):
if file.is_symlink():
content = file.read_bytes()
file.unlink()
file.write_bytes(content)
(temporary / ".horizon-revision").write_text(PIN + "\n")
# The source tree itself is deterministic. Cargo outputs live elsewhere.
files = {str(p.relative_to(temporary)): digest(p.read_bytes())
for p in sorted(temporary.rglob("*")) if p.is_file()}
(temporary / ".horizon-mod-template.json").write_text(json.dumps(
{"signature": signature, "horizon_revision": PIN, "files": files}, sort_keys=True, indent=2) + "\n")
os.rename(temporary, out)
except BaseException:
shutil.rmtree(temporary, ignore_errors=True)
raise
return out, True
def mixin_stage(horizon, stages):
revision = subprocess.check_output(["git", "-C", str(MIXINS), "rev-parse", "HEAD"], text=True).strip()
if revision != COMPAT["horizon_mixins_revision"]:
raise ValueError(f"horizon-mixins must be pinned at {COMPAT['horizon_mixins_revision']}, got {revision}")
subprocess.run(["git", "-C", str(MIXINS), "diff", "--quiet", "HEAD", "--", "Cargo.toml", "Cargo.lock", "crates"], check=True)
original, _ = stage(horizon, stages / "original")
subprocess.run(["cargo", "build", "--locked", "--offline", "--target-dir", str(MIXINS / "target"), "-p", "horizon-mixins-cli"], cwd=MIXINS, check=True, stdout=sys.stderr)
cli = MIXINS / "target/debug/horizon-mixins"
output = stages / "modded"
plan = stages / "plan.json"
base = ["--workspace", str(original), "--mods", str(ROOT / "mixins")]
subprocess.run([str(cli), "plan", *base, "--out", str(plan)], check=True, capture_output=True, text=True)
applied = subprocess.run([str(cli), "apply", *base, "--out", str(output)], check=True, capture_output=True, text=True)
report = json.loads(applied.stdout)
return output, report["stage_changed"]
@contextlib.contextmanager
def lock(path):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["stage", "check", "build", "test"])
parser.add_argument("--horizon", type=Path, default=ROOT.parent / "horizon")
parser.add_argument("--target-dir", type=Path, default=ROOT / "target/horizon-cargo")
args = parser.parse_args()
horizon = args.horizon.resolve()
target = args.target_dir.resolve()
if target.is_relative_to(horizon):
parser.error("Cargo target must be outside the original Horizon checkout")
started = time.monotonic()
with lock(ROOT / "target/stage.lock"):
output, changed = mixin_stage(horizon, ROOT / "target/mixins")
if target.is_relative_to(output) or output.is_relative_to(target):
parser.error("Cargo target and generated source stage must be separate")
report = {"stage": str(output), "stage_changed": changed,
"horizon_revision": PIN, "staging_seconds": time.monotonic()-started}
if args.action != "stage":
command = ["cargo", args.action, "--locked", "--offline", "-p", "horizon-client",
"--bin", "horizon-client", "--no-default-features", "--target-dir", str(target)]
if args.action == "build":
command.extend(["--profile", "fast"])
if args.action == "test":
command.extend(["game::example_mod::tests", "--", "--nocapture"])
with lock(target.parent / (target.name + ".mod-template.lock")):
run_start = time.monotonic()
result = subprocess.run(command, cwd=output, capture_output=True, text=True)
report.update(cargo_seconds=time.monotonic()-run_start, cargo_command=command,
cargo_exit=result.returncode)
log = ROOT / "target" / f"horizon-{args.action}.log"
log.write_text(result.stdout + result.stderr)
report["cargo_log"] = str(log)
if result.returncode:
print(result.stderr[-12000:], file=sys.stderr)
print(json.dumps(report), file=sys.stderr)
return result.returncode
if args.action == "build":
report["binary"] = str(target / "fast/horizon-client")
print(json.dumps(report, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, subprocess.SubprocessError) as error:
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
print(error.stderr, file=sys.stderr)
print(f"Native mod: {error}", file=sys.stderr)
raise SystemExit(1)
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Audit the real pinned export, deterministic staging and unchanged checkout."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
import horizon
from test_template import audit, snapshot
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--horizon', type=Path, default=horizon.ROOT.parent/'horizon')
args = parser.parse_args()
checkout = args.horizon.resolve()
status = subprocess.check_output(['git', '-C', str(checkout), 'status', '--porcelain'])
before = {p: (checkout/p).read_bytes() for p in horizon.COMPAT['source_sha256']}
(horizon.ROOT/'target').mkdir(exist_ok=True)
with horizon.lock(horizon.ROOT/'target/stage.lock'):
with tempfile.TemporaryDirectory(prefix='native-stage-test-', dir=horizon.ROOT/'target') as scratch:
root = Path(scratch)
output, changed = horizon.mixin_stage(checkout, root)
assert changed
first = snapshot(output)
second, changed = horizon.mixin_stage(checkout, root)
assert second == output and not changed and first == snapshot(output)
plan = json.loads((root/'plan.json').read_text())
assert plan['schema'] == horizon.COMPAT['mixin_schema']
assert len(plan['injections']) == 1 and len(plan['modules']) == 1
original = next((root/'original').glob('*/.horizon-mod-template.json')).parent
audit(original, output, root/'plan.json')
assert all((checkout/p).read_bytes() == content for p, content in before.items())
assert subprocess.check_output(['git', '-C', str(checkout), 'status', '--porcelain']) == status
print(json.dumps({'status': 'ok', 'original_checkout_unchanged': True,
'only_planned_asts_changed': True, 'deterministic_bytes_and_mtimes': True}, indent=2))
if __name__ == '__main__':
main()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Compile the real bundle on a tiny source fixture; no Horizon/assets required."""
import json
from pathlib import Path
import subprocess
import tempfile
import tomllib
import horizon
def run(command, **kwargs):
result = subprocess.run(command, capture_output=True, text=True, **kwargs)
if result.returncode:
raise RuntimeError(result.stdout + result.stderr)
return result
def snapshot(root):
return {str(p.relative_to(root)): (horizon.digest(p.read_bytes()), p.stat().st_mtime_ns)
for p in root.rglob('*') if p.is_file()}
def audit(original, stage, plan):
run(['cargo', 'run', '--quiet', '--locked', '--offline', '-p', 'native-mod-source-audit',
'--', str(original), str(stage), str(plan)], cwd=horizon.ROOT)
def main():
root = horizon.ROOT
(root/'target').mkdir(exist_ok=True)
run(['cargo', 'build', '--locked', '--offline', '-p', 'horizon-mixins-cli'], cwd=horizon.MIXINS)
cli = horizon.MIXINS/'target/debug/horizon-mixins'
manifests = list((root/'mixins').glob('*/horizon-mixin.toml'))
assert len(manifests) == 1, 'this template test expects one example bundle'
manifest = tomllib.loads(manifests[0].read_text())
with tempfile.TemporaryDirectory(prefix='template-test-', dir=root/'target') as scratch:
scratch = Path(scratch)
original, stage = scratch/'original', scratch/'stage'
(original/'crates/horizon-client/src').mkdir(parents=True)
(original/'Cargo.toml').write_text('[workspace]\nmembers=["crates/horizon-client"]\nresolver="2"\n')
(original/'crates/horizon-client/Cargo.toml').write_text('[package]\nname="horizon-client"\nversion="0.1.0"\nedition="2024"\n')
(original/'crates/horizon-client/src/lib.rs').write_text('pub mod game;\n')
(original/'crates/horizon-client/src/game.rs').write_text('''
pub struct Game<'a> { pub positions: &'a mut Vec<[i32;3]> }
impl Game<'_> {
pub fn break_block(&mut self,pos:[i32;3])->i32 {self.positions.push(pos); 7}
}
#[test] fn original_body_and_result_are_preserved() {
let mut positions=Vec::new();
let mut game=Game {positions:&mut positions};
assert_eq!(game.break_block([1,2,3]),7);
assert_eq!(game.break_block([-1,64,8]),7);
assert_eq!(positions,vec![[1,2,3],[-1,64,8]]);
}
''')
(original/'rust-toolchain.toml').write_bytes((root/'rust-toolchain.toml').read_bytes())
(original/'.horizon-revision').write_text(horizon.PIN+'\n')
run(['cargo', 'generate-lockfile', '--offline'], cwd=original)
before = snapshot(original)
base = ['--workspace', str(original), '--mods', str(root/'mixins')]
report = json.loads(run([str(cli), 'check', *base, '--out', str(stage), '--trust-native-mods']).stdout)
assert report['injections'] == 1 and report['modules'] == 1
assert '2 passed' in report['cargo_stdout']
first = snapshot(stage)
second = json.loads(run([str(cli), 'check', *base, '--out', str(stage), '--trust-native-mods']).stdout)
assert not second['stage_changed'] and snapshot(stage) == first
assert snapshot(original) == before
plan_path = scratch/'plan.json'
run([str(cli), 'plan', *base, '--out', str(plan_path)])
plan = json.loads(plan_path.read_text())
assert plan['schema'] == horizon.COMPAT['mixin_schema']
assert plan['injections'][0]['mod_id'] == manifest['mod']['id']
audit(original, stage, plan_path)
(original/'.horizon-revision').write_text('incompatible\n')
bad = subprocess.run([str(cli), 'apply', *base, '--out', str(stage)], capture_output=True, text=True)
assert bad.returncode and json.loads(bad.stderr)['code'] == 'E_REVISION'
assert snapshot(stage) == first
(original/'.horizon-revision').write_text(horizon.PIN+'\n')
source = original/'crates/horizon-client/src/game.rs'
source.write_text(source.read_text().replace('break_block', 'renamed_break_block'))
bad = subprocess.run([str(cli), 'apply', *base, '--out', str(stage)], capture_output=True, text=True)
assert bad.returncode and json.loads(bad.stderr)['code'] == 'E_TARGET_NOT_FOUND'
assert snapshot(stage) == first
print(json.dumps({'status': 'ok', 'compiled_runtime_and_original_body': True,
'only_planned_asts_changed': True, 'deterministic_bytes_and_mtimes': True,
'incompatible_revision_and_target_rejected': True}, indent=2))
if __name__ == '__main__':
main()