mirror of
https://github.com/horizon-rust/horizon-mixins.git
synced 2026-11-07 10:05:58 -10:00
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:
1 parent
0291f4d801
commit
b74802d8ac
12 files changed
+590
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 × {
|
||||
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;
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
Reference in new issue
Block a user