A support module declared in a crate root that re-exports another workspace crate (`pub use horizon_client_lib::*;` in Horizon's client after the crate split) was refused as E_MODULE_CONFLICT for any unresolved glob, so a mod like official_hitbox_plus failed on every newer Horizon. Planning now follows a glob to the workspace module it names (crate, self, super, another workspace crate, a child), up to 8 levels, and only refuses when the name is really brought in or the glob can't be followed. The transform re-check keeps to the module's own names on the same fingerprinted source. Every module diagnostic now carries the declaring mod's id and the module file, so a launcher can leave out the mod that caused it. Co-authored-by: Horizon agent <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
8.7 KiB
Lifetime methods, boundary HEAD hooks and support modules (0.4.0)
Veinminer is the first full-game consumer of this extension. Plans use schema 5; regenerate older plans. It declares three cancellable HEAD injections and one support module. All changes to the exported Horizon Rust source are performed by the mixin planner/transformer and atomic staging CLI.
Lifetime-bearing methods
Inherent impls may declare lifetime parameters, including impl<'a> Game<'a>.
Their self-type IDs normalize non-static lifetimes to '_, so a renamed
lifetime does not change a target ID. A method in an external commands module
is identified by its declaring module:
[[injection]]
id = "command"
kind = "HEAD"
cancellable = true
target = "horizon_client::game::commands::Game<'_>::run_command"
helper = "command.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "command", ty = "&str", value = "command" },
]
Type/const generic impls, impl where clauses, explicitly generic methods and async/unsafe/const/foreign targets remain unsupported. Helpers still use concrete types and do not capture the target's named lifetime parameters. Compiler checking is required for actual lifetime and borrow compatibility.
Preserved body control flow
HEAD can prepend a call before a body containing closures, ?, reference
expressions or nested ordinary items. It does not inspect or rewrite their
sites or exits. If a cancellable hook continues, the original body executes
with its original returns, error propagation and destruction behavior. If it
breaks, normal Rust return semantics drop the target's owned arguments once.
HEAD-only hooks, including cancellable HEAD, also support opaque expression and
statement macro invocations, such as format!, matches!, vec!, assert!
and macro-generated early returns. Their tokens remain untouched; no expansion or selection inside them
is performed. Signature, type, pattern and item macros (including local
macro_rules! definitions), unresolved cfg and attribute macros still fail
closed. The compiler's own crate-level attributes written as inner attributes
(#![recursion_limit = "256"], type_length_limit, crate_name, crate_type,
no_std, no_main, no_builtins, windows_subsystem and feature) configure
the compilation and expand nothing, so a crate root that carries them is not an
attribute-macro module. Attributes of the tools the compiler knows by name
(rustfmt::*, clippy::*, diagnostic::* and rust_analyzer::skip) hint to the
tool and expand nothing, so they pass too. macro_use, no_implicit_prelude,
derive, any other path (tokio::main, serde::...) and the attribute macros of other
crates still make a module or item an attribute-macro site. Attributes on macro invocations are checked as usual. Cancelling HEAD
returns before any original macro or local initializer executes. Async functions
remain rejected. RETURN and site injections continue to use their stricter control-flow validation, including
when combined with HEAD. HEAD-only planning does not assign expression/local
coordinates inside the untouched body; the transform skips expression traversal
when there are no planned sites.
The compilation fixture tests two mods in priority order, closure errors,
owned argument destruction, cancellation, stable lifetime IDs and deterministic
source/mtimes. It also tests rejection of RETURN over ?, type generics,
unsupported macro contexts, ambiguous module destinations and unsupported
payloads. Macro HEAD tests compile two ordered callbacks, expression/statement
macros, macro-generated early returns and tail values, and local/argument drops.
Both list/repeat vec! forms and format! arguments execute exactly once;
cancellable hooks exercise both Continue and Break before those expressions.
Removing only the generated HEAD calls reproduces the original body AST;
repeated staging preserves bytes and mtimes.
This is preservation, not macro expansion: selectors cannot target calls or
locals inside macro tokens. Body-rewriting hooks now accept format!, matches!
and vec! after validating their identity and inputs. Hidden exits or selector
matches are still rejected; see standard macro contracts.
Support modules
A bundle can contribute a self-contained Rust module to the top level of an existing indexed source file:
[[module]]
id = "runtime"
file = "crates/horizon-client/src/game.rs"
name = "veinminer"
source = "runtime.rs"
source is relative to the bundle and contains the module's items, without an
outer module wrapper. The transformer emits pub(crate) mod veinminer { ... }
after all edits to original functions and bridges. Its types/functions are
available to helpers through ordinary Rust paths and same-crate privacy.
An optional visibility accepts exactly "pub(crate)" (the default) or "pub".
For a support API used by a hook in another crate, declare it in a library file
without top-level glob imports. This example uses the two-crate test workspace
in fixtures/game, where fixture-driver already depends on fixture-game:
[[module]]
id = "runtime"
file = "engine/src/lib.rs"
name = "support"
source = "runtime.rs"
visibility = "pub"
With runtime.rs containing pub fn touch(value: &mut i32) { *value += 2; },
this emits pub mod support { ... }. A hook in fixture-driver can call
fixture_game::support::touch(value). The
public_support_module_is_callable_from_a_hook_in_a_dependent_crate test
compiles and exercises this arrangement. Public module visibility does not make
its items or parent modules public. Rust still checks item/type visibility and
dependency direction; no reverse dependency or cycle is introduced automatically.
A top-level glob import in a destination file is followed when it names a module
the index holds as a file (crate::…, self::…, super::…, a workspace crate, or a
child module): a local module shadows what a glob brings, so the destination is
refused with E_MODULE_CONFLICT only when that module's top-level items or imports
(its own globs followed the same way, eight deep) or a support module already
planned there have the name, whatever cfg the import has. Any other glob
(use std::fmt::*;, an inline module) may bring in anything and is still refused.
Horizon's client root crates/horizon-client/src/lib.rs
(pub use horizon_client_lib::*;) can therefore receive a support module.
It adds no Cargo dependencies or fields to existing structs. IDs are unique across injections, bridges and modules in a bundle.
The destination must have exactly one production source identity. The index treats
cfg(not(test)) as active and cfg(test) as inactive. Other cfg predicates
remain unresolved. Existing type/module/import names, competing module names
and glob imports that may bring the name are rejected; every diagnostic about a
module names its mod (mod_id). Payloads are parsed during planning.
External mod, #[path], direct include macros, file attributes and shebangs
are unsupported; assemble multi-file mod implementations into a self-contained
payload before planning. Ordinary Rust macros and cfg inside new implementation code
are compiled by Cargo; injected helpers are never matched against that code.
Plans contain module identity/version, destination fingerprint and source, plus
visibility = "pub" for public modules. Omitted plan visibility means
pub(crate), preserving existing schema-5 module records and generated output.
Original fingerprints are checked before function rewrites; module insertion
happens afterward so original node paths cannot shift. Changed support code
changes the plan and staged file; repeated identical runs preserve bytes and
mtimes. E_MODULE and E_MODULE_CONFLICT describe payload/destination errors.
Saved JSON remains a review artifact, not an executable CLI input.
Veinminer integration
The separate horizon-veinminer repository commits mixins/veinminer/ with its
manifest, three typed hooks and runtime payload. Its development tool exports
committed Horizon files, then invokes horizon-mixins plan and apply; it has
no custom Rust-source replacement logic. The runtime keeps UUID-keyed toggles
and cooldowns in per-world state on the hosting thread, avoiding any change to
Game fields or constructors. Extras call the unchanged native mining method.
Native validation type-checks the real pinned game binary and runs the 13 mining engine tests embedded in it. A separate syn audit removes only the three planned HEAD statements and module, then compares every affected file's AST with the pristine export. Gameplay sessions with prepared assets remain a manual check; no game assets or upstream source edits are needed for these checks.