Test and document 0.6.0 bundle crates, data, named hooks and post-build

Nine integration tests: the compiled named-hook + crate + data fixture,
unchanged bytes and mtimes, two mods' crates under --locked, and every
new diagnostic. A docs page for 0.6.0, a roadmap row and README notes.
The boundary plan snapshot no longer records the local rustc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
levzzzandClaude Opus 5.5 committed 2026-10-30 02:11:39 +05:00
1 parent 0291f4d801
commit b74802d8ac
12 files changed
+590 -4

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ A working CLI that rewrites a tiny pinned Rust workspace where **two independent
- Start with declarative TOML, *not* proc-macro attributes.
- Index free functions, inline/external modules, inherent methods and trait impl methods; reject unresolved cfg and ambiguous paths.
- Resolve every injection **against pristine original AST** before rewriting. Sort same-site inserts by priority descending, mod ID ascending, injection ID ascending. Reject competing OVERWRITE and OVERWRITE mixed with insertion.
- The baseline implements HEAD, BEFORE_CALL and OVERWRITE. The user-authorized extension adds cancellable HEAD, RETURN, MODIFY_ARG, MODIFY_CONSTANT and WRAP_OPERATION; follow docs/advanced-injections.md. The next user-authorized extension adds local capture, accessor/invoker bridges, slices, MODIFY_ARGS and MODIFY_VARIABLE; follow docs/scopes-and-bridges.md. Version 0.4.0 adds lifetime-method HEAD support and support modules; follow docs/boundary-head-and-modules.md. The user-authorized 0.5.0 extension adds MODIFY_EXPRESSION_VALUE, AFTER_CALL, AFTER_ASSIGN, interior cancellation and per-instance state; follow docs/effects-and-state.md. Syntactic call selectors require explicit match counts and ordinals; never silently skip.
- The baseline implements HEAD, BEFORE_CALL and OVERWRITE. The user-authorized extension adds cancellable HEAD, RETURN, MODIFY_ARG, MODIFY_CONSTANT and WRAP_OPERATION; follow docs/advanced-injections.md. The next user-authorized extension adds local capture, accessor/invoker bridges, slices, MODIFY_ARGS and MODIFY_VARIABLE; follow docs/scopes-and-bridges.md. Version 0.4.0 adds lifetime-method HEAD support and support modules; follow docs/boundary-head-and-modules.md. The user-authorized 0.5.0 extension adds MODIFY_EXPRESSION_VALUE, AFTER_CALL, AFTER_ASSIGN, interior cancellation and per-instance state; follow docs/effects-and-state.md. Version 0.6.0 adds named hooks, bundle crates, data and post-build steps; follow docs/crates-data-and-named-hooks.md. Syntactic call selectors require explicit match counts and ordinals; never silently skip.
- Test preservation of expression return value and exactly-once argument evaluation. Reject unsupported macro-expanded sites, closures/async cases not proven safe.
- Respect Rust privacy and crate dependency cycles: generated same-crate bridge or another tested approach, not magical cross-crate private access.
- Atomic staging, unchanged-file mtime preservation and locked Cargo target dirs.
+14 -1
View File
@@ -116,7 +116,7 @@ cargo test --workspace --locked
python3 tools/benchmark.py # fresh target/benchmark; use --out for another run
```
The 80-test integration suite includes generated source/plan snapshots, positive and
The 89-test integration suite includes generated source/plan snapshots, positive and
negative compilation, deterministic ordering, immutable originals, stale-source
rejection, concurrent checks, and unchanged mtimes. Linux CI runs the same checks.
@@ -160,3 +160,16 @@ cargo run --locked -p horizon-mixins-cli -- check \
--workspace fixtures/effects --mods fixtures/effects-mods \
--out target/effects-game --trust-native-mods
```
Version 0.6.0 adds what larger native mods need: a named `hook` + `hook_version` from the
workspace's `native-hooks.json` instead of a raw target, the bundle's own crates (`[[crate]]`,
staged as workspace members with registry dependencies taken only from the existing `Cargo.lock`),
`data/` files staged to `native-data/<mod-id>/`, and `[[post_build]]` steps written to
`native-post-build.json` for the launcher (the tool never runs them). Plans use schema 6. See
[named hooks, bundle crates, data and post-build steps](docs/crates-data-and-named-hooks.md).
```sh
cargo run --locked -p horizon-mixins-cli -- check \
--workspace fixtures/native --mods fixtures/native-mods \
--out target/native-game --trust-native-mods
```
@@ -0,0 +1,375 @@
use super::*;
fn fixture() -> Fixture {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("original");
let mods = temp.path().join("mods");
copy(&repo().join("fixtures/native"), &root);
copy(&repo().join("fixtures/native-mods"), &mods);
Fixture {
out: temp.path().join("stage"),
_temp: temp,
root,
mods,
}
}
fn manifest(f: &Fixture, mod_dir: &str, change: impl FnOnce(&mut toml::Table)) {
let path = f.mods.join(mod_dir).join("horizon-mixin.toml");
let mut v: toml::Table = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
change(&mut v);
fs::write(path, toml::to_string(&v).unwrap()).unwrap();
}
fn first<'a>(v: &'a mut toml::Table, section: &str) -> &'a mut toml::Table {
v.get_mut(section).unwrap().as_array_mut().unwrap()[0]
.as_table_mut()
.unwrap()
}
fn failure(f: &Fixture, code: &str) -> horizon_mixins_core::Diagnostic {
let before = source::files(&f.root).unwrap();
let error = build_plan(&f.root, &f.mods).unwrap_err();
assert_eq!(error.code, code, "{error:?}");
assert!(!error.message.is_empty());
assert_eq!(source::files(&f.root).unwrap(), before);
assert!(!f.out.exists());
error
}
fn engine_manifest(f: &Fixture, text: &str) {
fs::write(f.mods.join("boost/engine/Cargo.toml"), text).unwrap();
}
const ENGINE: &str = "[package]\nname = \"boost-engine\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n";
#[test]
fn bundle_crate_data_and_named_hook_compile_with_the_locked_registry() {
let f = fixture();
let original = source::files(&f.root).unwrap();
let original_times = mtimes(&f.root);
let result = f.run("check");
assert_eq!(result["crates"], 1);
assert_eq!(result["data"], 1);
assert_eq!(result["post_build"], 1);
let stdout = result["cargo_stdout"].as_str().unwrap();
// The driver's test drives the hook; the bundle crate's own unit test runs as a member.
assert!(stdout.contains("named_hook_calls_the_bundle_crate_and_reads_bundle_data ... ok"));
assert!(stdout.contains("bonus_comes_from_the_crate_data ... ok"));
let staged = source::files(&f.out).unwrap();
assert_eq!(
String::from_utf8_lossy(&staged["Cargo.toml"]),
"[workspace]\n# The bundle crates are appended to this list in the stage.\nmembers = [\n \"game\",\n \"driver\",\n \"native-crates/boost/engine\",\n]\nresolver = \"2\"\n"
);
assert!(
String::from_utf8_lossy(&staged["game/Cargo.toml"]).ends_with(
"serde = { version = \"1\", features = [\"derive\"] }\nboost-engine = { path = \"../native-crates/boost/engine\" }\n"
)
);
let lock = String::from_utf8_lossy(&staged["Cargo.lock"]);
assert!(lock.contains(
"[[package]]\nname = \"boost-engine\"\nversion = \"0.1.0\"\ndependencies = [\n \"serde_json\",\n]\n\n[[package]]\nname = \"itoa\""
));
assert!(lock.contains("name = \"native-game\"\nversion = \"0.1.0\"\ndependencies = [\n \"boost-engine\",\n \"serde\",\n]"));
assert_eq!(
staged["native-crates/boost/engine/data/table.json"],
b"{\"bonus\": 40}\n"
);
assert_eq!(
staged["native-data/boost/nested/blob.bin"],
[0x00, 0xff, 0x10, 0x80]
);
assert_eq!(
String::from_utf8_lossy(&staged["native-post-build.json"]),
"[\n {\n \"mod\": \"boost\",\n \"package\": \"boost-engine\",\n \"bin\": \"bake\",\n \"args\": [\n \"--quick\"\n ]\n }\n]\n"
);
// The plan records the resolved target and the hook it came from.
let plan = build_plan(&f.root, &f.mods).unwrap();
assert_eq!(plan.schema, 6);
let tick = &plan.injections[0];
assert_eq!(tick.target, "native_game::hooks::Game::native_tick");
assert_eq!(
(tick.hook.as_deref(), tick.hook_version),
(Some("tick"), Some(1))
);
assert_eq!(plan.crates[0].lock_dependencies, ["serde_json"]);
// Rerunning keeps every byte and mtime; originals are untouched.
let times = mtimes(&f.out);
assert_eq!(f.run("check")["stage_changed"], false);
assert_eq!(source::files(&f.out).unwrap(), staged);
assert_eq!(mtimes(&f.out), times);
assert_eq!(source::files(&f.root).unwrap(), original);
assert_eq!(mtimes(&f.root), original_times);
// Editing the helper republishes the stage but leaves the crate's and data's files alone,
// so Cargo's fingerprints for the bundle crate stay fresh.
f.replace("mods/boost/tick.rs", ".trim()", ".trim_end()");
assert_eq!(f.run("apply")["stage_changed"], true);
let new_times = mtimes(&f.out);
for (name, time) in &times {
if name != "game/src/lib.rs" && name != ".horizon-mixins-stage.json" {
assert_eq!(new_times[name], *time, "mtime changed for {name}");
}
}
}
#[test]
fn mods_without_crates_still_get_an_empty_post_build_list() {
let f = fixture();
manifest(&f, "boost", |v| {
v.remove("crate");
v.remove("post_build");
});
fs::remove_dir_all(f.mods.join("boost/engine")).unwrap();
f.replace(
"mods/boost/tick.rs",
"game.score = boost_engine::boost(game.score);",
"game.score += 40;",
);
let result = f.run("check");
assert_eq!(result["crates"], 0);
let staged = source::files(&f.out).unwrap();
assert_eq!(staged["native-post-build.json"], b"[]\n");
assert_eq!(
staged["Cargo.lock"],
fs::read(f.root.join("Cargo.lock")).unwrap()
);
assert_eq!(
staged["Cargo.toml"],
fs::read(f.root.join("Cargo.toml")).unwrap()
);
assert!(staged.contains_key("native-data/boost/greeting.txt"));
}
#[test]
fn named_hook_catalog_errors_list_what_exists() {
let f = fixture();
fs::remove_file(f.root.join("native-hooks.json")).unwrap();
// Cargo.lock and the sources are unchanged, so only the catalog is missing.
let e = failure(&f, "E_HOOK_CATALOG");
assert_eq!(e.injection_id.as_deref(), Some("tick"));
let f = fixture();
fs::write(f.root.join("native-hooks.json"), "{\"hooks\": [").unwrap();
failure(&f, "E_HOOK_CATALOG");
let f = fixture();
fs::write(
f.root.join("native-hooks.json"),
r#"{"hooks":[{"name":"tick","version":1,"target":"a::b"},{"name":"tick","version":1,"target":"a::c"}]}"#,
)
.unwrap();
failure(&f, "E_HOOK_CATALOG");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").insert("hook".into(), "tock".into());
});
let e = failure(&f, "E_HOOK_UNKNOWN");
assert!(e.message.contains("known hooks: frame, tick"), "{e:?}");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").insert("hook_version".into(), 2.into());
});
let e = failure(&f, "E_HOOK_VERSION");
assert!(e.message.contains("has version 1"), "{e:?}");
// A hook that resolves to a target the index lacks fails like a raw target would.
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").insert("hook".into(), "frame".into());
});
fs::write(
f.root.join("native-hooks.json"),
r#"{"hooks":[{"name":"frame","version":1,"target":"native_game::hooks::Game::gone"}]}"#,
)
.unwrap();
assert_ne!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_HOOK_UNKNOWN"
);
}
#[test]
fn hook_and_target_are_exclusive() {
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").insert(
"target".into(),
"native_game::hooks::Game::native_tick".into(),
);
});
failure(&f, "E_MANIFEST");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").remove("hook_version");
});
failure(&f, "E_MANIFEST");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "injection").remove("hook");
});
failure(&f, "E_MANIFEST");
}
#[test]
fn bundle_crates_must_stay_inside_horizons_workspace_and_lock() {
for (text, code) in [
(
format!("{ENGINE}serde_json = \"1\"\n\n[workspace]\n"),
"E_CRATE",
),
(
format!("{ENGINE}local = {{ path = \"../x\" }}\n"),
"E_CRATE_DEPENDENCY",
),
(
format!("{ENGINE}remote = {{ git = \"https://example.com/x\" }}\n"),
"E_CRATE_DEPENDENCY",
),
(
format!("{ENGINE}serde_json = {{ version = \"1\", workspace = true }}\n"),
"E_CRATE_DEPENDENCY",
),
(format!("{ENGINE}rand = \"0.8\"\n"), "E_CRATE_DEPENDENCY"),
(
format!("{ENGINE}serde_json = \"2\"\n"),
"E_CRATE_DEPENDENCY",
),
(
format!("{ENGINE}\n[target.'cfg(unix)'.dependencies]\nserde_json = \"1\"\n"),
"E_CRATE",
),
(
format!("{ENGINE}\n[lib]\npath = \"../../tick.rs\"\n"),
"E_CRATE",
),
(ENGINE.replace("boost-engine", "native-game"), "E_CRATE"),
(ENGINE.replace("boost-engine", "serde_json"), "E_CRATE"),
(
ENGINE.replace("version = \"0.1.0\"", "version.workspace = true"),
"E_CRATE",
),
] {
let f = fixture();
engine_manifest(&f, &text);
let e = failure(&f, code);
assert_eq!(e.mod_id.as_deref(), Some("boost"), "{text}");
if text.contains("rand") {
assert_eq!(
&*e.message,
"boost needs rand ^0.8, which isn't in Horizon's Cargo.lock"
);
}
}
let f = fixture();
fs::write(f.mods.join("boost/engine/Cargo.lock"), "version = 4\n").unwrap();
failure(&f, "E_CRATE");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "crate").insert("dependent".into(), "horizon-client".into());
});
let e = failure(&f, "E_CRATE");
assert!(e.message.contains("native-driver, native-game"), "{e:?}");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "crate").insert("path".into(), "missing".into());
});
failure(&f, "E_IO");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "crate").insert("path".into(), "data/inner".into());
});
failure(&f, "E_CRATE");
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "crate").insert("path".into(), "../boost/engine".into());
});
failure(&f, "E_INVALID_PATH");
// The dependent already depends on a package of that name.
let f = fixture();
f.append("game/Cargo.toml", "boost_engine = \"1\"\n");
failure(&f, "E_CRATE");
}
#[test]
fn two_mods_cannot_ship_the_same_package() {
let f = fixture();
copy(&f.mods.join("boost"), &f.mods.join("second"));
manifest(&f, "second", |v| {
v["mod"].as_table_mut().unwrap()["id"] = "second".into();
v.remove("post_build");
});
let e = failure(&f, "E_CRATE");
assert!(e.message.contains("mod 'boost' crate 'engine'"), "{e:?}");
assert_eq!(e.mod_id.as_deref(), Some("second"));
// Two mods ship different packages: both become members, in mod id order.
let f = fixture();
copy(&f.mods.join("boost"), &f.mods.join("second"));
manifest(&f, "second", |v| {
v["mod"].as_table_mut().unwrap()["id"] = "second".into();
v.remove("post_build");
v.remove("injection");
});
fs::remove_file(f.mods.join("second/tick.rs")).unwrap();
fs::remove_dir_all(f.mods.join("second/engine/src/bin")).unwrap();
fs::write(
f.mods.join("second/engine/Cargo.toml"),
ENGINE.replace("boost-engine", "second-engine") + "serde_json = \"1\"\n",
)
.unwrap();
// The second package sorts after serde_json in Cargo.lock; --locked accepts both entries.
let staged = f.run("check");
assert_eq!(staged["crates"], 2);
let lock = fs::read_to_string(f.out.join("Cargo.lock")).unwrap();
assert!(
lock.contains("dependencies = [\n \"boost-engine\",\n \"second-engine\",\n \"serde\",\n]")
);
}
#[test]
fn post_build_steps_name_a_bin_of_the_mods_own_crate() {
for (key, value) in [("crate", "other"), ("bin", "../bake"), ("bin", "missing")] {
let f = fixture();
manifest(&f, "boost", |v| {
first(v, "post_build").insert(key.into(), value.into());
});
let e = failure(&f, "E_POST_BUILD");
if value == "missing" {
assert!(e.message.contains("it has: bake"), "{e:?}");
}
}
// A [[bin]] table names a bin too.
let f = fixture();
fs::rename(
f.mods.join("boost/engine/src/bin/bake.rs"),
f.mods.join("boost/engine/src/bake_main.rs"),
)
.unwrap();
fs::write(
f.mods.join("boost/engine/Cargo.toml"),
format!(
"{ENGINE}serde_json = \"1\"\n\n[[bin]]\nname = \"bake\"\npath = \"src/bake_main.rs\"\n"
),
)
.unwrap();
assert_eq!(build_plan(&f.root, &f.mods).unwrap().post_build.len(), 1);
}
#[cfg(unix)]
#[test]
fn symlinks_and_reserved_stage_paths_are_rejected() {
use std::os::unix::fs::symlink;
let f = fixture();
symlink("/etc/hostname", f.mods.join("boost/data/link.txt")).unwrap();
failure(&f, "E_INVALID_PATH");
let f = fixture();
let data = f.mods.join("boost/data");
fs::rename(&data, f.mods.join("real-data")).unwrap();
symlink(f.mods.join("real-data"), &data).unwrap();
failure(&f, "E_INVALID_PATH");
let f = fixture();
symlink("../tick.rs", f.mods.join("boost/engine/src/linked.rs")).unwrap();
failure(&f, "E_INVALID_PATH");
let f = fixture();
let engine = f.mods.join("boost/engine");
fs::rename(&engine, f.mods.join("real-engine")).unwrap();
symlink(f.mods.join("real-engine"), &engine).unwrap();
failure(&f, "E_INVALID_PATH");
let f = fixture();
fs::create_dir_all(f.root.join("native-data")).unwrap();
fs::write(f.root.join("native-data/x"), "").unwrap();
failure(&f, "E_INVALID_PATH");
}
#[test]
fn crates_need_a_bundle_directory() {
let f = fixture();
fs::write(
f.mods.join("loose.toml"),
"[mod]\nid = \"loose\"\nversion = \"1\"\n\n[[crate]]\nid = \"c\"\npath = \"boost/engine\"\n",
)
.unwrap();
failure(&f, "E_CRATE");
}
@@ -818,3 +818,6 @@ mod effects;
#[path = "cases/standard_macros.rs"]
mod standard_macros;
#[path = "cases/native.rs"]
mod native;
+5
View File
@@ -83,3 +83,8 @@ State literal fields are inserted after original child traversal, with owner
fields/getters/initializer functions appended after all body changes. This keeps
all original function/expression indices stable. Struct updates move existing
state through their rest expression. See [effects and state](effects-and-state.md).
`horizon-mixins-core::native` (0.6.0) resolves named hooks from `native-hooks.json` before
indexing, and validates bundle crates, data and post-build steps against the workspace's members
and `Cargo.lock`. After the transform, `native::stage` adds the bundle files to the staged set and
edits the root manifest, the dependents' manifests and `Cargo.lock` in place.
+5
View File
@@ -49,3 +49,8 @@ Expression, after-hook and state changes use the same atomic stage, unchanged
mtime preservation and Cargo target lock. Constructor source, explicit state
initializers and helper sources participate in the canonical plan/stage identity.
No runtime injection or save-data migration is performed by staging.
Bundle crates, `data/` files and `native-post-build.json` (0.6.0) are part of the same complete
stage. Their file fingerprints are in the plan and the stage marker, and unchanged ones are
hard-linked with their mtimes kept. A bundle crate is its own Cargo package, so Cargo reuses its
build while only helpers or other mods change. See [crates, data and named hooks](crates-data-and-named-hooks.md).
+163
View File
@@ -0,0 +1,163 @@
# Named hooks, bundle crates, data and post-build steps (0.6.0)
Version 0.6.0 lets a native bundle grow past a few helper files:
- a **named hook** (`hook` + `hook_version`) instead of a raw function path, resolved from the
workspace's `native-hooks.json`;
- its **own crates** (`[[crate]]`), staged as workspace members and built separately from the game
crate, with registry dependencies taken from the workspace's existing `Cargo.lock`;
- **data files** (`data/`), staged to `native-data/<mod-id>/` for `include_bytes!`;
- **post-build steps** (`[[post_build]]`), recorded in `native-post-build.json` for the launcher.
`horizon-mixins` never runs a post-build step. Everything below follows the existing staging rules:
the stage is published atomically, the output is deterministic, unchanged files keep their bytes and
mtimes, symlinks and `..` fail, and every failure is a structured diagnostic. Plans use schema 6.
The compiled example is `fixtures/native` with `fixtures/native-mods/boost`:
```sh
cargo run --locked -p horizon-mixins-cli -- check \
--workspace fixtures/native --mods fixtures/native-mods \
--out target/native-game --trust-native-mods
```
## Named hooks
```toml
[[injection]]
id = "tick"
hook = "tick" # a name from <workspace>/native-hooks.json
hook_version = 1 # required with hook
kind = "HEAD"
helper = "tick.rs"
bindings = [{ name = "game", ty = "&mut Game", value = "self" }]
```
The catalog is the one Horizon keeps at its repository root
([horizon-rust/horizon#3871](https://github.com/horizon-rust/horizon/pull/3871)):
```json
{"hooks": [{"name": "tick", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_tick"}]}
```
Before planning, each `(hook, hook_version)` is replaced by its `target`, so the rest of planning is
unchanged. The plan keeps the resolved `target` and also records `hook` and `hook_version`. A hook
version is a contract (signature, timing, behaviour). Horizon keeps an old version, or adds a new one
when the contract changes incompatibly. So a bundle fails at plan time with a clear message instead
of compiling against a changed hook.
The catalog is read only when a manifest names a hook. Other fields in its entries are ignored.
| Code | Cause |
|---|---|
| `E_MANIFEST` | both `target` and `hook`; `hook` without `hook_version` (or the reverse); neither |
| `E_HOOK_CATALOG` | a hook is used but the workspace has no `native-hooks.json`, or it is invalid JSON, lists an entry without a name/target or with version 0, or lists a name/version twice |
| `E_HOOK_UNKNOWN` | the name is not in the catalog; the message lists the known names |
| `E_HOOK_VERSION` | the name is known but not at that version; the message lists the versions it has |
## Bundle crates
```toml
[[crate]]
id = "engine" # shares the id space with injections, modules, bridges, states
path = "engine" # bundle-relative folder holding Cargo.toml
dependent = "native-game" # workspace package that depends on it (default "horizon-client")
```
The folder's files are copied to `native-crates/<mod-id>/<crate-id>/`. `target/`, `.git/`, `.local/`
and `node_modules/` inside it are skipped, as in the workspace. The stage then:
1. appends `native-crates/<mod-id>/<crate-id>` to the root `[workspace] members`, copying the last
member's formatting;
2. adds `<package> = { path = "<relative path>" }` to the dependent's `[dependencies]`;
3. adds the package to `Cargo.lock`: a `[[package]]` entry in Cargo's order and format, and its name
in the dependent's `dependencies` list.
Manifests are edited with `toml_edit`, so comments and formatting elsewhere stay. Every other byte of
`Cargo.lock` stays, and no registry package is added or changed. That is why the stage still builds
with `--locked` (verified on the fixture and against a Horizon `main` export with
`cargo metadata --locked`). Crates are staged in mod id order, then manifest order.
A bundle crate's dependencies must be plain registry requirements: a version string, or a table using
only `version`, `features`, `default-features` and `optional`. This applies to `[dependencies]`,
`[dev-dependencies]` and `[build-dependencies]`. Each must match a registry package that is already
in the workspace's `Cargo.lock`; the highest matching locked version is used. Horizon's lock file is
the allow list. A crate cannot depend on the game crate (Cargo forbids the cycle) or on any path or
git crate. The game glue stays in helpers and support modules, and the heavy logic goes in the crate.
The tool cannot see features that turn on optional dependencies of a registry crate that the lock
does not already have. The `--locked` build catches those ("the lock file needs to be updated").
The crate's own data lives inside its folder and is copied with it:
`include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/table.json"))`.
Rejected, with `E_CRATE`:
- a crate in a direct `.toml` mod (crates need a bundle directory);
- a folder without `Cargo.toml`, or a `path` inside the bundle's `data/`;
- a `Cargo.lock` in the crate;
- `[workspace]`, `[target.*]`, `[patch]`, `[replace]`, `[profile]` or any other table outside
`package`, `lib`, `bin`, `test`, `example`, `bench`, `features`, `lints`, `badges` and the three
dependency tables;
- `package.workspace`, a non-literal `package.version`, and `package.build` or target `path`s that
leave the crate;
- a package name (with `-` and `_` treated alike) that is already a workspace member, any package in
`Cargo.lock`, another mod's crate, or a dependency name of the dependent;
- a `dependent` that is not a workspace member package. The message lists the members.
Dependency problems are `E_CRATE_DEPENDENCY`, for example
`boost needs rand ^0.8, which isn't in Horizon's Cargo.lock`. Paths with `..`, and symlinked crate
folders or files, are `E_INVALID_PATH`.
Cargo rebuilds a crate only when its sources or dependencies change. A bundle crate depends only on
registry crates, so editing the mod's helpers or toggling another mod leaves it built. Its staged
files keep their mtimes, which the integration test checks. The game crate still recompiles, but it
no longer contains the crate's code.
## Data
Every file under `<bundle>/data/` (of any type, with nothing skipped) is staged to
`native-data/<mod-id>/` at the workspace root. Helpers and support modules in a game crate read it
relative to that crate. From `crates/horizon-client`:
```rust
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../native-data/boost/greeting.txt"))
```
Horizon wraps this as `native_data!("<mod-id>", "path")`. A support module cannot call
`include_bytes!` directly, because modules must be self-contained. A `data` entry that is a file or a
symlink, or a symlink inside it, is `E_INVALID_PATH`. Data in direct `.toml` mods is not looked up.
## Post-build steps
```toml
[[post_build]]
crate = "engine" # a [[crate]] id of the same mod
bin = "bake" # a bin of that crate
args = ["--quick"] # optional, plain strings
```
A bin is a `[[bin]] name`, `src/bin/<name>.rs`, `src/bin/<name>/main.rs`, or the package name
when `src/main.rs` exists. Every stage contains `native-post-build.json`, which is `[]` when no mod
has a step:
```json
[
{
"mod": "boost",
"package": "boost-engine",
"bin": "bake",
"args": ["--quick"]
}
]
```
Entries are in mod id order, then manifest order. The launcher builds and runs them after the game
compiles. `E_POST_BUILD` covers a `crate` that is not one of the mod's crates, an invalid bin name,
and a bin the crate does not have (the message lists the crate's bins).
## Reserved paths
The stage owns `native-crates/`, `native-data/` and `native-post-build.json`. A workspace that
already has any of them fails with `E_INVALID_PATH`. The plan records each crate's and data
folder's file fingerprints, and staging checks them again before it publishes.
+10
View File
@@ -192,3 +192,13 @@ behavior. Planning and staging cancellable HEAD hooks on the actual
commit `3c9bb38e88f5b95ce42a139bc600d3e9c61a2175`; removing the generated statements
reproduces each complete file after canonical formatting. This source audit
does not claim a full-game compilation or gameplay run.
Version 0.6.0 implements [named hooks, bundle crates, data and post-build steps](crates-data-and-named-hooks.md).
The `fixtures/native` workspace has a two-hook catalog. The `boost` bundle has a named HEAD hook, a
crate (`boost-engine`, depending on the locked `serde_json`, with its own `data/` and a `bake` bin),
a binary data file and a post-build step. The staged workspace passes `cargo test --workspace --locked
--offline`, so the edited `Cargo.lock` is accepted unchanged. Nine integration tests cover the
compiled path, unchanged bytes and mtimes (including the crate's files across a helper edit), two
mods' crates, and every new diagnostic. Applying a crate-carrying bundle to a Horizon `main` export
(with the #3871 catalog) also produced a stage that `cargo metadata --locked` accepts. The suite has
89 integration tests. Plans and package version are schema 6 / 0.6.0.
+5
View File
@@ -127,3 +127,8 @@ Body-rewriting hooks can coexist with validated `format!`, `matches!` and `vec!`
inputs. Macro invocations stay unchanged; hidden control flow, ambiguous macro
identity and selectors matching macro internals are rejected. See
[standard macro contracts](standard-macros.md).
Version 0.6.0 adds `hook`/`hook_version` (exactly one of `target` or `hook` per injection),
`[[crate]]` and `[[post_build]]`, and stages a bundle's `data/` folder. A bundle may now consist of
crates only. CLI success reports include `crates`, `data` and `post_build` counts, and plans use
schema 6. See [named hooks, bundle crates, data and post-build steps](crates-data-and-named-hooks.md).
+2 -1
View File
@@ -16,7 +16,8 @@ Full game and launcher integration remain future work.
| 6b — Scoped features and bridges | Implemented: lexical local capture, same-block call-bounded slices, GET/GET_MUT/SET/INVOKE APIs, MODIFY_ARGS and MODIFY_VARIABLE STORE/scalar LOAD |
| 6c — Native consumer | Implemented: lifetime impl method IDs, HEAD over preserved closure/`?` bodies, support modules and a real TOML Veinminer bundle; complete game binary compilation and AST audit |
| 6d — Effects and state | Implemented: expression results, AFTER_CALL/AFTER_ASSIGN, interior cancellation, explicit per-instance fields/constructors and serde old-data defaults |
| 6e — Remaining parity | Future: REDIRECT, broader sites/receivers/patterns, arbitrary members/traits, optional groups and semantic matching; see [feature map](spongepowered-feature-gaps.md) |
| 6e — Bundle crates, data and named hooks | Implemented (0.6.0): `native-hooks.json` named hooks with versions, bundle crates as workspace members with `Cargo.lock`-only registry dependencies, `data/` staging, `native-post-build.json`; see [crates, data and named hooks](crates-data-and-named-hooks.md) |
| 6f — Remaining parity | Future: REDIRECT, broader sites/receivers/patterns, arbitrary members/traits, optional groups and semantic matching; see [feature map](spongepowered-feature-gaps.md) |
`cargo test --workspace --locked` checks two independent mods, same-site priority
and ID tie order, original selectors unaffected by injected calls, call results
+6
View File
@@ -37,3 +37,9 @@ are explicit and fingerprinted. State/serialization code has the same native
privileges as helpers. Serde defaults support old records, but custom game save
systems and schema migrations require explicit integration; see
[effects and state](effects-and-state.md).
Bundle crates (0.6.0) are native code with the same privileges, including their build scripts.
They may only use registry packages already pinned in the workspace's `Cargo.lock`, with no path,
git or workspace dependencies, and no `[workspace]`, `[patch]`, `[replace]` or `[target]` tables.
Their target paths must stay inside the crate. `[[post_build]]` steps are recorded, never executed,
by horizon-mixins; the launcher that runs them has to trust them as it trusts the mod.
+1 -1
View File
@@ -1,6 +1,6 @@
# SpongePowered Mixin feature map and remaining work
Status: Horizon Mixins 0.5.0. This is a capability comparison and design backlog,
Status: Horizon Mixins 0.6.0. This is a capability comparison and design backlog,
not Java annotation, bytecode or mod compatibility. The implementation uses Rust
source before Cargo compilation; Sponge Mixin integrates Java bytecode with the
runtime classloading pipeline. [Sponge overview](https://github.com/SpongePowered/Mixin/blob/master/README.MD).