mirror of
https://github.com/horizon-rust/horizon-native-mod-template.git
synced 2026-11-07 10:05:58 -10:00
Create native Horizon Mixins mod template based on Veinminer
This commit is contained in:
commit
c8bfba72fb
18 files changed
+843
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }]
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user