Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design: mods extending the mod API

This is a proposed contract for #2339. The explicit Wire derive and recursive layout identity are implemented; the mod extension facilities below remain proposed. It covers how a library mod (a chemicals system, an energy network, a machine framework) defines things that other mods use, listen to or extend: mod hooks, mod Part kinds, mod services and mod registries, plus the #[derive(Wire)] they rest on (section 0, the first to build) and how addons patch another mod's code. Each section gives the contract, its behaviour on wasm, its NeoForge counterpart and what it costs when nobody uses it. A staged PR plan and the open questions come at the end.

What exists, and the one fact the design rests on

These already work, read from the code at 54bee0f608:

  • Plain calls. A dependent names the library under [dependencies] and gets its crate as a Cargo dependency. On native, every code mod is linked into one game binary (launcher/src/native_mods/api.rs). On wasm, a library and everything that depends on it form one dependency group: one shared memory, one shared table and one copy of each member (horizon-mod-wasm/src/link.rs). Two mods that share a library share its group (groups() in horizon-mod-wasm/src/lib.rs). Calls, statics, generics, trait objects and Drop inside a group are ordinary Rust (tests/real_library.rs).
  • Mod capability keys. CapabilityKey::<dyn ResourceHandler<Chemical>>::new("chemlib:chemical"), providers through Registrar, lookups through level.capability. A provider whose handler type belongs to a mod is stored erased (OwnProvider, parts/capabilities.rs). On wasm, an erased Arc<dyn Any + Send + Sync> crosses as a group-scoped resource id. The group that made it gets it back, and any other group gets wire::Unsupported::CROSS_GROUP (wire_resources.rs, generator wire/mod.rs ERASED).
  • Hook machinery. horizon_mod_runtime::subscribers::Subscribers builds per-hook lists once at install. Order is priority descending, then dependency order. It also tracks receive_cancelled and keeps a bitset for the "anyone listening?" test. set_mod_enabled and mark_mod handle the player's switch and crash attribution.
  • Typed slot keys. BlockKey(u32) and the other slot keys are backed by horizon_mod_runtime::registry_keys::Names: requested at load, then resolved to slots.
  • Attachments. AttachmentType<T> on entity, player, block entity, chunk and level holders, with an optional codec, sync and copy-on-death (horizon-mod-runtime/src/attachments.rs). The Part instance state design (#2328) makes this the only store for per-instance data.

The fact: a mod can name a library's types only if it depends on the library. On native it then sits in the same binary as the library; on wasm it sits in the same group. So within one target, a library-defined value never has to cross the Wire to reach the mods that can use it. The game's job is the part a library cannot do alone: names, ownership, refusals, ordering, the player's on/off switch, crash attribution, lifetimes tied to the server and its levels, and the Mods page. The payload stays in the caller's address space as an erased handle.

What breaks that fact is mixed targets: a native library with wasm addons, or the reverse. Those mods are in different address spaces, so anything shared has to go through the game. That case has its own section, and it is the only one that needs mod types to have Wire forms.

Can a mod give its own types a Wire form today?

No, not in a supported way. mod_api::wire::Wire is a public trait, but it is #[doc(hidden)] and documented as "not a mod's API" (exports/wire.rs). Its impls for mod_api types are generated by horizon-mod-api-gen, and there is no derive a mod can use. More to the point, nothing on the boundary would carry such a value. Every crossing is a generated call whose signature comes from a mod_api trait (the IMPORTS/EXPORTS lists in horizon-mod-api-gen/src/wire/mod.rs), and the layout hash that refuses mismatched builds (WIRE_LAYOUT) covers only mod_api. A hand-written impl Wire for Chemical would compile and never be used.

Within one target this doesn't matter, as shown above. Across targets it does. The owner has decided that one #[derive(Wire)] serves mod_api and mods alike; section 0 is its spec.

0. #[derive(Wire)] for every type that crosses

Owner decision (2026-11-08): one derive in horizon-mod-api-macros, used by mod_api's own types and by mods alike. horizon-mod-api-gen stops writing per-type Wire code and builds only the host call tables. There is no second mechanism. This section is the spec for that build. It comes before everything else in this note because mod hooks, registries and the mixed-target cases all depend on it.

What the derive emits: the same bytes as today

The derive implements the existing trait (crates/horizon-mod-vocab/src/wire/wire.rs:558: MIN, put, put_owned, take). It copies the generator's rules from wire/emit.rs exactly, so no encoding changes:

ShapeBytesMINtake
Named recordeach field's form, in declaration order, with no headersum of the fields' MINinside input.nested(…)
Tuple recordeach field's form, in ordersum of the fields' MINinside input.nested(…)
Unit structnothing0Ok(Self)
Enum with no payloadsu32 LE of the case's declaration index (explicit discriminants are ignored, as today)4not nested; an unknown index is WireError::Tag
Enum with payloadsu32 LE declaration index, then the payload fields in order4inside input.nested(…); an unknown index is WireError::Tag
  • put_owned moves each field with out.put_owned, as the generator does, so resource fields hand over ownership.
  • On a #[non_exhaustive] type the derive emits the same _ => unreachable! arm under #[allow(unreachable_patterns)]. Within the defining crate, non_exhaustive adds no case.

Names. Type names in this section are as of 54bee0f608. D1, D2 and the golden test use the names as of D1's base. The restructure drops the Native prefix (106 renames, among them NativeWorldEpoch→WorldEpoch, NativeTickId→TickId, NativeGoal→GoalSpec, NativeHeldItem→HeldItemSlot, NativeSpawner→SpawnSource; the restructure PR carries the full table).

Golden bytes. These were taken from the current generated and hand-written impls at 54bee0f608 with a throwaway test, which was not committed:

ValueShapeMINBytes
Vec3 { x: 1.0, y: 2.0, z: -0.5 }record2400 00 00 00 00 00 f0 3f 00 00 00 00 00 00 00 40 00 00 00 00 00 00 e0 bf
Direction::Northenum (case 2)402 00 00 00
BlockStateId(7)tuple record407 00 00 00
Tag::Byte(5)enum with payload (case 1)401 00 00 00 05
Some(1u16)std, hand-written101 01 00
vec!["ab".to_string()]std, hand-written401 00 00 00 02 00 00 00 61 62
serde_json::json!(1)JSON string401 00 00 00 31

The first derive PR commits these as a golden test, plus one case per shape and per hand-written impl that becomes a derive. It runs against the generated impls first, then against the derive, so any byte change fails it.

Nesting. A derived record counts one nested level, exactly as the generated one does. A few hand-written impls that become derives (listed below) don't nest today. That changes no bytes; it only uses one more level of the shared 1040-level budget on those types. The budget is sized for 512 nested NBT levels, and Tag is already a generated variant enum.

What the generator stops emitting, and what it keeps

Stops:

  • Value impls. emit::types writes 532 of them at 54bee0f608: 319 records, 128 enums without payloads, 74 enums with payloads, 3 tuple records and 8 matched as JSON. Of those, only 4 really are JSON: serde_json::Value, serde_json::Map, ModelPart and MeshDefinition.
  • The assertions for types it couldn't write. There are 21 of them: const _: fn() = || wire::<T>(), for the slot keys, entity handles and attachment tokens. They become ordinary trait bounds (below).
  • The machinery behind them. The manual scan that skipped hand-written impls; Kind::Json; the kind() / reachable() rules (wire/ty.rs:635 onward) that read crate-visible fields from outside the type; and the use of public_path to name types. A derive sits on the type, in its module, with normal access to its fields. So the rule "a type with private fields needs a hand-written impl or is opaque" goes away.

Keeps (this is call-table plumbing, not a type's encoding):

  • The Arc<dyn Trait> / Box<dyn Trait> resource impls for the exported traits and capability handlers (emit.rs:109, 70 traits × 2 plus Any). These come from the EXPORTS list, not from types.
  • The constructors for borrowed records (__wire_record_N, 463 of them), and the Put impls with checked write-back for records that take appends, projections or views (3). These describe how a call's borrowed arguments cross and come back. That is a call-table concern.
  • The guest and host call glue, the wasm coverage page, and index.rs. The index stays only to read trait signatures for the call tables. It stops resolving type definitions for owned value encodings only. It still reads the field definitions of the borrowed records whose constructors and Put impls it keeps.

The kept constructors and Put impls are generated at the API root, so after the restructure a field private to its module is invisible to them. The crate-visible-fields rule (ty.rs:635) therefore stays for those borrowed records and goes away only for value impls.

Trait path, generics, borrowed types

  • Path. The derive emits ::mod_api::wire::Wire. Inside mod_api this resolves through the existing extern crate self as mod_api, so one expansion serves both. There is no crate = option.
  • Generics. Each type parameter gets a T: Wire bound, as serde's derives add theirs. Today a generic type crosses only as JSON or not at all; with the derive it crosses structurally. Const generics pass through.
  • Lifetimes.
    • A &'static str field works through the existing Wire for &str, which interns on read with the existing 256-byte / 4096-string limits.
    • Cow<'static, str> also already has a form.
    • Any other borrowed field (&'a T, &'a [T]) fails to compile with "Wire is for owned values; a borrowed record crosses as a call argument". Borrowed records stay with the call tables, as above.
  • Recursion. Tag holds Vec<Tag>, and that works unchanged because MIN never recurses through a list (a list's MIN is 4). A record that holds itself directly can't exist in Rust, and one that holds itself through Box gets Box<T>::MIN = T::MIN. That is a const cycle, which the compiler reports. The generated code has the same limit today.

Layout hash and versioning

  • A layout method on the trait, computed at run time. Wire gains a hidden method fn layout(visiting: &mut Vec<&'static str>) -> u64. It is not a const: mod_api has recursion through more than one type (Text holds TextContent and Vec<Text>, and TextContent holds Vec<Text> and Option<Box<Text>>), so a const layout would fail with rustc's const-evaluation cycle. The derived method is FNV-1a over:

    • the type's unqualified name (not module_path!, so the restructure's moves change nothing and only a rename changes it);
    • its shape (record, tuple, unit, enum);
    • its field and case names in order;
    • each field's F::layout(visiting).

    On entry it checks whether std::any::type_name::<Self>() is already in visiting. If it is, it returns a fixed "recursive" marker. If not, it pushes the name, hashes, and pops. The type name serves only as identity within the walk and is never hashed, so module paths still don't matter.

  • Layout follows the encoding, not the Rust spelling. Types with the same bytes have the same layout:

    • one list kind for Vec<T>, VecDeque<T>, Box<[T]> and Arc<[T]>, hashed with T::layout;
    • one string kind for String, Box<str>, Arc<str>, Cow<str> and &'static str;
    • one map kind for BTreeMap and HashMap, with key and value;
    • one set kind;
    • Box<T> and Arc<T> transparent, the same as T.

    Every container hashes its kind, then its contents' layout(visiting): element, key and value; all fields for tuples; element and N for [T; N]. That keeps the container's shape even when its element is recursive, so a change deep inside a Vec<Option<T>> still changes every hash above it. Leaf hand-written impls (numbers, bool, char, (), Duration, tokens) state a constant through a default method. Golden tests pin bytes only, never layout values, which are free to change when a type is renamed.

  • WIRE_LAYOUT. It becomes the hash of the call tables' signatures (as now) plus the layout of every type in them, computed once at first use (LazyLock) in place of today's generated const. This replaces hashing the generator's Def debug strings. Any field change in any crossing type still changes it, and a mod built against another layout is still refused with Refusal::Layout.

  • A layout per type. cargo horizon-mod build records the layout of each publicly reachable concrete type carrying a derive or hand-written Wire implementation in layouts.json at the package root. Its shape is {"mod_id": {"crate::Type": "0123456789abcdef"}}, including the mod dependencies it compiled against. A cached host probe computes these runtime identities for both build targets. Private types with no public path and uninstantiated generic declarations are not listed. A dependent on the other target that was built against another layout is refused at load, naming the mod and the first type whose layout differs.

  • No versioning inside the encoding. There are no optional fields, defaults or version tags. A change means a rebuild, and the hash refuses a stale build at load. This is the clean-break rule applied to bytes.

Always written, never added by the build

Owner decision (2026-11-09): the derive is always written on the type by hand. No build step adds it, whether cargo horizon-mod build, the staging pass or the generator.

  • mod_api. Every public type carries #[derive(Wire)] or a hand-written impl, or else a // no wire: <why> line above it, in the style of // exhaustive:. A test in tests/test_mod_api_boundary.py, next to test_every_public_type_is_non_exhaustive_or_says_why, fails CI on a public type with neither. The derive PR makes every existing public type pass. Types that never cross, or that cross only through a call table's plumbing, get the reason line.
  • Mods. A mod derives only the types it chooses to let cross. A type without the derive stays usable within its own target: same-target hooks, registries and internals mod points don't need it.

A missing derive

Every place where a type has to cross, and that is known at compile time, is a trait bound. So a missing derive is a compile error that names the type:

  • a field of a derived type, through the out.put(&self.f) bound;
  • a parameter or return in a call table (the generator emits a bound check per signature type);
  • a point record field (#2242's records are built from mod_api types);
  • a mod hook's event and a mod registry's value: ModHook and ModRegistry require E: Wire / T: Wire. The bound costs nothing when nothing crosses, and it means a library's hooks and registries never have to refuse a cross-target addon.

Wire gets #[diagnostic::on_unimplemented(message = "{Self} has no Wire form", label = "add #[derive(mod_api::Wire)] to it")].

A refusal at load or registration is left only where the type is decided at run time:

  • a point on an engine function whose parameter has no API type, which is #2242's existing refusal naming the parameter (an engine type, not a derive question);
  • a cross-target dependency whose recorded layout differs, refused naming the type.

The trait stays unsealed

It has to be. The derive expands in the mod's crate, so the impl lives there, and a sealed trait would need a public, hidden seal, which seals nothing. Leaving it open is safe for three reasons:

  1. Wire is safe code, so an impl can't cause memory unsafety. Reader itself checks lengths, UTF-8 and the depth of what nested wraps, but tag validation and calling nested are each impl's job: a hand-written impl can skip them. That is why the trust boundary is the host's decoding, not Reader.
  2. Everything the game decodes is a mod_api type, a std type or an engine mirror, decoded by mod_api's own impls (derived or hand-written in mod_api, never by a mod). Orphan rules stop a mod from implementing Wire for a mod_api type or an engine type, so the host always decodes mod_api values with mod_api's own impls.
  3. The game never decodes a mod's type. It forwards the bytes and compares the layout hash.

A mod may still write an impl by hand, for a type with invariants. It must then implement layout itself: the method has no default for compound types, so the derive PR's switch provides every impl in one go.

With this change, the parts of mod_api::wire a mod uses (Wire, Reader, Writer, WireError, the derive) become documented API. The transport (INIT, RELEASE, resources, wire_guest) stays hidden.

One attribute: #[wire(via = T)]

Many hand-written impls exist only because the type is built through a checked constructor from a simpler form. #[wire(via = String)] encodes T::from(&self) and decodes through TryFrom<T>, mapping a failure to WireError::Invalid("<type>"). Its bytes and layout are T's. Only the conversion is bounded (T: Wire, T: From<&Self>, Self: TryFrom<T>), not the type's own parameters. A failed TryFrom inside a record stops the outer decode with Invalid. This is the same derive, not a second mechanism, and it keeps validation at the boundary that owns it (the owner's coding rules). A JSON form is not an attribute: the 4 JSON types keep hand-written impls (below), so no type changes its encoding.

Migrating the existing impls

WhereCountBecomesWhy
wire.rs: numbers, bool, char, (), strings, Option, Result, lists, sets, maps, arrays, tuples, Duration, &str23stay hand-writtenforeign std types, the base the derive builds on
generated: serde_json::Value, Map2move to wire.rs by handforeign types; JSON string form kept byte for byte
generated: ModelPart, MeshDefinition2hand-written JSON impls beside the typeskeeps their bytes; a structural derive later would be a deliberate layout change
generated: other value types528#[derive(Wire)] on the typethe rules above
wire_types.rs: Transaction, TransactionSeal2stay hand-writtengame-issued tokens: a transaction's write token (u64), and a seal's write and close tokens; the game checks them on every use (#2424)
wire_types.rs: LoadedMods, RegistrationContext2stay hand-writtenzero-byte lent tokens: the receiver rebuilds its own token. Boundary policy, not a layout
wire_types.rs: OwnProvider; wire_slots.rs: Id2stay hand-writtenresource handles (group-scoped ids, the Unsupported marker)
wire_types.rs: slot keys (9 u32, UnresolvedFluid u64)1 macrostay hand-writtenthe host checks each slot with from_slot; the guest carries it unchecked, which differs by target
wire_types.rs: field-by-field handles and values with plain constructors (WorldEpoch, TickId, EntityRef and the 9 entity handles, LevelRef, ChunkRef, GlobalPos, Corners, PackageKind, Side, PackageInfo, …)~35derive, after checking the field order matches the hand impl's put ordersame bytes. PackageKind/Side return WireError::Tag in place of Invalid for an unknown case, a different error with the same refusal
wire_types.rs: impls whose take validates or converts (Identifier, LevelKey, ConfigPath, JavaString, BlockState, BlockStateRange, Components, ItemStack, StatePredicate, …)~25#[wire(via = …)] where one simple form exists (LevelKey, ConfigPath via String); hand-written where take runs several steps (ItemStack, Identifier as two strings)validation stays where it is
attachment_query.rs: AttachmentHolder1derivealready a declaration-ordered u32 case plus payload
attachment_query.rs: AttachmentWriteToken, AttachmentBatch2stay hand-writtentokens with generation checks
wire_patch.rs: RecordPatch1deleted by #2242—

The first PR classifies every impl in a table in its description, with the golden test proving each "derive" row.

  • Tests that go: the_hand_written_wire_impls_are_tracked_sources (it guards the manual scan, which goes away) and the generator's emit fixtures.
  • Tests that change: layout_includes_private_borrowed_shapes hashes the call-table shapes it still owns.
  • Tests that stay: the wasm coverage page (rendered from the generator when the book builds) and coverage_page_is_well_formed describe calls, which still cross, so they stay.
  • Crossing layouts: crossing.rs is deleted by #2242. The WIRE_LAYOUT value changes once, in the derive PR, which is expected and refuses old wasm builds.

PR plan for the derive

PRWhatSize (estimate)Depends on
D1One PR, in order: (1) the golden byte test, passing against today's generated and hand-written impls; (2) the derive in horizon-mod-api-macros (shapes, MIN, put_owned, non_exhaustive, generic bounds, via, layout), the layout method on the trait with std layouts composed by encoding, on_unimplemented; (3) mod_api switched: #[derive(Wire)] on the 528 types, the hand-impl migration above, the generator stripped to call tables, WIRE_LAYOUT from layout, the golden test passing unchanged. layout has no default for compound types, so the derive can't land without every impl providing it: one PR, not two. Ordered before the restructure, as api-scaffold confirmed: the mover carries #[derive(mod_api::Wire)] with each item, and ::mod_api::wire::Wire still resolves after wire.rs becomes wire/mod.rs.~2,200 changed, mostly one-line attributes; generator −1,000#2242 merged
D2Mods: documented wire API, per-mod layout in cargo horizon-mod build metadata, load refusal naming the type, a book page.~400D1

The restructure can be stacked on D1, or D1's attribute edits can be done inside the restructure's moves; api-scaffold and fork-lead decide which. Stages 2–6 of the plan below assume D1 has landed.

1. Mod hooks

Contract

A mod hook is a hook in the same sense as a catalog hook or a point under #2242. It has a listener of the form fn(&mut <Side>Host, &mut Event), a contract, priority and dispatch order. Its name is owned by a mod and not taken from the catalog or from an engine function.

#![allow(unused)]
fn main() {
// chemlib's crate
#[derive(mod_api::Wire)]
pub struct TankFilled { pub tank: BlockEntityRef, pub chemical: Chemical, pub amount: u32, pub cancelled: bool }
pub static TANK_FILLED: ModHook<Server, Cancel<TankFilled>> = ModHook::new("chemlib:tank_filled");

fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
    r.declare_hook(&TANK_FILLED);
}
// where a tank fills:
if TANK_FILLED.listened() {
    let mut event = TankFilled { tank, chemical, amount, cancelled: false };
    if TANK_FILLED.post(host, &mut event).cancelled() { return; }
}

// an addon's crate
r.listen(&chemlib::TANK_FILLED, Listen::priority(10).receive_cancelled(), |host, event| { … });
}

The names in the example are only illustrations and still need API review.

PointRule
Name<declaring mod id>:<path>. declare_hook refuses a namespace other than the declaring mod's id, and refuses a second declaration of the same name, naming both mods.
SideServer, Client or Both, as the catalog's side. It fixes the host the listener gets (&mut dyn ServerHost, &mut dyn ClientHost, or HostSide), so a listener on the wrong side doesn't compile.
ContractThe catalog's four: listen, cancel (a cancelled field), collect (a Vec field listeners push to) and replace (an Option field, where the last writer in dispatch order wins, as the catalog's out). Using one set keeps a library's hooks reading like the game's.
RegistrationTANK_FILLED.listen(r, …) during register. Registrar stays object-safe: the typed generic methods (listen, declare_hook, and in later sections attach, provide, register) live on the key types and call one erased, object-safe core on Registrar (name, owner, erased handle, options). The r.listen(&KEY, …) spelling in the examples is shorthand. A mod may listen to one hook more than once, and its listeners keep their registration order, as points tie on inject id. Subscribers lists a mod once per hook today, so mod hooks need a list that keeps one entry per listener; it extends Subscribers and is not a second table. Listening to a name that no loaded mod declares is refused at load, naming both. A listener whose event type differs from the declaration's is refused, naming both types. This is the same check the capability key plan adds for keys.
OrderPriority descending, then dependency order, then registration order within the mod. This is Subscribers' rule.
CancellationWhen a listener cancels, the listeners that follow are skipped unless they registered receive_cancelled. Those still run, see cancelled, and may clear it. post returns the outcome, and the poster decides what cancelling means.
Host and transactionsListeners get the host the poster passed, so their writes run inside the poster's transaction (every hook gets a Host).
Who postsAny mod that can name the hook. In practice that is the library and its addons. NeoForge doesn't restrict posting either.
LifetimeDeclarations and listeners are fixed when registration seals, and the order is resolved once. A mod the player switches off is skipped, through mod_index_on, as for catalog hooks.
ErrorsA native listener's panic is handled exactly as a catalog hook listener's: the mark_mod guard only attributes it (mod and hook name) while it unwinds, and nothing catches it. This design adds no containment; whether native listener panics should be contained is a separate contract for all hooks. A wasm trap stops the group, which is also the library's group. Posting depth is bounded across all mod hooks together, not per hook, so a chain A → B → A is caught too: a post 32 levels deep is refused with a log line naming the chain's last hook, not a stack overflow.

Points. Under #2242 a point is a hook named by an engine function path, with a generated record. A mod hook is a hook named by a mod identifier, with a record the mod writes. Both use the same listener shape, contracts, order, switch and refusal style. The one difference is registration. Points are declared in [[inject]], because the game fills compiled slots at load without source. Mod hooks are registered in code, because the library's type is a Cargo dependency anyway. Root decides whether [[inject]] at = "chemlib:tank_filled" should also work (question 2).

On wasm

Nothing crosses at post time. r.listen goes through the Registrar import, so the game records the owner, the order and the declaration. The listener closure stays in the group as an erased resource (Arc<dyn Any + Send + Sync>, the existing ERASED form). When registration seals, the game makes one load-phase call per group that declared hooks, handing it the resolved order. All listeners are in that group by construction. post then walks that list in the group's own memory. Switching a mod on or off makes one call into each group concerned to update its bitset. TankFilled never needs a Wire form. The only refusal is the mixed-target case below.

NeoForge

A NeoForge library defines class TankFilledEvent extends Event implements ICancellableEvent and posts it with IEventBus#post, on NeoForge.EVENT_BUS or its own bus. Listeners set priority and receiveCanceled on @SubscribeEvent or pass them to IEventBus#addListener(priority, receiveCanceled, eventType, consumer). Sources: public docs and javadoc only, namely the events guide (labelled 26.1) and the event bus javadoc (IEventBus, bus 8.0.5). 26.3 equivalence is unverified. Differences: NeoForge has five EventPriority steps where Horizon uses i32, as catalog hooks already do. NeoForge dispatches by event class where Horizon uses a declared name, because a name can be refused, shown and checked for type agreement. A Java listener for an event nobody posts is silently never called, while Horizon refuses it.

Cost when unused

listened() is one load of a per-hook word, resolved into the ModHook static once after sealing. A library that guards construction with it builds nothing. post with no listeners returns at once. Neither target crosses the boundary or allocates, and no mod is scanned. A library with no hooks costs nothing.

2. Mod Part kinds

Contract

A Part kind is a trait the library defines, plus a name, plus what its parts attach to. Addons implement the trait and attach it. The library asks the attached parts at its own moments.

#![allow(unused)]
fn main() {
pub trait Reactor: Send + Sync + 'static { fn react(&self, host: &mut dyn ServerHost, input: &Chemical) -> Option<Chemical>; }
pub static REACTORS: PartKind<dyn Reactor> = PartKind::new("chemlib:reactor", Scope::BlockEntityType);
// chemlib: r.declare_part_kind(&REACTORS);
// addon:   r.attach(&chemlib::REACTORS, Target::id("addon:fusion_chamber"), FusionReactor);
// chemlib, at its moment:
for reactor in REACTORS.parts(host, block_entity_type) { … }
}
PointRule
NameNamespaced by the declaring mod, declared once, with the same refusals as hooks.
ScopeWhat a part attaches to. It can be one of the holder kinds Parts use today (block, block entity type, item, entity type, fluid, menu type) or a mod registry (section 4), such as Scope::Registry(&CHEMICALS), for behaviour attached to a chemical. Attaching to an id of the wrong kind, or to an id that doesn't exist, is refused, naming both. Note that capability() today returns silently for an unknown block entity type.
TargetsAn id or a tag. Tags are resolved by each game at world open and on /reload, as built-in parts are, into a table keyed by slot.
MultiplicityOne part of each concrete type per kind and thing, the existing insert_part rule and message. Different mods' parts on one thing are all kept.
OrderDependency order, then registration order within a mod, as Attached keeps it.
ContractThe library's own. parts() yields the parts in order, and the library decides whether all of them run, the first answer wins, or anything else. Part kind methods are part of the library's API: a pipe library's PipeAttachment trait is the library's to define and evolve.
StateParts are shared behaviour. Data for each instance lives in attachments, as #2328 proposes.
LifetimeAttached during register and frozen when it seals. The resolved table is rebuilt at world open and on /reload, and an iterator never outlives the call it was made in.

A Part kind and a capability key share storage but answer different questions. A capability is "who answers this, from this side", with the first answer winning and possibly a default from the game. A Part kind is "every behaviour attached to this thing". Both should be built on one erased parts table (Attached<dyn Any>), not two. Whether CapabilityKey becomes a Part kind with a context is question 3.

On wasm

This works like OwnProvider today. The part is an erased resource in the attaching mod's group, which is also the library's group. The game keeps (kind, target, owner, order, handle). At world open and on /reload, it hands each group that declared kinds the resolved table in one load-phase call. parts() reads that table in the group without crossing. Nothing of the library's type crosses.

NeoForge

There is no first-class counterpart, and #41's Parts are already a fork addition. On NeoForge, a library that wants addons to attach behaviour to its blocks (say, filters and upgrades that fit into a pipe) defines its own interface and its own lookup table for it. Capabilities (BlockCapability.createSided plus RegisterCapabilitiesEvent, in the public capabilities guide, labelled 26.1) are the platform's nearest mechanism, and they map to CapabilityKey. Horizon gives every library the same table, the same refusals and tag resolution in place of hand-rolled maps.

Cost when unused

None if no kind is declared: no tables and no world-open call. A declared kind with no parts makes parts() one slot index into an empty slice. Resolution scales with the number of attachments, not with ticks.

3. Mod services

Plain calls already cover a stateless library API. A service is for the two things plain calls get wrong.

Scoped state. A library that keeps a manager in a static shares it between the client thread and the server thread in single player, which runs on a server thread (#1514). The state also outlives the world when the player leaves and reopens it. Scoped state ties the object to the host's own lifetime:

#![allow(unused)]
fn main() {
pub static NETWORKS: Scoped<Server, Networks> = Scoped::new("chemlib:networks", Networks::default);
NETWORKS.get_mut(host)  // reached only through a ServerHost
}
ScopeCreatedDroppedReached through
Serveron first use after server_about_to_startafter server_stoppedServerHost
Levelon first use in a loaded levelafter that level's level_unloadServerHost plus LevelRef
ClientSessionon first use after the client logs inafter ClientPlayerNetworkEvent.LoggingOutClientHost
  • No new lifecycle callbacks. A library that wants to act at a scope's start or end listens to the existing hooks named in the table (one hook per moment).
  • Not saved. Saved data goes through attachments. A level-scoped state saves through AttachmentType on AttachmentHolder::Level, which is NeoForge's SavedData on a level. Server-wide saved data has no holder today; question 4 asks whether to add AttachmentHolder::Server. A service keeps runtime state (graphs, caches, indexes) and rebuilds it from attachments when its scope starts.
  • Threads and types. T: Send + 'static. Only the scope's thread reaches it, which the host type enforces, so T needn't be Sync. get_mut takes &mut of the host, so the borrow is exclusive: a listener can't hold NETWORKS while posting a hook whose listeners reach for it too. Code that needs both takes the state out for the call or ends the borrow first.
  • Names. Namespaced by the declaring mod, declared once, with the same refusals as hooks.

Service interfaces. An API mod (types and a key, no implementation) declares pub static GRID: ServiceKey<dyn EnergyGrid>. One implementing mod depends on it and calls r.provide(&GRID, scope, factory). Consumers depend only on the API mod and call GRID.get(host) -> Option<&dyn EnergyGrid>.

  • A second provider is refused at load, naming both mods.
  • With no provider installed, get returns None. A consumer that requires the service says so with ServiceKey::required, and it is then refused at load naming the key, not left to fail at run time.
  • The provided object is scoped like Scoped.

Whether a provider may replace another one (an optimisation mod taking over a library's default) is question 5.

On wasm

The object is an erased resource in the group. Scope identity comes from the host: the server session's WorldEpoch, a LevelRef, or the client session. The group keeps a map from scope identity to handle. At each scope end, the game calls each group that declared a service of that scope once, to drop the entries for that scope. A lookup costs one host read for the scope identity, which the group caches per call. Nothing of the library's type crosses.

NeoForge

NeoForge has no service type. Libraries combine Java ServiceLoader (an API jar with one implementation found at run time), InterModComms messages at load (InterModEnqueueEvent and InterModProcessEvent), SavedData (SavedDataStorage#computeIfAbsent on ServerLevel#getDataStorage, or MinecraftServer#getDataStorage for data not specific to a level) and data attachments on levels. Sources: public docs only, namely the events guide, saved data guide and attachments guide, all labelled 26.1. Scoped state plus attachments covers SavedData. ServiceKey covers the ServiceLoader pattern, and adds a load-time refusal for two providers or a missing required one where Java just picks one. IMC isn't needed, because dependents call the library directly in dependency order.

Cost when unused

None. Nothing is created until first use, and the scope-end calls go only to groups that declared services. On native a lookup is a slot index into the host's table.

4. Mod registries (code-defined)

Contract

#![allow(unused)]
fn main() {
pub static CHEMICALS: ModRegistry<Chemical> = ModRegistry::new("chemlib:chemical");
// chemlib: r.declare_registry(&CHEMICALS, RegistryOptions { sync: RegistrySync::Names, default: Some("chemlib:empty") });
// addon:   let PLASMA: RegistryEntry<Chemical> = r.register(&CHEMICALS, "addon:plasma", Chemical::gas(0xff00ff));
CHEMICALS.get(PLASMA) / CHEMICALS.by_name("addon:plasma") / CHEMICALS.iter()
}
PointRule
Registry nameNamespaced by the declaring mod, declared once (refused otherwise, naming both). It shares one key space with the game's registries and with #2332's data-pack registries, and a collision with either is refused.
EntriesNamed in the registering mod's own namespace, which is refused otherwise, as #2332 proposes. A duplicate entry name is refused, naming both mods. register returns a typed RegistryEntry<T> slot key, the same kind of key as BlockKey.
OrderDependency order already makes the library's declare_registry run before any dependent's register, so one registration phase is enough. NeoForge registers a new registry in NewRegistryEvent and its entries afterwards through DeferredRegister or RegisterEvent; Horizon's declared dependency order makes that second phase unnecessary.
FreezingThe registry is frozen when registration seals. After sealing, the library builds its derived tables in a listener of the first lifecycle hook after registration. Which hook that is, and whether it needs a dedicated "registries frozen" hook with no NeoForge name, is question 6.
SlotsFixed when registration seals and local to the session. Saves store names, never slots, as #2332 requires. A save naming an entry that is no longer registered follows #2336's quarantine rule.
ClientsWith RegistrySync::Names, the server sends name→slot during configuration and the client remaps, as NeoForge's sync(true) does. Values aren't sent: both sides ran the same code. A client missing the declaring mod is refused at join with the registry named. RegistrySync::None registries are server-only.
Tagsdata/<ns>/tags/<registry ns>/<registry path>/…, through the existing tag loader. Part kinds (section 2) can target these tags.
Data packsA code registry has no entries from data packs. A registry whose entries come from data is #2332's. #2332 entries may refer to code-registry entries by name, resolved on the game side.

On wasm

The game keeps names, slots, owners and tags. Values stay in the group as erased resources: each value is registered by a group member, and the library is in that group. When registration seals, the group receives the slot order in one call. get is then an index into the group's own table. Names and slots are strings and u32s, so they already cross. Nothing of the library's type crosses.

NeoForge

new RegistryBuilder<>(key) with .sync(true) (integer id syncing) and .defaultKey(…), built and registered in NewRegistryEvent. Entries come from DeferredRegister.create(registry, modid) or RegisterEvent. Sources: public docs only, namely the registries guide, labelled 26.1. 26.3 is unverified. Differences: there is one phase in place of two, for the reason above. Entry namespaces are enforced. A missing client mod is refused by registry name.

Cost when unused

None without a declaration: nothing is added to configuration or the tag loader. Reads are an index. Seal and sync work scales with the number of entries, once per session.

Mixed native and wasm mods

Native and wasm mods can't call each other's functions: they live in different address spaces. Today a wasm mod whose dependency is a native mod is refused at load. groups() resolves over the wasm packages only, so it reports MissingDependency ("needs X, which isn't installed"), which is misleading. The reverse direction wasn't traced: whether the launcher's native build would compile a wasm library's source natively is not checked. This design proposes:

Rule. A library is loaded on exactly one target. An addon on the other target may still depend on it, but only through extension points that go through the game. It never links the library's code.

Extension pointAcross targets
Plain calls, statics, genericsRefused at load with "chemlib is a native mod; wasm mod X calls its code". On wasm this is an unresolved library symbol, which link.rs already refuses, with a better message. On native it is a link error at build time.
Mod hooksWork: the event type always has a Wire form, since ModHook requires E: Wire (section 0). The game forwards the encoded event between the poster's target and the listeners on the other one, and the listeners' changes come back. Order and cancellation follow one merged list.
Mod registriesWork for names and slots, which the game owns. ModRegistry requires T: Wire, so a cross-target mod can read a value: it is encoded once when registration seals and decoded into the reader's own copy.
Capabilities, Part kinds, service interfacesRefused at load in the first stages. A handler is a trait object, and calling it across targets needs the trait's methods to be generated as boundary calls, as the generator does for mod_api's EXPORTS. That would come later, as an opt-in #[mod_api::extension] on the library's trait, which requires every argument to have a Wire form. Until then, the refusal names the trait.
Scoped stateNever shared across targets. Each target holding its own copy would give two diverging states, so it is refused, naming the key.

Wire forms for mod types come from the derive in section 0. Each mod with crossing types has its own layout hash, so a stale cross-target build is refused at load naming the type. The game never decodes a mod's type; it forwards bytes and compares hashes.

What a cross-target addon compiles against. It needs the library's types and keys without its code. That means the library publishes them in a crate that holds no state (types, keys, traits, Wire derives). Whether that becomes a declared part of the package model, or mixed targets are refused outright, is question 1. The existing rule one dependency model, two build targets means whichever is chosen has to hold for both targets.

Mod points: addons patching another mod's code

Decided by the owner (2026-11-09, on #2339): mod points mirror engine points. This section is the contract, built from #2242's point machinery as it stands on that PR's head (points-arch-unify). It is also the mechanism for "addons patching another mod's code": there is no separate patching system.

Contract

  • Which functions. At build time, every function of a mod crate that horizon_mod_point_shape::eligible admits gets a point, public and private. This is the engine's own predicate (crates/horizon-mod-point-shape/src/lib.rs), used by the staging pass and by the generator alike, so the two always agree. It leaves out:

    • const, async, unsafe and extern functions;
    • methods that consume self;
    • impl Trait in the signature;
    • -> !;
    • anything under cfg/cfg_attr, or #[test];
    • #[inline(always)];
    • parameters named cancel or result.

    As with the engine's game module, the whole crate is covered, not an opt-in list. "Function" means what the engine's staging walk means (file_functions in horizon-mod-api-gen/src/points.rs):

    • free functions;
    • methods of inherent impl blocks on a plain path type;
    • functions in inline modules.

    Trait impl methods are not eligible, nor are impls on a qualified type (<T as Trait>::Assoc), or modules and impls under cfg. A trait method's behaviour is reached through the library's own call sites, which are points, and through the trait itself, which is the library's extension point. This matches the engine, so the pass and the catalog keep one rule.

  • Opting out. #[mod_api::no_point] on a function keeps the build tool from giving it a point. An addon that declares a listener there is refused at load: "chemlib::fill_tank has no point (opted out)". eligible treats the attribute as one more exclusion, so the pass and the generator still agree.

  • Declaration. The same declarations as engine points: [[inject]], or code registration, naming the function's path within its crate (chemlib::tanks::fill_tank). There is the same kind (head, return), the same priority, and the same order: priority descending, then dependency order, then inject id.

  • Levels.

    • access = "api". The listener gets the side's host and a record holding a copy of every parameter. &mut parameters are copied in and out. The record has cancel and result where the contract has them. Every parameter type needs a Wire form. A mod's own types have one only if its author wrote #[derive(Wire)] on them: nothing adds it at build time. A same-target api listener on a parameter without it is refused at registration, naming the parameter and type, exactly as #2242 does for engine types. A cross-target api listener (wasm addon on a native library, or the reverse) is refused at load, naming the library, the function, the parameter and its type. The library's mod-points.json records which parameters have a Wire form, so the refusal needs no decoding. The derive PRs test this on native and on wasm. With the derive, api listeners work across native and wasm, through the game.
    • access = "internals". The listener gets the receiver and every parameter by reference, in its own type. This works only within one target: the same binary on native, the same dependency group on wasm, where the library's types are ordinary Rust. It is refused at load when the listener's mod is on the other target, with "internals access to chemlib needs the same target". Engine internals is native-only because a wasm mod can't reach the game's memory; a wasm library and its wasm addons in one dependency group share memory and no engine type crosses, so the same principle allows internals there and refuses it across targets (owner, on #2339).
  • Generics. A generic function gets the api level only, with no raw form, as on the engine. The record is per point, not per instantiation. A parameter whose type is a type parameter needs T: Wire at that instantiation, or the listener is refused.

  • Signature check. Each mod point carries a hash, recorded in the library's mod-points.json inside its .hmod. Listeners pin it in their [[inject]] hash, written by cargo horizon-mod inject add. The hash is what engine internals use (internals::signature_hash in horizon-mod-api-gen: the first 8 bytes of SHA-256, as hex) over:

    1. the canonical path;
    2. the signature's token text: receiver, parameter names and types, return type;
    3. for api declarations, the layout of every record field's type (section 0), computed when the catalog is built, so a changed field in a parameter type also refuses.

    At load, a declaration whose hash differs from the installed library's is refused, naming the library, its version, the function, and both hashes and signatures. This is the E_INJECT_CHANGED path that inject update already reports. Engine points don't need this check at load, because a native mod is pinned to one game build.

  • Cost when nothing listens. The same check as an engine point: one load of the point's AtomicBool slot and a branch at the head, and one at the return; the listened path runs out of line. Measured on the chemicals fixture's per-tick transfer (a function of about 2 ns, 63 calls per tick): about 1.2 ns a call natively (1.7 → 2.9 ns, release) and 1.6–2 ns on wasm (2.9 → 4.5–4.9 ns, wasmtime). The rest of the cost is that the staged function is no longer inlined into its caller.

How cargo horizon-mod build stages it

It mirrors the engine's staging pass (horizon-mod-api-gen/src/points.rs, stage()):

  1. Walk the mod crate's module files, as module_files/file_functions do. Run eligible on each function and skip #[mod_api::no_point].
  2. Write a staged copy of the crate with #[point] on each function, plus each raw form's types beside it. The author's source is never edited. This is the copy Cargo compiles.
  3. Emit the mod's mod-points.json: path, kind, side, record, api_refusal, hash. Emit its mod_points-style module: records, typed slots, PointId. Both are build outputs, recorded in the build identity and covered by the package hash.
  4. For wasm, as now, compile into each member only the declared places' guest codecs and dispatch. The group build already knows its addons' declarations. The full catalog still sets the layout hash.

How it is built

Built as stage P (the how-to is the reference):

  1. Every point table, the game's and each mod crate's, is a Table in one registry (in the mod API's internal registration module). A listener carries its table, and the one seal sets every table that has listeners.
  2. #[point(table = "…")] expands against its table: the game's mod_points by default, a mod crate's generated crate::__horizon_points for a mod point. Mod points have no engine mirrors.
  3. stage_mod (horizon-mod-api-gen/src/points/mod_crate.rs) is the staging pass for a mod crate. cargo horizon-mod build --wasm runs it on every member, and the launcher on every native mod crate it compiles.
  4. The mod's catalog and the routing of an addon's injection to it (horizon-mod-package, manifest::inject::mod_points) are shared by every loader.
  5. The listened paths run out of line (in the internal cold dispatch module), so an unlistened point costs a slot load and a branch at its head and one at its return.
  6. Wasm members dynamically share each mod library, but each member links its own mod_api copy. Generated typed registration functions execute in the library member and build listeners in that member's registry; only listener function pointers and scalar metadata cross. The group registers every member's listeners before calling every library's seal, before mod initialization. Native continues to use its single shared registry directly.

Mixed native and wasm

internals points are refused across targets (above). api points will cross through the game, as an engine point with a wasm listener does (#2242's real_member), once mixed-target dependencies are supported (stage 6): until then an api listener across targets is refused, naming a parameter without a Wire form when there is one. There is no load-time module rewriting: it can't work on native, and it would miss code the compiler has inlined.

Where it lives after the restructure

Following the domain modules in the mod_api restructure: ModHook, Listen and Scoped/ServiceKey go in runtime, beside Flow, listened and the mod list. ModRegistry and RegistryEntry go in content, beside the registry keys. PartKind goes beside the Parts it shares a table with. The Registrar methods (declare_*, listen, attach, provide, register) are generated like the other registrar methods.

Staged PR plan

Sizes are estimates.

StageWhatSizeDepends on
0This note.docs—
D1–D2#[derive(Wire)], as section 0's plan. First.see section 0#2242; D1 before or with the restructure
PMod points (decided section above): #[point] generalised to mod crates, per-mod slot tables, #[mod_api::no_point], staging in cargo horizon-mod build, per-mod mod-points.json with signature hashes, the load check, the same-target rule for internals, the unpatched cost measured on the sample. Comes right after the derive.~1,500D1, #2242
1(after D1) An erased table shared by owner, kind and name: the declare-once and namespace refusals, type-agreement checks, and the load-phase "resolved table" call into wasm groups. Ported onto it: OwnProvider, with the capability key plan's name/type refusals.~600the restructure (lands into runtime)
2Mod hooks: ModHook, declare_hook, listen, the four contracts, Subscribers reused, the switch, crash attribution, the depth limit. Tests: a real wasm library and addon (real_library style) and the same source natively, covering order, cancel, receive_cancelled, a switched-off mod, an undeclared hook, type mismatch, and no listeners meaning no work. Sample: chem-api plus an addon listener in horizon-machine-sample.~9001, and #2242 merged (shares listener shape and order code)
3Mod registries: ModRegistry, slots through Names, sync during configuration, tags, saves by name.~9001; coordinate the key space with #2332
4Part kinds on the shared table, including registry scope.~6001, 3 (for registry scope)
5Scoped state and ServiceKey; AttachmentHolder::Server if question 4 says yes.~6001
6Mixed targets: the cross-target refusals with clear messages (they can land early and small), then cross-target hook forwarding and registry value reads over the derive's per-mod layouts.~300 + ~800D2, 2, 3; question 1
7Optional: #[mod_api::extension] cross-target trait calls.large6; an owner decision

Each stage updates this book page into a how-to and adds rustdoc. None of them touches point machinery before #2242 merges.

Open questions for root

  1. Mixed targets. Should a native library with wasm addons (or the reverse) be supported through the game, as proposed above? That needs a stateless "types crate" in the package model. The alternative is refusing mixed targets outright with a clear message. Recommendation: land the clear refusals first (stage 6a) and decide the types crate when a real library needs it.
  2. [[inject]] for mod hooks. Should mod hooks also be listenable through [[inject]] at = "<mod>:<hook>", which would make points and mod hooks one manifest model? Recommendation: code registration only, since it is typed and needs no slot.
  3. Capability keys and Part kinds. Should CapabilityKey be merged into PartKind (a part kind with a context and first-answer lookup)? Recommendation: keep NeoForge's name and API for capabilities and share only the storage.
  4. Server-wide saved data. Should AttachmentHolder::Server be added for state that belongs to a server and not a level? NeoForge's public saved data guide attaches such data to MinecraftServer#getDataStorage.
  5. Replacing a service provider. May a mod override another mod's service provider? Recommendation: no, refuse two providers, until a concrete case exists.
  6. The "registries frozen" moment. Which existing hook is the first after registration seals? If none fits, a Horizon-only hook is needed.
  7. Patching other mods' code: decided. Mod points mirror engine points (section above). Left for review: the opt-out attribute's name, #[mod_api::no_point].
  8. #[wire(via = T)]. Is one conversion attribute on the derive acceptable? The alternative is keeping about 25 more impls hand-written.